From 636ade0273595cb7669ec4310c234f0aace3e684 Mon Sep 17 00:00:00 2001 From: Claudio Maggioni Date: Tue, 7 Apr 2020 12:46:55 +0200 Subject: [PATCH] WIP on redux refactor --- smart-hut/package.json | 3 + smart-hut/src/client_server.js | 6 - smart-hut/src/index.js | 10 +- smart-hut/src/remote.js | 459 +++++++++++++++++++++++++++++++++ smart-hut/src/store.js | 137 ++++++++++ smart-hut/src/storeActions.js | 36 +++ smart-hut/yarn.lock | 36 ++- 7 files changed, 677 insertions(+), 10 deletions(-) create mode 100644 smart-hut/src/remote.js create mode 100644 smart-hut/src/store.js create mode 100644 smart-hut/src/storeActions.js diff --git a/smart-hut/package.json b/smart-hut/package.json index 9591d77..7a37c28 100644 --- a/smart-hut/package.json +++ b/smart-hut/package.json @@ -17,10 +17,13 @@ "react-circular-slider-svg": "^0.1.5", "react-device-detect": "^1.11.14", "react-dom": "^16.12.0", + "react-redux": "^7.2.0", "react-round-slider": "^1.0.1", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.0", + "redux": "^4.0.5", + "redux-thunk": "^2.3.0", "semantic-ui-react": "^0.88.2", "styled-components": "^5.0.1" }, diff --git a/smart-hut/src/client_server.js b/smart-hut/src/client_server.js index 2598b53..4218c1f 100644 --- a/smart-hut/src/client_server.js +++ b/smart-hut/src/client_server.js @@ -271,12 +271,6 @@ export var call = { headers: { Authorization: "Bearer " + tkn }, }); }, - deviceGetById: function (data, headers) { - return axios.get(config + data.device + "/" + data.id); - }, - deviceGetAll: function (data, headers) { - return axios.get(config + data.device); - }, smartPlugReset: function (id) { return axios.delete(config + "smartPlug/" + id + "/meter", { headers: { Authorization: "Bearer " + tkn }, diff --git a/smart-hut/src/index.js b/smart-hut/src/index.js index 12af31f..111847a 100644 --- a/smart-hut/src/index.js +++ b/smart-hut/src/index.js @@ -2,10 +2,14 @@ import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; import * as serviceWorker from "./serviceWorker"; -//React Router -//import { BrowserRouter, Route, Switch } from "react-router-dom"; +import { Provider } from "react-redux"; +import smartHutStore from "./store"; -const index = ; +const index = ( + + + +); ReactDOM.render(index, document.getElementById("root")); serviceWorker.unregister(); diff --git a/smart-hut/src/remote.js b/smart-hut/src/remote.js new file mode 100644 index 0000000..1115f69 --- /dev/null +++ b/smart-hut/src/remote.js @@ -0,0 +1,459 @@ +import smartHutStore from "./store"; +import actions from "./storeActions"; +import axios from "axios"; + +class Endpoint { + axiosInstance = axios.create({ + baseURL: this.URL, + validateStatus: (status) => status >= 200 && status < 300, + }); + + /** + * Returns the endpoint URL (SmartHut backend URL) + * @returns {String} endpoint URL + */ + static get URL() { + return window.BACKEND_URL !== "__BACKEND_URL__" + ? window.BACKEND_URL + : "http://localhost:8080"; + } + + /** + * Returns token for current session, null if logged out + * @returns {String|null} the token + */ + static get token() { + return smartHutStore.getState().login.token; + } + + /** + * Performs an authenticated request + * @param {get|post|put|delete} the desired method + * @param {String} route the desired route (e.g. "/rooms/1/devices") + * @param {[String]String} query query ('?') parameters (no params by default) + * @param {any} body the JSON request body + */ + static send(method, route, query = {}, body = null) { + if (!this.token) { + throw new Error("No token while performing authenticated request"); + } + + if (method !== "get") + return this.axiosInstance(route, { + method: method, + params: query, + data: ["put", "post"].indexOf(method) !== -1 ? body : null, + headers: { + Authorization: `Bearer ${this.token}`, + }, + }).then((res) => { + if (!res.data) { + console.error("Response body is empty"); + return null; + } else { + return res; + } + }); + } + + /** + * Performs an authenticated GET request + * @param {String} route the desired route (e.g. "/rooms/1/devices") + * @param {[String]String} query query ('?') parameters (no params by default) + */ + static get(route, query) { + return this.send("get", route, query); + } + + /** + * Performs an authenticated POST request + * @param {String} route the desired route (e.g. "/rooms/1/devices") + * @param {[String]String} query query ('?') parameters (no params by default) + * @param {any} body the JSON request body + */ + static post(route, query, body) { + return this.send("post", route, query, body); + } + + /** + * Performs an authenticated PUT request + * @param {String} route the desired route (e.g. "/rooms/1/devices") + * @param {[String]String} query query ('?') parameters (no params by default) + * @param {any} body the JSON request body + */ + static put(route, query, body) { + return this.send("put", route, query, body); + } + + /** + * Performs an authenticated DELETE request + * @param {get|post|put|delete} the desired method + * @param {String} route the desired route (e.g. "/rooms/1/devices") + * @param {[String]String} query query ('?') parameters (no params by default) + */ + static delete(route, query) { + return this.send("delete", route, query); + } +} + +/** + * Given an error response, returns an array of user + * friendly messages to display to the user + * @param {*} err the Axios error reponse object + * @returns {String[]} user friendly error messages + */ +function parseValidationErrors(err) { + if ( + err.response && + err.response.status === 400 && + err.response.data && + Array.isArray(err.response.data.errors) + ) { + return [...new Set(err.response.data.errors.map((e) => e.defaultMessage))]; + } else { + console.warn("Non validation error", err); + return ["Network error"]; + } +} + +export class RemoteService { + /** + * Performs login + * @param {String} usernameOrEmail + * @param {String} password + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static login(usernameOrEmail, password) { + return (dispatch) => { + return Endpoint.axiosInstance + .post(`${Endpoint.URL}/auth/login`, { + usernameOrEmail, + password, + }) + .then((res) => { + localStorage.setItem("token", res.token); + localStorage.setItem( + "exp", + new Date().getTime() + 5 * 60 * 60 * 1000 + ); + dispatch(actions.loginSuccess(res.token)); + }) + .catch((err) => { + console.warn("login error", err); + return [ + err.response.status === 401 + ? "Wrong credentials" + : "An error occurred while logging in", + ]; + }); + }; + } + + /** + * Performs logout + * @param {String} usernameOrEmail + * @param {String} password + */ + static logout() { + return (dispatch) => void dispatch(actions.logout()); + } + + /** + * Fetches user information via REST calls, if it is logged in + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static fetchUserInfo() { + return (dispatch) => { + return Endpoint.get("/auth/profile") + .then((res) => void dispatch(actions.userInfoUpdate(res.data))) + .catch((err) => { + console.warn("Fetch user info error", err); + return ["Network error"]; + }); + }; + } + + /** + * Fetches all rooms that belong to this user. This call does not + * populate the devices attribute in rooms. + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static fetchAllRooms() { + return (dispatch) => { + return Endpoint.get("/room") + .then((res) => void dispatch(actions.roomsUpdate(res.data))) + .catch((err) => { + console.error("Fetch all rooms error", err); + return ["Network error"]; + }); + }; + } + + /** + * Fetches all devices in a particular room, or fetches all devices. + * This also updates the devices attribute on values in the map rooms. + * @param {Number|null} roomId the room to which fetch devices + * from, null to fetch from all rooms + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static fetchDevices(roomId = null) { + return (dispatch) => { + return Endpoint.get(roomId ? `/room/${roomId}/device` : "/device") + .then((res) => void dispatch(actions.devicesUpdate(roomId, res.data))) + .catch((err) => { + console.error(`Fetch devices roomId=${roomId} error`, err); + return ["Network error"]; + }); + }; + } + + /** + * Creates/Updates a room with the given data + * @param {String} data.name the room's name, + * @param {String} data.icon the room's icon name in SemanticUI icons + * @param {String} data.image ths room's image, as base64 + * @param {Number|null} roomId the room's id if update, null for creation + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static saveRoom(data, roomId = null) { + return (dispatch) => { + data = { + name: data.name, + icon: data.icon, + image: data.image, + }; + + return (roomId + ? Endpoint.put(`/room/${roomId}`, {}, data) + : Endpoint.post(`/room`, {}, data) + ) + .then((res) => void dispatch(actions.roomSave(res.data))) + .catch(parseValidationErrors); + }; + } + + /** + * Creates/Updates a device with the given data. If + * data.id is truthy, then a update call is performed, + * otherwise a create call is performed. The update URL + * is computed based on data.kind when data.flowType = + * 'OUTPUT', otherwise the PUT "/device" endpoint + * is used for updates and the POST "/" + * endpoints are used for creation. + * @param {Device} data the device to update. + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static updateDevice(data) { + return (dispatch) => { + let url = "/device"; + if ((data.id && data.flowType === "OUTPUT") || !data.id) { + url = "/" + data.kind; + } + + return Endpoint[data.id ? "put" : "post"](url, {}, data) + .then((res) => void dispatch(actions.deviceUpdate(res.data))) + .catch((err) => { + console.warn("Update device: ", data, "error: ", err); + return ["Network error"]; + }); + }; + } + + static _operateInput(url, getUrl, action) { + return (dispatch) => { + return Endpoint.put(url, {}, action) + .then(async (res) => { + const inputDevice = Endpoint.get(getUrl); + delete inputDevice.outputs; + dispatch(actions.deviceOperationUpdate([...res.data, inputDevice])); + }) + .catch((err) => { + console.warn(`${url} error`, err); + return ["Network error"]; + }); + }; + } + + /** + * Changes the state of a switch, by turning it on, off or toggling it. + * + * @typedef {"ON" | "OFF" | "TOGGLE"} SwitchOperation + * + * @param {Number} switchId the switch device id + * @param {SwitchOperation} type the operation to perform on the switch + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static switchOperate(switchId, type) { + return this._operateInput("/switch/operate", `/switch/${switchId}`, { + type: type.toUpperCase(), + id: switchId, + }); + } + + /** + * Turns a knob dimmer to a specific amount + * + * @param {Number} dimmerId the knob dimmer id + * @param {number} intensity the absolute intensity to dim to. Must be >=0 and <= 100. + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static knobDimmerDimTo(dimmerId, intensity) { + return this._operateInput("/knobDimmer/dimTo", `/knobDimmer/${dimmerId}`, { + intensity, + id: dimmerId, + }); + } + + /** + * Turns a button dimmer up or down + * + * @typedef {"UP" | "DOWN"} ButtonDimmerDimType + * + * @param {Number} dimmerId the button dimmer id + * @param {ButtonDimmerDimType} dimType the type of dim to perform + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static buttonDimmerDim(dimmerId, dimType) { + return this._operateInput( + "/buttonDimmer/dim", + `/buttonDimmer/${dimmerId}`, + { + dimType, + id: dimmerId, + } + ); + } + + /** + * Resets the meter on a smart plug + * + * @param {Number} smartPlugId the smart plug to reset + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static smartPlugReset(smartPlugId) { + return (dispatch) => { + return Endpoint.delete(`/smartPlug/${smartPlugId}/meter`) + .then((res) => dispatch(actions.deviceOperationUpdate([res.data]))) + .catch((err) => { + console.warn(`Smartplug reset error`, err); + return ["Network error"]; + }); + }; + } + + /** + * Deletes a room + * @param {Number} roomId the id of the room to delete + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static deleteRoom(roomId) { + return (dispatch) => { + Endpoint.delete(`/room/${roomId}`) + .then((_) => dispatch(actions.roomDelete(roomId))) + .catch((err) => { + console.warn("Room deletion error", err); + return ["Network error"]; + }); + }; + } + + /** + * Deletes a device + * @param {Number} deviceId the id of the device to delete + * @returns {Promise} promise that resolves to void and rejects + * with user-fiendly errors as a String array + */ + static deleteDevice(deviceId) { + return (dispatch) => { + Endpoint.delete(`/device/${deviceId}`) + .then((_) => dispatch(actions.deviceDelete(deviceId))) + .catch((err) => { + console.warn("Device deletion error", err); + return ["Network error"]; + }); + }; + } +} + +export class Forms { + /** + * Attempts to create a new user from the given data. + * This method does not update the global state, + * please check its return value. + * @param {String} data.username the chosen username + * @param {String} data.password the chosen password + * @param {String} data.email the chosen email + * @param {String} data.name the chosen full name + * @returns {Promise} promise that resolves to void and rejects + * with validation errors as a String array + */ + static submitRegistration(data) { + return Endpoint.post( + "/register", + {}, + { + username: data.username, + password: data.password, + name: data.name, + email: data.email, + } + ) + .then((_) => void 0) + .catch(parseValidationErrors); + } + + /** + * Sends a request to perform a password reset. + * This method does not update the global state, + * please check its return value. + * @param {String} email the email to which perform the reset + * @returns {Promise} promise that resolves to void and rejects + * with validation errors as a String array + */ + static submitInitResetPassword(email) { + return Endpoint.post( + "/register/init-reset-password", + {}, + { + usernameOrEmail: email, + } + ) + .then((_) => void 0) + .catch((err) => { + console.warn("Init reset password failed", err); + return ["Network error"]; + }); + } + + /** + * Sends the password for the actual password reset, haviug already + * performed email verification + * This method does not update the global state, + * please check its return value. + * @param {String} password the new password + * @returns {Promise} promise that resolves to void and rejects + * with validation errors as a String array + */ + static submitResetPassword(password) { + return Endpoint.post( + "/register/reset-password", + {}, + { + password, + } + ) + .then((_) => void 0) + .catch(parseValidationErrors); + } +} diff --git a/smart-hut/src/store.js b/smart-hut/src/store.js new file mode 100644 index 0000000..47c64cc --- /dev/null +++ b/smart-hut/src/store.js @@ -0,0 +1,137 @@ +import { createStore } from "redux"; +import { RemoteService } from "./remote"; +import { createDispatchHook } from "react-redux"; +import actions from "./storeActions"; + +const initialToken = localStorage.getItem("token"); + +const initialState = { + login: { + loggedIn: false, + token: initialToken ? initialToken : null, + }, + userInfo: null, + /** @type {[integer]Room} */ + rooms: {}, + /** @type {[integer]Device} */ + devices: {}, +}; + +function reducer(previousState, action) { + let newState = Object.assign({}, previousState); + const createOrUpdateRoom = (room) => { + if (!(room.id in newState.rooms)) { + newState.rooms[room.id] = room; + newState.rooms[room.id].devices = new Set(); + } else { + newState.rooms[room.id].name = room.name; + newState.rooms[room.id].image = room.image; + newState.rooms[room.id].icon = room.icon; + } + }; + + switch (action.type) { + case "LOGIN_UPDATE": + newState.login = action.login; + delete newState.errors.login; + break; + case "USER_INFO_UPDATE": + newState.user = action.user; + delete newState.errors.userInfo; + break; + case "ROOMS_UPDATE": + for (const room of action.rooms) { + createOrUpdateRoom(room); + } + delete newState.errors.rooms; + break; + case "DEVICES_UPDATE": + // if room is given, delete all devices in that room + // and remove any join between that room and deleted + // devices + if (action.roomId) { + const room = newState.rooms[action.roomId]; + for (const deviceId of room.devices) { + delete newState.devices[deviceId]; + } + room.devices = []; + } else if (action.partial) { + // if the update is partial and caused by an operation on an input + // device (like /switch/operate), iteratively remove deleted + // devices and their join with their corresponding room. + for (const device of action.devices) { + const room = newState.rooms[newState.devices[device.id].roomId]; + room.devices.delete(device.id); + delete newState.devices[device.id]; + } + } else { + // otherwise, just delete all devices and all joins + // between rooms and devices + newState.devices = {}; + for (const room of newState.rooms) { + room.devices = []; + } + } + + for (const device of action.devices) { + newState.devices[device.id] = device; + + if (device.roomId in newState.rooms) { + newState.rooms[device.roomId].devices.add(device.id); + } else { + console.warn( + "Cannot join device", + device, + `in room ${device.roomId} since that + room has not been fetched` + ); + } + } + + delete newState.errors.devices; + break; + case "ROOM_SAVE": + createOrUpdateRoom(action.room); + break; + case "ROOM_DELETE": + if (!(actions.roomId in newState.rooms)) { + console.warn(`Room to delete ${actions.roomId} does not exist`); + break; + } + + // This update does not ensure the consistent update of switchId/dimmerId properties + // on output devices connected to an input device in this room. Please manually request + // all devices again if consistent update is desired + for (const id of newState.rooms[action.roomId].devices) { + delete newState.devices[id]; + } + + delete newState.rooms[action.roomId]; + break; + case "DEVICE_DELETE": + if (!(actions.deviceId in newState.devices)) { + console.warn(`Device to delete ${actions.deviceId} does not exist`); + break; + } + + newState.rooms[newState.devices[actions.deviceId].roomId].devices.delete( + actions.deviceId + ); + delete newState.devices[actions.deviceId]; + break; + case "LOGOUT": + newState.login = { token: null, loggedIn: false }; + newState.rooms = []; + newState.devices = []; + delete newState.errors.login; + break; + default: + console.warn(`Action type ${action.type} unknown`, action); + } + + return newState; +} + +const smartHutStore = createStore(reducer, initialState); + +export default smartHutStore; diff --git a/smart-hut/src/storeActions.js b/smart-hut/src/storeActions.js new file mode 100644 index 0000000..58c24ed --- /dev/null +++ b/smart-hut/src/storeActions.js @@ -0,0 +1,36 @@ +const actions = { + loginSuccess: (token) => ({ + type: "LOGIN_UPDATE", + login: { + loggedIn: true, + token, + }, + }), + logout: () => ({ + type: "LOGOUT", + }), + userInfoUpdate: (userInfo) => ({ + type: "USER_INFO_UPDATE", + userInfo, + }), + roomSave: (room) => ({ + type: "ROOM_SAVE", + room, + }), + devicesUpdate: (roomId, devices, partial = false) => ({ + type: "DEVICES_UPDATE", + roomId, + devices, + partial, + }), + roomDelete: (roomId) => ({ + type: "ROOM_DELETE", + roomId, + }), + deviceDelete: (deviceId) => ({ + type: "DEVICE_DELETE", + deviceId, + }), +}; + +export default actions; diff --git a/smart-hut/yarn.lock b/smart-hut/yarn.lock index 9f36482..d625164 100644 --- a/smart-hut/yarn.lock +++ b/smart-hut/yarn.lock @@ -5011,7 +5011,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -8747,6 +8747,11 @@ react-is@^16.8.1, react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-is@^16.9.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-popper@^1.3.4: version "1.3.7" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324" @@ -8760,6 +8765,17 @@ react-popper@^1.3.4: typed-styles "^0.0.7" warning "^4.0.2" +react-redux@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" + integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== + dependencies: + "@babel/runtime" "^7.5.5" + hoist-non-react-statics "^3.3.0" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.9.0" + react-round-slider@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/react-round-slider/-/react-round-slider-1.0.1.tgz#2f6f14f4e7ce622cc7e450911a163b5841b3fd88" @@ -8980,6 +8996,19 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" @@ -10100,6 +10129,11 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"