Merge branch 'update-js-toolchain' into 'v3'

Update js toolchain

See merge request openflexure/openflexure-microscope-server!431
This commit is contained in:
Julian Stirling 2025-11-04 17:10:28 +00:00
commit c2b8706fb5
48 changed files with 6656 additions and 9629 deletions

View file

@ -161,7 +161,6 @@ build:
- .javascript_webapp - .javascript_webapp
- .rules_common - .rules_common
script: script:
- export NODE_OPTIONS=--openssl-legacy-provider
# Build JS application for production # Build JS application for production
- npm run build - npm run build
artifacts: artifacts:

View file

@ -5,14 +5,19 @@ module.exports = {
node: true, node: true,
}, },
parser: "vue-eslint-parser",
parserOptions: { parserOptions: {
parser: "babel-eslint", parser: "@babel/eslint-parser",
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: "module",
}, },
extends: ["plugin:vue/recommended", "eslint:recommended", "@vue/prettier"],
rules: { rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
}, },
extends: ["plugin:vue/recommended", "eslint:recommended", "@vue/prettier"],
}; };

View file

@ -18,12 +18,9 @@
_Note: If you are using a Windows OS, the following commands must be executed in a Powershell terminal._ _Note: If you are using a Windows OS, the following commands must be executed in a Powershell terminal._
* Install Node.js (and npm) * Install Node.js v18 (and npm)
* Install dependencies with `npm install`
* Node v18 changes SSL, and so you need a legacy version for compatibility:
* On Windows: `$env:NODE_OPTIONS = "--openssl-legacy-provider"`
* On Linux/MacOS `export NODE_OPTIONS=--openssl-legacy-provider`
* Navigate into the webapp directory with `cd webapp` * Navigate into the webapp directory with `cd webapp`
* Install dependencies with `npm install`
* Build the static web app with `npm run build` * Build the static web app with `npm run build`
# Developing # Developing

15759
webapp/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,34 +7,37 @@
"scripts": { "scripts": {
"gv": "genversion src/version.js", "gv": "genversion src/version.js",
"serve": "npm run gv && vue-cli-service serve --mode development", "serve": "npm run gv && vue-cli-service serve --mode development",
"build": "npm run gv && vue-cli-service build --openssl-legacy-provider --mode production", "build": "npm run gv && vue-cli-service build --mode production",
"lint": "vue-cli-service lint --no-fix --max-warnings 0", "lint": "vue-cli-service lint --no-fix --max-warnings 0",
"lint:fix": "vue-cli-service lint --fix" "lint:fix": "vue-cli-service lint --fix"
}, },
"dependencies": { "dependencies": {
"material-design-icons": "^3.0", "material-design-icons": "^3.0",
"material-symbols": "^0.14.3",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"openseadragon": "^5.0.0", "openseadragon": "^5.0.0",
"vue-observe-visibility": "^0.4" "vue-observe-visibility": "^0.4"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^3.12", "@babel/eslint-parser": "^7.28.5",
"@vue/cli-plugin-eslint": "^4.5", "@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-service": "^4.5", "@vue/cli-plugin-eslint": "^5.0.9",
"@vue/eslint-config-prettier": "^6.0", "@vue/cli-plugin-router": "^5.0.9",
"axios": "^0.21", "@vue/cli-plugin-vuex": "^5.0.9",
"babel-eslint": "^10.1", "@vue/cli-service": "^5.0.9",
"@vue/eslint-config-prettier": "^7.1.0",
"axios": "^1.13.1",
"core-js": "^3.46.0",
"css-loader": "^3.6", "css-loader": "^3.6",
"eslint": "^6.8", "eslint": "^7.32.0",
"eslint-plugin-prettier": "^3.4", "eslint-config-prettier": "^8.10.2",
"eslint-plugin-vue": "^6.2", "eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-vue": "^7.20.0",
"genversion": "^3.2.0", "genversion": "^3.2.0",
"less": "^3.13", "less": "^4.4.2",
"less-loader": "^5.0", "less-loader": "^11.1.4",
"mdns-js": "^1.0", "material-symbols": "^0.39.1",
"prettier": "^1.19", "prettier": "^2.8.8",
"uikit": "^3.17", "uikit": "^3.24.2",
"vue": "^2.7", "vue": "^2.7",
"vue-template-compiler": "^2.7", "vue-template-compiler": "^2.7",
"vuejs-paginate": "^2.1", "vuejs-paginate": "^2.1",

View file

@ -98,7 +98,7 @@ export default {
this.systemDark = true; this.systemDark = true;
} }
// Create a theme observer to watch for changes // Create a theme observer to watch for changes
this.themeObserver = mql.addListener(e => { this.themeObserver = mql.addListener((e) => {
if (e.matches) { if (e.matches) {
this.systemDark = true; this.systemDark = true;
} else { } else {
@ -131,7 +131,7 @@ export default {
// Arrow keys // Arrow keys
Mousetrap.bind( Mousetrap.bind(
["up", "down", "left", "right"], ["up", "down", "left", "right"],
event => { (event) => {
this.arrowKeysDown[event.keyCode] = true; //Add key to array this.arrowKeysDown[event.keyCode] = true; //Add key to array
this.navigateKeyHandler(); this.navigateKeyHandler();
}, },
@ -139,7 +139,7 @@ export default {
); );
Mousetrap.bind( Mousetrap.bind(
["up", "down", "left", "right"], ["up", "down", "left", "right"],
event => { (event) => {
delete this.arrowKeysDown[event.keyCode]; //Remove key from array delete this.arrowKeysDown[event.keyCode]; //Remove key from array
}, },
"keyup", "keyup",

View file

@ -22,7 +22,7 @@
> >
<img <img
v-if="item.iconURL" v-if="item.iconURL"
style="filter: grayscale(100%);width: 22px;margin-top: 5px;margin-bottom: 8px;" style="filter: grayscale(100%); width: 22px; margin-top: 5px; margin-bottom: 8px"
:src="item.iconURL" :src="item.iconURL"
/> />
<span v-if="!item.iconURL" class="material-symbols-outlined"> <span v-if="!item.iconURL" class="material-symbols-outlined">
@ -182,7 +182,7 @@ export default {
}, },
]; ];
if (!this.$store.state.galleryEnabled) { if (!this.$store.state.galleryEnabled) {
tabs = tabs.filter(tab => tab.id != "gallery"); tabs = tabs.filter((tab) => tab.id != "gallery");
} }
return tabs; return tabs;
}, },
@ -196,7 +196,7 @@ export default {
mounted() { mounted() {
// A global signal listener to switch tab // A global signal listener to switch tab
this.$root.$on("globalSwitchTab", tabID => { this.$root.$on("globalSwitchTab", (tabID) => {
this.currentTab = tabID; this.currentTab = tabID;
}); });
// A global signal listener to increment tab // A global signal listener to increment tab

View file

@ -245,13 +245,13 @@ export default {
} }
// Check for a task that is ongoing. // Check for a task that is ongoing.
// We can't handle multiple tasks ongoing, so this picks the first. // We can't handle multiple tasks ongoing, so this picks the first.
const ongoingTask = response.data.find(t => ["pending", "running"].includes(t.status)); const ongoingTask = response.data.find((t) => ["pending", "running"].includes(t.status));
if (ongoingTask) { if (ongoingTask) {
// There is a started task // There is a started task
this.taskStarted = true; this.taskStarted = true;
this.$emit("taskStarted"); this.$emit("taskStarted");
// Find its URL // Find its URL
const taskUrl = ongoingTask.links.find(t => t.rel == "self").href; const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href;
try { try {
await this.pollOngoingTask(ongoingTask.id, taskUrl); await this.pollOngoingTask(ongoingTask.id, taskUrl);
} catch (error) { } catch (error) {
@ -332,7 +332,7 @@ export default {
var checkCondition = (resolve, reject) => { var checkCondition = (resolve, reject) => {
// If the condition is met, we're done! // If the condition is met, we're done!
axios.get(this.taskUrl, { baseURL: this.$store.getters.baseUri }).then(response => { axios.get(this.taskUrl, { baseURL: this.$store.getters.baseUri }).then((response) => {
var result = response.data.status; var result = response.data.status;
this.taskStatus = result; this.taskStatus = result;
if ((result == "running") | (result == "pending")) { if ((result == "running") | (result == "pending")) {

View file

@ -17,9 +17,7 @@
</div> </div>
<!-- Paused banner outside scroll container, positioned relative to wrapper --> <!-- Paused banner outside scroll container, positioned relative to wrapper -->
<div v-if="userIsHovering" class="paused-banner"> <div v-if="userIsHovering" class="paused-banner">Auto-scroll paused</div>
Auto-scroll paused
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -162,7 +162,7 @@ export default {
return "number_array"; return "number_array";
} }
if (Array.isArray(prop.items)) { if (Array.isArray(prop.items)) {
if (prop.items.every(t => num_types.includes(t.type))) { if (prop.items.every((t) => num_types.includes(t.type))) {
return "number_array"; return "number_array";
} }
} }

View file

@ -90,7 +90,7 @@ export default {
this.value = requestedValue; this.value = requestedValue;
await this.writeThingProperty(this.thingName, this.propertyName, requestedValue); await this.writeThingProperty(this.thingName, this.propertyName, requestedValue);
if (this.readBack) { if (this.readBack) {
await new Promise(r => setTimeout(r, this.readBackDelay)); await new Promise((r) => setTimeout(r, this.readBackDelay));
let newVal = await this.readProperty(); let newVal = await this.readProperty();
if (newVal == requestedValue) { if (newVal == requestedValue) {
this.animate = true; this.animate = true;

View file

@ -1,9 +1,7 @@
<template> <template>
<div> <div>
<p> <p>
<b> <b> Before starting camera calibration: </b>
Before starting camera calibration:
</b>
</p> </p>
<ul class="uk-list uk-list-bullet"> <ul class="uk-list uk-list-bullet">
<li>Remove any samples from your microscope</li> <li>Remove any samples from your microscope</li>

View file

@ -1,12 +1,8 @@
<template> <template>
<div> <div>
<p>Camera-stage mapping will calibrate the stage movement using the camera.</p>
<p> <p>
Camera-stage mapping will calibrate the stage movement using the camera. <b> Before starting camera-stage mapping: </b>
</p>
<p>
<b>
Before starting camera-stage mapping:
</b>
</p> </p>
<ul class="uk-list uk-list-bullet"> <ul class="uk-list uk-list-bullet">
<li>Add a sample with dense features to the microscope.</li> <li>Add a sample with dense features to the microscope.</li>

View file

@ -1,8 +1,6 @@
<template> <template>
<stepTemplateWithStream> <stepTemplateWithStream>
<p> <p>Use the buttons below to bring the sample into focus.</p>
Use the buttons below to bring the sample into focus.
</p>
<p>You may also adjust the z position with <b>page up</b> and <b>page down</b>.</p> <p>You may also adjust the z position with <b>page up</b> and <b>page down</b>.</p>
<template #below-stream> <template #below-stream>
<div class="action-button-container"> <div class="action-button-container">

View file

@ -3,12 +3,8 @@
<p> <p>
<b>Calibration complete</b> <b>Calibration complete</b>
</p> </p>
<p> <p>You'll need to repeat these steps from the Settings tab if you swap your objective.</p>
You'll need to repeat these steps from the Settings tab if you swap your objective. <p>Click Finish to return to your microscope.</p>
</p>
<p>
Click Finish to return to your microscope.
</p>
</div> </div>
</template> </template>

View file

@ -1,9 +1,7 @@
<template> <template>
<div> <div>
<p> <p>
<b> <b> Some important microscope calibration data is currently missing. </b>
Some important microscope calibration data is currently missing.
</b>
</p> </p>
<p> <p>
Your microscope will still function, however some functionality will be limited, and image Your microscope will still function, however some functionality will be limited, and image

View file

@ -7,9 +7,7 @@
<input v-model="reloadWhenOverridingOrigin" class="uk-input uk-checkbox" type="checkbox" /> <input v-model="reloadWhenOverridingOrigin" class="uk-input uk-checkbox" type="checkbox" />
Reload web app with new origin Reload web app with new origin
</label> </label>
<button class="uk-button uk-button-default uk-margin-small"> <button class="uk-button uk-button-default uk-margin-small">Apply</button>
Apply
</button>
</form> </form>
</div> </div>
</template> </template>

View file

@ -50,9 +50,7 @@
<hr /> <hr />
</div> </div>
<div v-else-if="$store.state.waiting"> <div v-else-if="$store.state.waiting">Loading...</div>
Loading...
</div>
<div v-else-if="$store.state.error"><b>Error:</b> {{ $store.state.error }}</div> <div v-else-if="$store.state.error"><b>Error:</b> {{ $store.state.error }}</div>
<div v-else>No active connection</div> <div v-else>No active connection</div>
</div> </div>

View file

@ -139,9 +139,7 @@
</div> </div>
<div v-else class="uk-text-warning"> <div v-else class="uk-text-warning">
<p>Stage positioning is disabled since no stage is connected.</p> <p>Stage positioning is disabled since no stage is connected.</p>
<p> <p>Please check all data and power connections to your motor controller board.</p>
Please check all data and power connections to your motor controller board.
</p>
</div> </div>
</div> </div>
</template> </template>
@ -235,7 +233,7 @@ export default {
methods: { methods: {
timeout(ms) { timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}, },
async move(x, y, z, absolute) { async move(x, y, z, absolute) {
// Move the stage, by updating the controls and starting a move task // Move the stage, by updating the controls and starting a move task
@ -281,7 +279,7 @@ export default {
.then(() => { .then(() => {
this.updatePosition(); // Update the position in text boxes this.updatePosition(); // Update the position in text boxes
}) })
.catch(error => { .catch((error) => {
this.modalError(error); // Let mixin handle error this.modalError(error); // Let mixin handle error
}) })
.then(() => { .then(() => {

View file

@ -1,6 +1,6 @@
<template> <template>
<div v-observe-visibility="visibilityChanged"> <div v-observe-visibility="visibilityChanged">
<div id="openseadragon"></div> <div id="openseadragon" ref="osdContainer"></div>
</div> </div>
</template> </template>
@ -76,8 +76,9 @@ export default {
if (this.osdViewer) { if (this.osdViewer) {
this.osdViewer.destroy(); this.osdViewer.destroy();
} }
await this.$nextTick();
this.osdViewer = OpenSeaDragon({ this.osdViewer = OpenSeaDragon({
id: "openseadragon", element: this.$refs.osdContainer,
crossOriginPolicy: "Anonymous", crossOriginPolicy: "Anonymous",
tileSources: this.src, tileSources: this.src,
showNavigationControl: false, showNavigationControl: false,

View file

@ -35,9 +35,7 @@
button-label="Download JPEG" button-label="Download JPEG"
/> />
</div> </div>
<button class="uk-button uk-button-default uk-width-1-1" @click="deleteScan"> <button class="uk-button uk-button-default uk-width-1-1" @click="deleteScan">Delete</button>
Delete
</button>
<action-button <action-button
v-if="scanData.can_stitch | (scanData.stitch_available & !scanData.dzi)" v-if="scanData.can_stitch | (scanData.stitch_available & !scanData.dzi)"
submit-label="Stitch Images" submit-label="Stitch Images"
@ -121,14 +119,8 @@ export default {
let yyyy = d.getFullYear(); let yyyy = d.getFullYear();
let mm = d.getMonth() + 1; let mm = d.getMonth() + 1;
let dd = d.getDate(); let dd = d.getDate();
let HH = d let HH = d.getHours().toString().padStart(2, "0");
.getHours() let MM = d.getMinutes().toString().padStart(2, "0");
.toString()
.padStart(2, "0");
let MM = d
.getMinutes()
.toString()
.padStart(2, "0");
return `${HH}:${MM} ${dd}/${mm}/${yyyy}`; return `${HH}:${MM} ${dd}/${mm}/${yyyy}`;
}, },
formatDuration(duration) { formatDuration(duration) {

View file

@ -18,9 +18,7 @@
:button-primary="false" :button-primary="false"
:modal-progress="true" :modal-progress="true"
:requires-confirmation="true" :requires-confirmation="true"
:confirmation-message=" :confirmation-message="'<h3>Stitch all unstitched scans?</h3><br>Depending on the number and size of scans, this may be slow, and your microscope should not be used during the stitching.'"
'<h3>Stitch all unstitched scans?</h3><br>Depending on the number and size of scans, this may be slow, and your microscope should not be used during the stitching.'
"
@error="modalError" @error="modalError"
/> />
</div> </div>
@ -62,9 +60,7 @@
<div class="gallery-grid uk-grid-match" uk-grid> <div class="gallery-grid uk-grid-match" uk-grid>
<div v-if="scansEmpty"> <div v-if="scansEmpty">
<h2>No scans available</h2> <h2>No scans available</h2>
<p> <p>There are no scans available to show.</p>
There are no scans available to show.
</p>
</div> </div>
<div v-for="scanData in paginatedScans" :key="scanData.id"> <div v-for="scanData in paginatedScans" :key="scanData.id">
<scan-card <scan-card
@ -168,7 +164,7 @@ export default {
(state, getters) => { (state, getters) => {
return getters.ready; return getters.ready;
}, },
ready => { (ready) => {
if (ready) { if (ready) {
// If the connection is now ready, update capture list // If the connection is now ready, update capture list
this.updateScans(); this.updateScans();
@ -205,7 +201,7 @@ export default {
if (!scans | (scans.length == 0)) { if (!scans | (scans.length == 0)) {
this.scans = scans; this.scans = scans;
} }
scans.forEach(scan => { scans.forEach((scan) => {
scan.can_stitch = !scan.stitch_available && scan.number_of_images > 3; scan.can_stitch = !scan.stitch_available && scan.number_of_images > 3;
}); });
scans.sort((a, b) => { scans.sort((a, b) => {

View file

@ -6,9 +6,7 @@
:button-primary="true" :button-primary="true"
:can-terminate="true" :can-terminate="true"
:requires-confirmation="true" :requires-confirmation="true"
:confirmation-message=" :confirmation-message="'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'"
'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'
"
thing="camera_stage_mapping" thing="camera_stage_mapping"
action="calibrate_xy" action="calibrate_xy"
:submit-label="'Auto-Calibrate Using Camera'" :submit-label="'Auto-Calibrate Using Camera'"
@ -26,9 +24,9 @@
> >
Download Calibration Data Download Calibration Data
</button> </button>
<div v-if="csmMatrix != 'undefined'" style="margin:10px;"> <div v-if="csmMatrix != 'undefined'" style="margin: 10px">
<details <details>
><summary>Calibration Details</summary> <summary>Calibration Details</summary>
<strong>CSM calculated for images with a resolution of {{ csmResolution }}</strong> <strong>CSM calculated for images with a resolution of {{ csmResolution }}</strong>
<ul> <ul>
<li> <li>

View file

@ -4,16 +4,12 @@
<div> <div>
<div class="uk-margin"> <div class="uk-margin">
<p>Your z motor is currently {{ z_inverted }} inverted.</p> <p>Your z motor is currently {{ z_inverted }} inverted.</p>
<p> <p>We expect that moving in +z:</p>
We expect that moving in +z:
</p>
<ul> <ul>
<li>Moves your objective up, towards the sample and illumination</li> <li>Moves your objective up, towards the sample and illumination</li>
<li>Turns the exposed z gear anti-clockwise (when viewed from above)</li> <li>Turns the exposed z gear anti-clockwise (when viewed from above)</li>
</ul> </ul>
<p> <p>If this is not the case, click the button below to switch.</p>
If this is not the case, click the button below to switch.
</p>
<div class="uk-margin"> <div class="uk-margin">
<div class="uk-margin"> <div class="uk-margin">
<action-button <action-button

View file

@ -6,9 +6,7 @@
><input v-model="disableStream" class="uk-checkbox" type="checkbox" /> Disable Web ><input v-model="disableStream" class="uk-checkbox" type="checkbox" /> Disable Web
Stream</label Stream</label
> >
<p class="uk-margin-small"> <p class="uk-margin-small">This will disable the embedded web stream of the camera.</p>
This will disable the embedded web stream of the camera.
</p>
</div> </div>
</div> </div>
</template> </template>

View file

@ -1,7 +1,5 @@
<template> <template>
<div v-if="!backendOK" class="uk-alert-danger"> <div v-if="!backendOK" class="uk-alert-danger">No scan back-end found.</div>
No scan back-end found.
</div>
<!-- Grid managing tab content --> <!-- Grid managing tab content -->
<div v-else uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <div v-else uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small"> <div class="control-component uk-padding-small">
@ -101,9 +99,7 @@
</div> </div>
</div> </div>
<div v-show="scanning"> <div v-show="scanning">
<h2 v-if="displayImageOnRight" style="text-align: center;"> <h2 v-if="displayImageOnRight" style="text-align: center">Live stitching preview</h2>
Live stitching preview
</h2>
<mini-stream-display v-if="displayImageOnRight" /> <mini-stream-display v-if="displayImageOnRight" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" /> <action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<action-progress-bar :progress="progress" :task-status="taskStatus" /> <action-progress-bar :progress="progress" :task-status="taskStatus" />

View file

@ -198,5 +198,5 @@ Vue.mixin({
new Vue({ new Vue({
store, store,
render: h => h(App), render: (h) => h(App),
}).$mount("#app"); }).$mount("#app");

View file

@ -146,14 +146,14 @@ export default new Vuex.Store({
actions: {}, actions: {},
getters: { getters: {
baseUri: state => state.origin, baseUri: (state) => state.origin,
ready: state => state.available, ready: (state) => state.available,
}, },
plugins: [ plugins: [
store => { (store) => {
// Load initial state from localStorage // Load initial state from localStorage
LOCALSTORAGE_KEYS.forEach(key => { LOCALSTORAGE_KEYS.forEach((key) => {
console.log(key); console.log(key);
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved !== null) { if (saved !== null) {

View file

@ -28,12 +28,7 @@ export const wotStoreModule = {
// Deduplication should be done elsewhere. // Deduplication should be done elsewhere.
let response = await axios.get(uri); let response = await axios.get(uri);
let td = response.data; let td = response.data;
let thing_name = let thing_name = name | uri.replace(/\/$/, "").split("/").pop();
name |
uri
.replace(/\/$/, "")
.split("/")
.pop();
commit("addThingDescription", { commit("addThingDescription", {
thingName: thing_name, thingName: thing_name,
thingDescription: td, thingDescription: td,
@ -53,16 +48,18 @@ export const wotStoreModule = {
}, },
}, },
getters: { getters: {
thingDescriptions: state => { thingDescriptions: (state) => {
return state.thingDescriptions; return state.thingDescriptions;
}, },
thingDescription: state => thingName => { thingDescription: (state) => (thingName) => {
return state.thingDescriptions[thingName]; return state.thingDescriptions[thingName];
}, },
thingAvailable: state => thingName => { thingAvailable: (state) => (thingName) => {
return thingName in state.thingDescriptions; return thingName in state.thingDescriptions;
}, },
thingFormUrl: state => (thing, affordanceType, affordance, op, allowUndefined = true) => { thingFormUrl:
(state) =>
(thing, affordanceType, affordance, op, allowUndefined = true) => {
// Find the URL for a particular operation // Find the URL for a particular operation
let td = state.thingDescriptions[thing]; let td = state.thingDescriptions[thing];
if (!td) { if (!td) {
@ -100,7 +97,7 @@ export function findFormHref(affordance, op) {
// Find the form in the affordance that matches the given operation type // Find the form in the affordance that matches the given operation type
if (affordance == undefined) return undefined; if (affordance == undefined) return undefined;
let forms = affordance.forms; let forms = affordance.forms;
let matchingForm = forms.find(f => f.op == op || f.op.includes(op)); let matchingForm = forms.find((f) => f.op == op || f.op.includes(op));
if (matchingForm == undefined) return undefined; if (matchingForm == undefined) return undefined;
return matchingForm.href; return matchingForm.href;
} }