Browse Source

Merge branch 'release/v0.1.5'

rigwild 2 years ago
parent
commit
0c0ebe2b74

+ 1 - 1
.eslintrc.js

@@ -134,7 +134,7 @@ module.exports = {
     'no-mixed-spaces-and-tabs': 1,
     'no-multiple-empty-lines': [1, { max: 2 }],
     'no-trailing-spaces': 1,
-    'no-underscore-dangle': 1,
+    'no-underscore-dangle': 0,
     'quote-props': [1, 'consistent'],
     'quotes': [1, 'single'],
     'semi': ['error', 'never'],

+ 5 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "expe-web",
-  "version": "0.1.4",
+  "version": "0.1.5",
   "private": true,
   "scripts": {
     "server:start": "node -r esm server/index.js",
@@ -19,17 +19,18 @@
     "esm": "^3.2.22",
     "express": "^4.16.4",
     "helmet": "^3.16.0",
-    "mongoose": "^5.5.4",
+    "mongoose": "^5.5.5",
     "morgan": "^1.9.1",
     "serve-static": "^1.13.2",
     "sharp": "^0.22.1",
     "vue": "^2.6.10",
+    "vue-native-websocket": "^2.0.13",
     "vue-router": "^3.0.6",
-    "vuetify": "^1.5.13",
+    "vuetify": "^1.5.14",
     "vuex": "^3.1.0",
     "vuex-persist": "^2.0.0",
     "winston": "^3.2.1",
-    "ws": "^6.2.1"
+    "ws": "^7.0.0"
   },
   "devDependencies": {
     "@vue/cli-plugin-babel": "^3.7.0",

+ 8 - 4
server/functions.js

@@ -104,6 +104,8 @@ export const getSceneFiles = sceneName => {
 
 /** Image data type definition (do no remove)
  * @typedef {object} ImageData
+ * @property {string} fileName file name of image
+ * @property {string} sceneName scene name of image
  * @property {string} prefix prefix of image
  * @property {number} quality quality of image
  * @property {string} ext extension of image
@@ -112,7 +114,7 @@ export const getSceneFiles = sceneName => {
  * Get image data from every files in a scene (exclude blacklisted ones)
  * @typedef {string} filename path to the image
  * @param {string} sceneName the scene name
- * @returns {Promise<Map<filename, ImageData>>} the data for all images in a scene (Map key = file name)
+ * @returns {Promise<ImageData[]>} the data for all images in a scene
  * @throws some file names could not be parsed
  */
 export const getSceneFilesData = async sceneName => {
@@ -146,20 +148,22 @@ export const getSceneFilesData = async sceneName => {
       const fileData = {
         prefix: regexRes[1],
         quality: parseInt(regexRes[2], 10),
-        ext: regexRes[3]
+        ext: regexRes[3],
+        fileName: regexRes[0],
+        sceneName
       }
 
       // Check valid quality
       if (isNaN(fileData.quality)) return acc
 
       // Data is valid, set it
-      acc.set(regexRes[0], fileData)
+      acc.push(fileData)
     }
     catch (err) {
       failList.push(`Failed to parse file name : "${image}".`)
     }
     return acc
-  }, new Map())
+  }, [])
 
   // Check if the parse fail list is empty
   if (failList.length > 0)

+ 73 - 20
server/routes/getImage.js

@@ -10,7 +10,7 @@ import { asyncMiddleware, checkSceneName, checkRequiredParameters, getSceneFiles
 const router = express.Router()
 
 /**
- * @api {get} /getImage?sceneName=:sceneName&imageQuality=:imageQuality Get an image from a scene
+ * @api {get} /getImage?sceneName=:sceneName&imageQuality=:imageQuality&nearestQuality=:nearestQuality Get an image from a scene
  * @apiVersion 0.1.0
  * @apiName GetImage
  * @apiGroup API
@@ -18,7 +18,8 @@ const router = express.Router()
  * @apiDescription Get an image from a scene with the required quality
  *
  * @apiParam {String} sceneName The selected scene
- * @apiParam {Number} imageQuality The required quality of the image
+ * @apiParam {String="min","max","median", "any integer"} imageQuality The required quality of the image (can be an integer, `min`, `max` or `median`)
+ * @apiParam {Boolean} [nearestQuality=false] if selected quality not availabie, select the nearest one
  *
  * @apiExample Usage example
  * curl -i -L -X GET "http://diran.univ-littoral.fr/api/getImage?sceneName=bathroom&imageQuality=200"
@@ -27,7 +28,13 @@ const router = express.Router()
  * @apiSuccessExample {json} Success response example
  * HTTP/1.1 200 OK /api/getImage?sceneName=bathroom&imageQuality=200
  * {
- *   "data": "/api/images/bathroom/bathroom_00200.png"
+ *   "data": {
+ *     "link": "/api/images/bathroom/bathroom_00200.png",
+ *     "fileName": "bathroom_00200.png",
+ *     "sceneName": "bathroom",
+ *     "quality": 200,
+ *     "ext": "png"
+ *   }
  * }
  *
  * @apiError (Error 4xx) 400_[1] Missing parameter(s)
@@ -44,7 +51,8 @@ const router = express.Router()
  *   "message": "Invalid query parameter(s).",
  *   "data": [
  *     "The requested scene name \".//../\" is not valid.",
- *     "The specified quality is not an integer."
+ *     "The specified quality is not an integer.",
+ *     "Impossible to use \"min\", \"max\" or \"median\" with \"nearestQuality\" on."
  *   ]
  * }
  *
@@ -88,26 +96,60 @@ const router = express.Router()
 /**
  * Get the link and path to an image
  * @param {string} sceneName the scene to get the image from
- * @param {number} qualityInt the requested quality
+ * @param {number|"min"|"max"|"median"} quality the requested quality
+ * @param {boolean} [nearestQuality=false] if selected quality not availabie, select the nearest one
  * @returns {Promise<Image>} the link and path to the image
  */
-export const getImage = async (sceneName, qualityInt) => {
+export const getImage = async (sceneName, quality, nearestQuality = false) => {
+  const throwErrIfTrue = x => {
+    if (x) throw boom.badRequest('Impossible to use "min", "max" or "median" with "nearestQuality" on.')
+  }
   const sceneData = await getSceneFilesData(sceneName)
 
+  let imageData = null
   // Search an image with the requested quality in the scene
-  for (const [imageName, imageData] of sceneData.entries())
-    if (qualityInt === imageData.quality)
-      return {
-        link: `${imageServedUrl}/${sceneName}/${imageName}`,
-        path: path.resolve(imagesPath, sceneName, imageName),
-        fileName: imageName,
-        sceneName,
-        quality: imageData.quality,
-        ext: imageData.ext
+  if (quality === 'min') {
+    throwErrIfTrue(nearestQuality)
+    const toFind = Math.min(...sceneData.map(x => x.quality))
+    imageData = sceneData.find(x => x.quality === toFind)
+  }
+  else if (quality === 'max') {
+    throwErrIfTrue(nearestQuality)
+    const toFind = Math.max(...sceneData.map(x => x.quality))
+    imageData = sceneData.find(x => x.quality === toFind)
+  }
+  else if (quality === 'median') {
+    throwErrIfTrue(nearestQuality)
+    imageData = sceneData.length > 0 ? sceneData[Math.ceil(sceneData.length / 2) - 1] : null
+  }
+  else {
+    if (nearestQuality && sceneData.length > 0 && !isNaN(parseInt(quality, 10))) {
+      let minGap = Number.MAX_SAFE_INTEGER
+      let minGapImageData = null
+      for (const x of sceneData) {
+        const tempGap = Math.abs(x.quality - quality)
+        if (tempGap < minGap) {
+          minGap = tempGap
+          minGapImageData = x
+        }
       }
+      imageData = minGapImageData
+    }
+    else imageData = sceneData.find(x => quality === x.quality)
+  }
+
+  if (imageData)
+    return {
+      link: `${imageServedUrl}/${sceneName}/${imageData.fileName}`,
+      path: path.resolve(imagesPath, sceneName, imageData.fileName),
+      fileName: imageData.fileName,
+      sceneName: imageData.sceneName,
+      quality: imageData.quality,
+      ext: imageData.ext
+    }
 
   // Image not found
-  throw boom.notFound(`The requested quality (${qualityInt}) was not found for the requested scene (${sceneName}).`)
+  throw boom.notFound(`The requested quality "${quality}" was not found for the requested scene "${sceneName}".`)
 }
 
 router.get('/', asyncMiddleware(async (req, res) => {
@@ -115,6 +157,7 @@ router.get('/', asyncMiddleware(async (req, res) => {
   checkRequiredParameters(['sceneName', 'imageQuality'], req.query)
 
   const { sceneName, imageQuality } = req.query
+  const nearestQuality = req.query.nearestQuality === 'true'
 
   let errorList = []
 
@@ -126,16 +169,26 @@ router.get('/', asyncMiddleware(async (req, res) => {
     errorList.push(err.message)
   }
 
-  // Check `imageQuality` is an integer
+  // Check `imageQuality` is an integer or `min`, `max` or `median`
   const qualityInt = parseInt(imageQuality, 10)
-  if (isNaN(qualityInt)) errorList.push('The specified quality is not an integer.')
+  let quality = null
+  if (['min', 'median', 'max'].some(x => x === imageQuality)) {
+    if (nearestQuality)
+      errorList.push('Impossible to use "min", "max" or "median" with "nearestQuality" on.')
+    else quality = imageQuality
+  }
+  else if (!isNaN(qualityInt))
+    quality = qualityInt
+  else
+    errorList.push('The specified quality is not an integer or "min", "max" or "median".')
 
   // Check there is no errors with parameters
   if (errorList.length > 0)
     throw boom.badRequest('Invalid query parameter(s).', errorList)
 
-  const { link } = await getImage(sceneName, qualityInt)
-  res.json({ data: link })
+  const data = await getImage(sceneName, quality, nearestQuality)
+  data.path = undefined
+  res.json({ data })
 }))
 
 export default router

+ 40 - 12
server/routes/getImageExtracts.js

@@ -13,7 +13,7 @@ import { getImage } from './getImage'
 const router = express.Router()
 
 /**
- * @api {get} /getImageExtracts?sceneName=:sceneName&imageQuality=:imageQuality&horizontalExtractCount=:horizontalExtractCount&verticalExtractCount=:verticalExtractCount Get image extracts
+ * @api {get} /getImageExtracts?sceneName=:sceneName&imageQuality=:imageQuality&horizontalExtractCount=:horizontalExtractCount&verticalExtractCount=:verticalExtractCount&nearestQuality=:nearestQuality Get image extracts
  * @apiVersion 0.1.0
  * @apiName GetImageExtracts
  * @apiGroup API
@@ -21,9 +21,10 @@ const router = express.Router()
  * @apiDescription Get an image from a scene with the required quality and cut it with the requested configuration
  *
  * @apiParam {String} sceneName The selected scene
- * @apiParam {Number} imageQuality The required quality of the image
+ * @apiParam {String="min","max","median", "any integer"} imageQuality The required quality of the image (can be an integer, `min`, `max` or `median`)
  * @apiParam {Number} horizontalExtractCount The amount of extracts for the horizontal axis
  * @apiParam {Number} verticalExtractCount The amount of extracts for the vertical axis
+ * @apiParam {Boolean} [nearestQuality=false] if selected quality not availabie, select the nearest one
  *
  * @apiExample Usage example
  * curl -i -L -X GET "http://diran.univ-littoral.fr/api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2"
@@ -32,10 +33,19 @@ const router = express.Router()
  * @apiSuccessExample {json} Success response example
  * HTTP/1.1 200 OK /api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2
  * {
- *   "data": [
- *     "/api/images/bathroom/extracts/x1_y2/zone00001/bathroom_zone00001_200.png",
- *     "/api/images/bathroom/extracts/x1_y2/zone00002/bathroom_zone00002_200.png"
- *   ]
+ *   "data": {
+ *     extracts: [
+ *       "/api/images/bathroom/extracts/x1_y2/zone00001/bathroom_zone00001_200.png",
+ *       "/api/images/bathroom/extracts/x1_y2/zone00002/bathroom_zone00002_200.png"
+ *     ],
+ *     "info": {
+ *       "link": "/api/images/bathroom/bathroom_00200.png",
+ *       "fileName": "bathroom_00200.png",
+ *       "sceneName": "bathroom",
+ *       "quality": 200,
+ *       "ext": "png"
+ *     }
+ *   }
  * }
  *
  * @apiError (Error 4xx) 400_[1] Missing parameter(s)
@@ -54,7 +64,8 @@ const router = express.Router()
  *     "The requested scene name \".//../\" is not valid.",
  *     "The specified quality is not an integer.",
  *     "The specified number of extract for the horizontal axis is not an integer.",
- *     "The specified number of extract for the vertical axis is not an integer."
+ *     "The specified number of extract for the vertical axis is not an integer.",
+ *     "Impossible to use \"min\", \"max\" or \"median\" with \"nearestQuality\" on."
  *   ]
  * }
  *
@@ -205,6 +216,7 @@ router.get('/', asyncMiddleware(async (req, res) => {
   checkRequiredParameters(['sceneName', 'imageQuality', 'horizontalExtractCount', 'verticalExtractCount'], req.query)
 
   const { sceneName, imageQuality, horizontalExtractCount, verticalExtractCount } = req.query
+  const nearestQuality = req.query.nearestQuality === 'true'
 
   let errorList = []
 
@@ -216,15 +228,24 @@ router.get('/', asyncMiddleware(async (req, res) => {
     errorList.push(err.message)
   }
 
-  // Check `imageQuality` is an integer
+  // Check `imageQuality` is an integer or `min`, `max` or `median`
   const qualityInt = parseInt(imageQuality, 10)
-  if (isNaN(qualityInt)) errorList.push('The specified quality is not an integer.')
+  let quality = null
+  if (['min', 'median', 'max'].some(x => x === imageQuality)) {
+    if (nearestQuality)
+      errorList.push('Impossible to use "min", "max" or "median" with "nearestQuality" on.')
+    else quality = imageQuality
+  }
+  else if (!isNaN(qualityInt))
+    quality = qualityInt
+  else
+    errorList.push('The specified quality is not an integer or "min", "max" or "median".')
 
   // Check `horizontalExtractCount` is an integer
   const horizontalExtractCountInt = parseInt(horizontalExtractCount, 10)
   if (isNaN(horizontalExtractCountInt)) errorList.push('The specified number of extract for the horizontal axis is not an integer.')
 
-  // Check `imageQuality` is an integer
+  // Check `verticalExtractCountInt` is an integer
   const verticalExtractCountInt = parseInt(verticalExtractCount, 10)
   if (isNaN(verticalExtractCountInt)) errorList.push('The specified number of extract for the vertical axis is not an integer.')
 
@@ -234,13 +255,20 @@ router.get('/', asyncMiddleware(async (req, res) => {
     throw boom.badRequest('Invalid query parameter(s).', errorList)
 
   // Get the image path and link
-  const image = await getImage(sceneName, qualityInt)
+  const image = await getImage(sceneName, quality, nearestQuality)
 
   // Cut the image
   const extracts = await cutImage(image, horizontalExtractCountInt, verticalExtractCountInt)
 
+  image.path = undefined
+
   // Send an array of links
-  res.json({ data: extracts.map(x => x.link) })
+  res.json({
+    data: {
+      extracts: extracts.map(x => x.link),
+      info: image
+    }
+  })
 }))
 
 export default router

+ 1 - 1
server/routes/listSceneQualities.js

@@ -69,7 +69,7 @@ router.get('/', asyncMiddleware(async (req, res) => {
 
   const { sceneName } = req.query
   const sceneData = await getSceneFilesData(sceneName)
-  const data = Array.from(sceneData.values()).map(x => x.quality)
+  const data = sceneData.map(x => x.quality)
   res.json({ data })
 }))
 

+ 1 - 1
server/webSocket/messageHandler.js

@@ -24,7 +24,7 @@ const messageHandler = ws => async data => {
 
   await DataController.add(json)
   if (!TEST_MODE) wsLogger.info(formatLog(json, 'message'))
-  ws.send('ok')
+  ws.send('{"message":"ok"}')
 }
 
 export default messageHandler

+ 46 - 32
src/App.vue

@@ -7,13 +7,14 @@
     <!--/ Application cache reset button -->
 
     <v-slide-y-transition mode="out-in">
+      <!-- Loading screen -->
+      <loader v-if="loadingMessage" :message="loadingMessage" />
+      <!--/ Loading screen -->
+
       <!-- Host connection configuration -->
-      <host-config v-if="!isHostConfigured" />
+      <host-config v-else-if="!isHostConfigured" />
       <!--/ Host connection configuration -->
 
-      <!-- Loading screen -->
-      <loader v-else-if="loadingMessage" :message="loadingMessage" />
-      <!--/ Loading screen -->
 
       <div v-else>
         <!-- Sidebar menu -->
@@ -24,21 +25,21 @@
           app
         >
           <v-list dense>
-            <v-list-tile to="/" exact>
+            <v-list-tile to="/experimentsList" exact>
               <v-list-tile-action>
-                <v-icon>home</v-icon>
+                <v-icon>library_books</v-icon>
               </v-list-tile-action>
               <v-list-tile-content>
-                <v-list-tile-title>Home</v-list-tile-title>
+                <v-list-tile-title>List of experiments</v-list-tile-title>
               </v-list-tile-content>
             </v-list-tile>
 
-            <v-list-tile to="/experiencesList" exact>
+            <v-list-tile @click="loadScenes">
               <v-list-tile-action>
-                <v-icon>photo_library</v-icon>
+                <v-icon>refresh</v-icon>
               </v-list-tile-action>
               <v-list-tile-content>
-                <v-list-tile-title>Experiences list</v-list-tile-title>
+                <v-list-tile-title>Refresh list of scenes</v-list-tile-title>
               </v-list-tile-content>
             </v-list-tile>
           </v-list>
@@ -48,19 +49,21 @@
         <!-- Top bar -->
         <v-toolbar app fixed clipped-left>
           <v-toolbar-side-icon @click.stop="drawer = !drawer" />
-          <v-toolbar-title>Web experience</v-toolbar-title>
+          <v-toolbar-title>Web experiment</v-toolbar-title>
         </v-toolbar>
         <!--/ Top bar -->
 
         <!-- Pages content -->
         <v-content>
-          <v-container fluid fill-height>
+          <v-container fill-height>
             <v-layout justify-center>
-              <v-scroll-x-reverse-transition mode="out-in">
-                <!-- View injected here -->
-                <router-view />
+              <v-flex xs12>
+                <v-scroll-x-reverse-transition mode="out-in">
+                  <!-- View injected here -->
+                  <router-view />
                 <!--/ View injected here -->
-              </v-scroll-x-reverse-transition>
+                </v-scroll-x-reverse-transition>
+              </v-flex>
             </v-layout>
           </v-container>
         </v-content>
@@ -87,7 +90,7 @@ export default {
       darkMode: true,
       drawer: false,
 
-      hostConfigured: false,
+      loadingErrorMessage: null,
       loadingMessage: null
     }
   },
@@ -96,29 +99,40 @@ export default {
   },
   watch: {
     isHostConfigured(value) {
-      if (!this.areScenesLoaded && value) this.loadAppData()
+      if (value) this.loadAppData()
     }
   },
   mounted() {
-    if (this.isHostConfigured && !this.areScenesLoaded) this.loadAppData()
+    this.loadAppData()
   },
   methods: {
-    ...mapActions(['loadScenesList']),
+    ...mapActions(['loadScenesList', 'connectToWs']),
     async loadAppData() {
-      if (this.isHostConfigured && !this.areScenesLoaded) {
-        this.loadingMessage = 'Loading scenes list...'
-        try {
-          await this.loadScenesList()
-        }
-        catch (err) {
-          this.loadingErrorMessage = err.message
-          return
-        }
-        finally {
-          this.loadingMessage = null
-        }
+      if (this.isHostConfigured) await this.loadWebSocket()
+      if (this.isHostConfigured && !this.areScenesLoaded) await this.loadScenes()
+    },
+
+    async load(fn, loadingMessage) {
+      try {
+        this.loadingMessage = loadingMessage
+        await fn()
+      }
+      catch (err) {
+        this.loadingErrorMessage = err.message
+        return
+      }
+      finally {
+        this.loadingMessage = null
       }
+    },
+
+    loadScenes() {
+      return this.load(this.loadScenesList, 'Loading scenes list...')
+    },
+    loadWebSocket() {
+      return this.load(this.connectToWs, 'Connecting to WebSocket server...')
     }
+
   }
 }
 </script>

+ 17 - 2
src/components/HostConfig.vue

@@ -10,6 +10,14 @@
               <v-slide-y-transition mode="out-in">
                 <loader v-if="loadingMessage" :message="loadingMessage" />
                 <v-form v-else ref="form">
+                  <v-flex xs3>
+                    <v-select
+                      v-model="config.ssl"
+                      :items="[true, false]"
+                      label="SSL"
+                    />
+                  </v-flex>
+
                   <v-text-field
                     v-model="config.host"
                     label="Host IP address or hostname"
@@ -25,6 +33,7 @@
                     required
                   />
 
+
                   <v-btn color="error" @click="reset">Reset Form</v-btn>
 
                   <v-btn color="success" @click="validate">Submit</v-btn>
@@ -54,7 +63,7 @@ export default {
   data() {
     return {
       config: {
-        protocol: 'HTTP',
+        ssl: true,
         host: 'diran.univ-littoral.fr',
         port: '80'
       },
@@ -64,10 +73,16 @@ export default {
     }
   },
 
+  watch: {
+    'config.ssl'(newValue) {
+      if (newValue === true) this.config.port = 443
+    }
+  },
+
   methods: {
     ...mapActions(['setHostConfig']),
     reset() {
-      this.config.protocol = 'HTTP'
+      this.config.ssl = true
       this.config.host = ''
       this.config.port = null
       this.configErrorMessage = null

+ 81 - 23
src/components/ResetAppButton.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="text-xs-center">
     <v-dialog
-      v-model="dialog"
+      v-model="showDialog"
       width="600"
       :fullscreen="$vuetify.breakpoint.smAndDown"
     >
@@ -19,47 +19,105 @@
 
         <v-divider />
 
-        <v-card-actions v-if="$vuetify.breakpoint.smAndDown">
-          <v-flex xs12 text-xs-center>
-            <div>
-              <v-btn color="primary" block flat @click="reset({ hostConfig: true })">Reset configuration</v-btn>
-            </div>
-            <div>
-              <v-btn color="primary" block flat @click="reset({ hostConfig: true, scenesList: true })">Reset everything</v-btn>
-            </div>
 
-            <div class="mt-4">
-              <v-btn color="secondary" block flat @click="dialog = false">Cancel</v-btn>
-            </div>
-          </v-flex>
-        </v-card-actions>
-
-        <v-card-actions v-else>
-          <v-btn color="secondary" flat @click="dialog = false">Cancel</v-btn>
+        <v-card-actions>
+          <v-btn color="secondary" flat @click="showDialog = false">Cancel</v-btn>
           <v-spacer />
-          <v-btn color="primary" flat @click="reset({ hostConfig: true })">Reset configuration</v-btn>
-          <v-btn color="primary" flat @click="reset({ hostConfig: true, scenesList: true })">Reset everything</v-btn>
+          <v-flex xs6>
+            <v-select
+              v-model="selectedItems"
+              :items="items"
+              label="Data to reset"
+              multiple
+              item-text="text"
+              item-value="value"
+              return-object
+              single-line
+              chips
+              deletable-chips
+            >
+              <template v-slot:prepend-item>
+                <v-list-tile ripple @click="toggle">
+                  <v-list-tile-action>
+                    <v-icon :color="selectedItems.length > 0 ? 'indigo darken-4' : ''">{{ icon }}</v-icon>
+                  </v-list-tile-action>
+                  <v-list-tile-content>
+                    <v-list-tile-title>Reset All</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+                <v-divider class="mt-2" />
+              </template>
+            </v-select>
+          </v-flex>
+          <v-btn color="primary" flat @click="reset" :disabled="!(selectedItems.length > 0)">Reset</v-btn>
         </v-card-actions>
       </v-card>
     </v-dialog>
+
+    <toast-message ref="toast" />
   </div>
 </template>
 
 <script>
 import { mapActions } from 'vuex'
+import ToastMessage from '@/components/ToastMessage.vue'
 
 export default {
   name: 'ResetAppButton',
+  components: {
+    ToastMessage
+  },
   data() {
     return {
-      dialog: false
+      showDialog: false,
+
+      selectedItems: [],
+      items: [
+        { text: 'Host configuration', value: 'hostConfig' },
+        { text: 'Progression', value: 'progression' }
+      ]
+    }
+  },
+  computed: {
+    selectAll() {
+      return this.selectedItems.length === this.items.length
+    },
+    selectSome() {
+      return this.selectedItems.length > 0 && !this.selectAll
+    },
+    icon() {
+      if (this.selectAll) return 'mdi-close-box'
+      if (this.selectSome) return 'mdi-minus-box'
+      return 'mdi-checkbox-blank-outline'
     }
   },
   methods: {
+    toggle() {
+      this.$nextTick(() => {
+        if (this.selectAll) {
+          this.selectedItems = []
+        }
+        else {
+          this.selectedItems = this.items.slice()
+        }
+      })
+    },
+
     ...mapActions(['resetApp']),
-    reset(toResetObj) {
-      this.resetApp(toResetObj)
-      this.dialog = false
+    reset() {
+      const toReset = this.selectedItems.reduce((acc, x) => {
+        acc[x.value] = true
+        return acc
+      }, {})
+      try {
+        this.resetApp(toReset)
+        this.$refs.toast.show('Successfully reseted requested data')
+        this.showDialog = false
+      }
+      catch (err) {
+        this.$refs.toast.show(err.message, 'error', 10000)
+      }
+      this.$router.push('/')
     }
   }
 }

+ 49 - 0
src/components/ToastMessage.vue

@@ -0,0 +1,49 @@
+<template>
+  <v-card>
+    <v-snackbar
+      v-model="isVisible"
+      :color="level"
+      :timeout="timeout"
+      multi-line
+    >
+      {{ text }}
+      <v-btn
+        dark
+        flat
+        @click="isVisible = false"
+      >
+        Close
+      </v-btn>
+    </v-snackbar>
+  </v-card>
+</template>
+
+<script>
+export default {
+  name: 'ToastMessage',
+  data() {
+    return {
+      text: '',
+      level: 'success',
+      timeout: 4000,
+      isVisible: false
+    }
+  },
+  methods: {
+    /**
+     * Briefly show a toast message
+     *
+     * @param {String} text toast to show
+     * @param {('info'|'success'|'error')} [level='success'] toast type
+     * @param {Number} [timeout=4000] amount of time the toast will be visible
+     * @returns {void}
+     */
+    show(text, level = 'success', timeout = 4000) {
+      this.text = text
+      this.level = level
+      this.timeout = timeout
+      this.isVisible = true
+    }
+  }
+}
+</script>

+ 6 - 4
src/functions.js

@@ -6,17 +6,19 @@ export const API_ROUTES = {
 
   listSceneQualities: sceneName => `${API_PREFIX}/listSceneQualities?sceneName=${new URLSearchParams({ sceneName })}`,
 
-  getImage: (sceneName, imageQuality) => `${API_PREFIX}/getImage?${new URLSearchParams({ sceneName, imageQuality })}`,
+  getImage: (sceneName, imageQuality, nearestQuality = false) => `${API_PREFIX}/getImage?${new URLSearchParams({ sceneName, imageQuality, nearestQuality })}`,
 
-  getImageExtracts: (sceneName, imageQuality, horizontalExtractCount, verticalExtractCount) =>
+  getImageExtracts: (sceneName, imageQuality, horizontalExtractCount, verticalExtractCount, nearestQuality = false) =>
     `${API_PREFIX}/getImage?${new URLSearchParams({
       sceneName,
       imageQuality,
       horizontalExtractCount,
-      verticalExtractCount
+      verticalExtractCount,
+      nearestQuality
     })}`
 }
 
 export const delay = ms => new Promise(res => setTimeout(res, ms))
 
-export const buildURI = (protocol, host, port, route = '') => `${protocol}://${host}:${port}${route}`
+export const buildURI = (ssl, host, port, route = '') => `${ssl ? 'https' : 'http'}://${host}:${port}${route}`
+export const buildWsURI = (ssl, host, port) => `${ssl ? 'wss' : 'ws'}://${host}:${port}`

+ 11 - 1
src/main.js

@@ -2,10 +2,20 @@ import Vue from 'vue'
 import './plugins/vuetify'
 import App from './App.vue'
 import router from './router'
-import store from './store/'
+import store from './store'
+import VueNativeSock from 'vue-native-websocket'
 
 Vue.config.productionTip = false
 
+Vue.use(VueNativeSock, 'ws://example.com', {
+  store,
+  connectManually: true,
+  reconnection: true,
+  reconnectionAttempts: 5,
+  reconnectionDelay: 1000
+})
+store.$socket = Vue.prototype.$socket
+
 new Vue({
   router,
   store,

+ 0 - 20
src/router.js

@@ -1,20 +0,0 @@
-import Vue from 'vue'
-import Router from 'vue-router'
-import Home from './views/Home.vue'
-
-Vue.use(Router)
-
-export default new Router({
-  routes: [
-    {
-      path: '/',
-      name: 'Home',
-      component: Home
-    },
-    {
-      path: '/experiencesList',
-      name: 'ExperiencesList',
-      component: () => import('./views/ExperiencesList.vue')
-    }
-  ]
-})

+ 16 - 0
src/router/experiments.js

@@ -0,0 +1,16 @@
+export default [
+  {
+    path: '/experiments/ExperimentNoReference/:sceneId',
+    name: 'ExperimentNoReference',
+    fullName: 'No reference image',
+    component: () => import('@/views/Experiments/NoReference.vue'),
+    props: true
+  },
+  {
+    path: '/experiments/ExperimentWithReference/:sceneId',
+    name: 'ExperimentWithReference',
+    fullName: 'With reference image',
+    component: () => import('@/views/Experiments/WithReference.vue'),
+    props: true
+  }
+]

+ 27 - 0
src/router/index.js

@@ -0,0 +1,27 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+import ExperimentsList from '@/views/ExperimentsList.vue'
+import Experiments from './experiments'
+
+Vue.use(Router)
+
+export default new Router({
+  routes: [
+    {
+      path: '/',
+      redirect: '/experimentsList'
+    },
+    {
+      path: '/experimentsList',
+      name: 'ExperimentsList',
+      component: ExperimentsList
+    },
+    {
+      path: '/experiments/selectScene/:experimentName',
+      name: 'SelectExperimentScene',
+      component: () => import('@/views/SelectExperimentScene.vue'),
+      props: true
+    },
+    ...Experiments
+  ]
+})

+ 28 - 14
src/store/actions.js

@@ -1,25 +1,18 @@
-import { API_ROUTES, buildURI } from '../functions'
+import Vue from 'vue'
+import { API_ROUTES, buildURI, buildWsURI } from '../functions'
 
 export default {
-  async increment({ commit }, amount = 1) {
-    commit('increment', amount)
+  resetApp({ commit }, { hostConfig = false, progression = false }) {
+    commit('resetApp', { hostConfig, progression })
   },
 
-  async decrement({ commit }, amount = 1) {
-    commit('increment', -amount)
-  },
-
-  resetApp({ commit }, { hostConfig = false, scenesList = false }) {
-    commit('resetApp', { hostConfig, scenesList })
-  },
-
-  async setHostConfig({ commit }, { protocol, host, port }) {
+  async setHostConfig({ commit }, { ssl, host, port }) {
     // Timeout after 1s
     const controller = new AbortController()
     const signal = controller.signal
     setTimeout(() => controller.abort(), 1500)
 
-    const URI = buildURI(protocol, host, port, API_ROUTES.ping())
+    const URI = buildURI(ssl, host, port, API_ROUTES.ping())
     return fetch(URI, { signal })
       .then(async res => {
         if (res.status !== 200) throw new Error(`Received wrong HTTP status code : ${res.status} (Need 200).`)
@@ -27,8 +20,10 @@ export default {
         const content = await res.text()
         if (content !== 'pong') throw new Error('Received wrong web content (Need to receive "pong").')
 
+        this._vm.$connect(buildWsURI(ssl, host, port))
+
         // Configuration is valid
-        commit('setHostConfig', { protocol, host, port })
+        commit('setHostConfig', { ssl, host, port })
       })
       .catch(err => {
         // Host not reachable or invalid HTTP status code
@@ -36,11 +31,30 @@ export default {
       })
   },
 
+  async connectToWs({ state, getters }) {
+    if (state.socket.isConnected) return /*eslint-disable-line */
+    else if (getters.isHostConfigured) {
+      this._vm.$connect(getters.getHostWsURI)
+    }
+    else throw new Error('Could not connect to WebSocket server. Host is not configured.')
+  },
+
+  sendMessage(_, message) {
+    Vue.prototype.$socket.send(JSON.stringify(message) || message)
+  },
+
   async loadScenesList({ getters: { isHostConfigured, getHostURI }, commit }) {
     if (!isHostConfigured) throw new Error('Host is not configured.')
 
     const URI = getHostURI
     const scenes = await fetch(`${URI}${API_ROUTES.listScenes()}`).then(res => res.json())
     commit('setListScenes', scenes.data)
+  },
+
+  setExperimentProgress({ commit }, { experimentName, sceneName, data }) {
+    commit('setExperimentProgress', { experimentName, sceneName, data })
+  },
+  setExperimentDone({ commit }, { experimentName, sceneName, done = true }) {
+    commit('setExperimentDone', { experimentName, sceneName, done })
   }
 }

+ 13 - 3
src/store/getters.js

@@ -1,15 +1,25 @@
-import { buildURI } from '../functions'
+import { buildURI, buildWsURI } from '../functions'
 
 export default {
   isHostConfigured(state) {
-    return !!(state.hostConfig.protocol && state.hostConfig.host && state.hostConfig.protocol)
+    return !!(state.hostConfig.ssl !== null && state.hostConfig.host && state.hostConfig.port)
   },
   getHostURI(state, getters) {
     if (getters.isHostConfigured)
-      return buildURI(state.hostConfig.protocol, state.hostConfig.host, state.hostConfig.port)
+      return buildURI(state.hostConfig.ssl, state.hostConfig.host, state.hostConfig.port)
+  },
+
+  getHostWsURI(state, getters) {
+    if (getters.isHostConfigured)
+      return buildWsURI(state.hostConfig.ssl, state.hostConfig.host, state.hostConfig.port)
   },
 
   areScenesLoaded(state) {
     return state.scenesList !== null
   }
+
+  // TODO: Cache scene thumb URI
+  // areScenesThumbsLoaded(state) {
+  //   return state.scenesList !== null
+  // }
 }

+ 8 - 10
src/store/index.js

@@ -1,29 +1,27 @@
 import Vue from 'vue'
 import Vuex from 'vuex'
 import VuexPersistence from 'vuex-persist'
-import state from './state'
+import defaultState from './state'
 import getters from './getters'
 import mutations from './mutations'
 import actions from './actions'
 
 Vue.use(Vuex)
 
-const PRODUCTION_MODE = process.env.NODE_ENV === 'production'
-
 const vuexLocal = new VuexPersistence({
   storage: window.localStorage,
   key: 'webexpe-state',
-  strictMode: !PRODUCTION_MODE
+  reducer: state => ({
+    hostConfig: state.hostConfig,
+    scenesList: state.scenesList,
+    progression: state.progression
+  })
 })
 
 export default new Vuex.Store({
-  state,
+  state: defaultState(),
   getters,
-  mutations: {
-    RESTORE_MUTATION: !PRODUCTION_MODE ? vuexLocal.RESTORE_MUTATION : undefined,
-    ...mutations
-  },
+  mutations,
   actions,
-  strict: !PRODUCTION_MODE,
   plugins: [vuexLocal.plugin]
 })

+ 56 - 6
src/store/mutations.js

@@ -1,13 +1,18 @@
+import Vue from 'vue'
 import { defaultState } from '@/store/state'
+import Experiments from '@/router/experiments'
 
-export default {
-  increment(state, amount = 1) {
-    state.count += amount
-  },
+const checkProgression = (state, experimentName, sceneName) => {
+  if (!state.progression[experimentName])
+    state.progression[experimentName] = {}
+  if (!state.progression[experimentName][sceneName])
+    state.progression[experimentName][sceneName] = { done: false, data: {} }
+}
 
-  resetApp(state, { hostConfig, scenesList }) {
+export default {
+  resetApp(state, { hostConfig, progression }) {
     if (hostConfig) state.hostConfig = defaultState.hostConfig
-    if (scenesList) state.scenesList = defaultState.scenesList
+    if (progression) state.progression = defaultState.progression
   },
 
   setHostConfig(state, newConfig) {
@@ -16,5 +21,50 @@ export default {
 
   setListScenes(state, scenes) {
     state.scenesList = scenes
+    const progressionObj = Experiments.reduce((accExpe, expe) => {
+      const scenesProgressObj = scenes.reduce((accScene, scene) => {
+        // Do not overwrite current progression
+        if (state.progression[expe.name] && state.progression[expe.name][scene])
+          accScene[scene] = state.progression[expe.name][scene]
+        else accScene[scene] = { done: false, data: {} }
+        return accScene
+      }, {})
+      accExpe[expe.name] = scenesProgressObj
+      return accExpe
+    }, {})
+
+    state.progression = progressionObj
+  },
+
+  setExperimentProgress(state, { experimentName, sceneName, data }) {
+    checkProgression(state, experimentName, sceneName)
+    state.progression[experimentName][sceneName].data = data
+  },
+  setExperimentDone(state, { experimentName, sceneName, done }) {
+    checkProgression(state, experimentName, sceneName)
+    state.progression[experimentName][sceneName].done = done
+  },
+
+  SOCKET_ONOPEN(state, event) {
+    Vue.prototype.$socket = event.currentTarget
+    state.socket.isConnected = true
+  },
+  SOCKET_ONCLOSE(state, event) {
+    state.socket.isConnected = false
+  },
+  SOCKET_ONERROR(state, event) {
+    console.error(state, event)
+  },
+  // default handler called for all methods
+  SOCKET_ONMESSAGE(state, { data: rawMessage }) {
+    const message = JSON.parse(rawMessage)
+    state.socket.message = message
+  },
+  // mutations for reconnect methods
+  SOCKET_RECONNECT(state, count) {
+    console.info(state, count)
+  },
+  SOCKET_RECONNECT_ERROR(state) {
+    state.socket.reconnectError = true
   }
 }

+ 10 - 7
src/store/state.js

@@ -1,12 +1,15 @@
-export const defaultState = {
+// Deep copy to not mutate it with the store (default state is needed when reloading after a refresh)
+export default () => JSON.parse(JSON.stringify({
   hostConfig: {
-    protocol: null,
+    ssl: null,
     host: null,
     port: null
   },
   scenesList: null,
-  count: 0
-}
-
-// Deep copy defaultState to not modify it with the store
-export default JSON.parse(JSON.stringify(defaultState))
+  progression: {},
+  socket: {
+    isConnected: false,
+    message: '',
+    reconnectError: false
+  }
+}))

+ 0 - 125
src/views/ExperiencesList.vue

@@ -1,125 +0,0 @@
-<template>
-  <div>
-    List of experiences
-
-    <v-data-table
-      :headers="headers"
-      :items="desserts"
-      class="elevation-1"
-    >
-      <template v-slot:items="props">
-        <td>{{ props.item.name }}</td>
-        <td class="text-xs-right">{{ props.item.calories }}</td>
-        <td class="text-xs-right">{{ props.item.fat }}</td>
-        <td class="text-xs-right">{{ props.item.carbs }}</td>
-        <td class="text-xs-right">{{ props.item.protein }}</td>
-        <td class="text-xs-right">{{ props.item.iron }}</td>
-      </template>
-    </v-data-table>
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'ExperiencesList',
-  components: {},
-  data() {
-    return {
-      headers: [
-        {
-          text: 'Experience',
-          align: 'left',
-          value: 'name'
-        },
-        { text: 'Calories', value: 'calories' },
-        { text: 'Fat (g)', value: 'fat' },
-        { text: 'Carbs (g)', value: 'carbs' },
-        { text: 'Protein (g)', value: 'protein' },
-        { text: 'Iron (%)', value: 'iron' }
-      ],
-      desserts: [
-        {
-          name: 'Frozen Yogurt',
-          calories: 159,
-          fat: 6.0,
-          carbs: 24,
-          protein: 4.0,
-          iron: '1%'
-        },
-        {
-          name: 'Ice cream sandwich',
-          calories: 237,
-          fat: 9.0,
-          carbs: 37,
-          protein: 4.3,
-          iron: '1%'
-        },
-        {
-          name: 'Eclair',
-          calories: 262,
-          fat: 16.0,
-          carbs: 23,
-          protein: 6.0,
-          iron: '7%'
-        },
-        {
-          name: 'Cupcake',
-          calories: 305,
-          fat: 3.7,
-          carbs: 67,
-          protein: 4.3,
-          iron: '8%'
-        },
-        {
-          name: 'Gingerbread',
-          calories: 356,
-          fat: 16.0,
-          carbs: 49,
-          protein: 3.9,
-          iron: '16%'
-        },
-        {
-          name: 'Jelly bean',
-          calories: 375,
-          fat: 0.0,
-          carbs: 94,
-          protein: 0.0,
-          iron: '0%'
-        },
-        {
-          name: 'Lollipop',
-          calories: 392,
-          fat: 0.2,
-          carbs: 98,
-          protein: 0,
-          iron: '2%'
-        },
-        {
-          name: 'Honeycomb',
-          calories: 408,
-          fat: 3.2,
-          carbs: 87,
-          protein: 6.5,
-          iron: '45%'
-        },
-        {
-          name: 'Donut',
-          calories: 452,
-          fat: 25.0,
-          carbs: 51,
-          protein: 4.9,
-          iron: '22%'
-        },
-        {
-          name: 'KitKat',
-          calories: 518,
-          fat: 26.0,
-          carbs: 65,
-          protein: 7,
-          iron: '6%'
-        }
-      ]
-    }
-  }
-}
-</script>

+ 9 - 0
src/views/Experiments/NoReference.vue

@@ -0,0 +1,9 @@
+<template>
+  <div></div>
+</template>
+
+<script>
+export default {
+  name: 'ExperimentNoReference'
+}
+</script>

+ 45 - 0
src/views/Experiments/WithReference.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <v-container grid-list-md text-xs-center fluid>
+      <v-layout row wrap>
+        <v-flex xs6>
+          <v-card dark color="primary">
+            <v-card-text class="px-0">Experience image</v-card-text>
+            <v-img src="https://diran.univ-littoral.fr/api/images/Appart1opt02/appartAopt_00900.png" />
+          </v-card>
+        </v-flex>
+        <v-flex xs6>
+          <v-card dark color="primary">
+            <v-card-text>Reference image</v-card-text>
+            <v-img src="https://diran.univ-littoral.fr/api/images/Appart1opt02/appartAopt_00900.png" />
+          </v-card>
+        </v-flex>
+      </v-layout>
+    </v-container>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import { API_ROUTES } from '@/functions'
+
+export default {
+  name: 'ExperimentWithReference',
+  props: {
+    sceneId: {
+      type: String,
+      required: true
+    }
+  },
+  computed: {
+  },
+  async mounted() {
+    await this.getExtracts()
+  },
+  methods: {
+    async getExtracts() {
+      const scenes = await fetch(`${this.getHostURI}${API_ROUTES.getImage()}`).then(res => res.json())
+    }
+  }
+}
+</script>

+ 79 - 0
src/views/ExperimentsList.vue

@@ -0,0 +1,79 @@
+<template>
+  <div>
+    List of experiments
+
+    <v-card>
+      <v-card-title>
+        Choose an experiment
+        <v-spacer />
+        <v-text-field
+          v-model="search"
+          append-icon="search"
+          label="Search"
+          single-line
+          hide-details
+        />
+      </v-card-title>
+      <v-data-table
+        v-if="items"
+        :headers="headers"
+        :items="items"
+        :search="search"
+        :pagination.sync="pagination"
+      >
+        <template v-slot:items="props">
+          <td>{{ props.item.name }}</td>
+          <td class="text-xs-center">{{ props.item.completion }}</td>
+          <td class="text-xs-center"><v-btn small dark :to="props.item.link">Start experiment</v-btn></td>
+        </template>
+        <template v-slot:no-results>
+          <v-alert :value="true" color="error" icon="warning">
+            Your search for "{{ search }}" found no results.
+          </v-alert>
+        </template>
+      </v-data-table>
+    </v-card>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import Experiments from '@/router/experiments'
+
+export default {
+  name: 'ExperimentsList',
+  data() {
+    return {
+      search: '',
+      pagination: { rowsPerPage: 10 },
+      headers: [
+        { text: 'Experiment name', value: 'name' },
+        { text: 'Completion', value: 'completion', align: 'center' },
+        { text: 'Start', value: 'name', sortable: false, align: 'center' }
+      ],
+      items: null
+    }
+  },
+  computed: {
+    ...mapState(['scenesList', 'progression'])
+  },
+  mounted() {
+    this.items = Experiments.map(expe => {
+      const res = {
+        name: expe.fullName,
+        link: `/experiments/selectScene/${expe.name}`
+      }
+      // Check cache has an entry for each scenes in this experiment
+      if (this.progression[expe.name] && Object.keys(this.progression[expe.name]).every(y => this.scenesList.includes(y))) {
+        // Set experiment completion percentage
+        const numberOfDoneScenes = Object.keys(this.progression[expe.name]).filter(y => this.progression[expe.name][y].done).length
+        const percentage = Math.round(numberOfDoneScenes / this.scenesList.length * 100)
+        res.completion = `${percentage}%`
+      }
+      else res.completion = '0%'
+
+      return res
+    })
+  }
+}
+</script>

+ 0 - 29
src/views/Home.vue

@@ -1,29 +0,0 @@
-<template>
-  <div>
-    Home page
-    <div>
-      <v-btn @click="decrement()">Decrement</v-btn>
-      <v-btn @click="$store.commit('increment', -count)">Reset</v-btn>
-      <v-btn @click="increment()">Increment</v-btn>
-    </div>
-    <div>
-      {{ count }}
-    </div>
-    Count should stay in the same state if you reload the page/close your browser.
-  </div>
-</template>
-
-<script>
-import { mapActions, mapState } from 'vuex'
-
-export default {
-  name: 'Home',
-  components: {},
-  computed: {
-    ...mapState(['count'])
-  },
-  methods: {
-    ...mapActions(['increment', 'decrement'])
-  }
-}
-</script>

+ 102 - 0
src/views/SelectExperimentScene.vue

@@ -0,0 +1,102 @@
+<template>
+  <div>
+    Select a scene for the experiment "{{ experimentName }}"
+
+    <v-card>
+      <v-container
+        fluid
+        grid-list-md
+      >
+        <v-layout row wrap>
+          <v-flex
+            v-for="aScene in scenes"
+            :key="aScene.name"
+          >
+            <v-card>
+              <v-img
+                :src="aScene.thumbLink"
+                height="200px"
+              />
+              <v-card-title primary-title>
+                <div>
+                  <div class="headline">{{ aScene.name }}</div>
+                </div>
+                <v-card-actions>
+                  <v-chip v-if="aScene.progression === 'done'" color="green" text-color="white" small>
+                    <v-avatar class="green darken-4">
+                      <v-icon>check</v-icon>
+                    </v-avatar>
+                    <span>Validated</span>
+                  </v-chip>
+                  <v-chip v-else-if="aScene.progression === 'working'" color="orange" text-color="white" small>
+                    <v-avatar class="orange darken-4">
+                      <v-icon>edit</v-icon>
+                    </v-avatar>
+                    <span>Started but not validated</span>
+                  </v-chip>
+                  <v-chip v-else-if="aScene.progression === 'todo'" color="red" text-color="white" small>
+                    <v-avatar class="red darken-4">
+                      <v-icon>close</v-icon>
+                    </v-avatar>
+                    <span>Not started</span>
+                  </v-chip>
+                </v-card-actions>
+                <v-spacer />
+                <v-card-actions>
+                  <v-btn flat round :to="aScene.experimentLink">Start experiment</v-btn>
+                </v-card-actions>
+              </v-card-title>
+            </v-card>
+          </v-flex>
+        </v-layout>
+      </v-container>
+    </v-card>
+  </div>
+</template>
+
+<script>
+import { mapState, mapGetters } from 'vuex'
+import { API_ROUTES } from '@/functions'
+
+export default {
+  name: 'SelectExperimentScene',
+  props: {
+    experimentName: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      scenes: []
+    }
+  },
+  computed: {
+    ...mapState(['scenesList', 'progression']),
+    ...mapGetters(['getHostURI'])
+  },
+  async mounted() {
+    for (const aScene of this.scenesList) {
+      const { data: thumb } = await fetch(`${this.getHostURI}${API_ROUTES.getImage(aScene, 'max')}`)
+        .then(res => res.json())
+
+      let sceneObj = {}
+      sceneObj = {
+        name: thumb.sceneName,
+        thumbLink: `${this.getHostURI}${thumb.link}`,
+        experimentLink: `/experiments/${this.experimentName}/${thumb.sceneName}`
+      }
+      if (this.progression[this.experimentName] && this.progression[this.experimentName][thumb.sceneName]) {
+        const obj = this.progression[this.experimentName][thumb.sceneName]
+        if (obj.done)
+          sceneObj.progression = 'done'
+        else if (Object.entries(obj.data).length !== 0 && obj.constructor === Object)
+          sceneObj.progression = 'working'
+        else
+          sceneObj.progression = 'todo'
+      }
+      this.scenes.push(sceneObj)
+    }
+  }
+}
+</script>

+ 51 - 1
test/api/getImage.js

@@ -30,6 +30,15 @@ test('GET /getImage?sceneName=invalid/../scene&imageQuality=aaaa', async t => {
   t.truthy(res.body.data.find(x => x.includes('The specified quality is not an integer')), json(res.body))
 })
 
+test('GET /getImage?sceneName=bathroom&imageQuality=max&nearestQuality=true', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=max&nearestQuality=true`)
+
+  t.is(res.status, 400, json(res))
+  t.true(res.body.message.includes('Invalid query parameter'), json(res.body))
+  t.truthy(res.body.data.find(x => x.match(/Impossible to use.*min.*max.*median.*with.*nearestQuality/)), json(res.body))
+})
+
 test('GET /getImage?sceneName=unknown-scene-name&imageQuality=10', async t => {
   const res = await request(t.context.server)
     .get(`${apiPrefix}/getImage?sceneName=unknown-scene-name&imageQuality=10`)
@@ -46,12 +55,51 @@ test('GET /getImage?sceneName=bathroom&imageQuality=999999', async t => {
   t.truthy(res.body.message.match(/requested quality.*not found for.*scene/), json(res.body))
 })
 
+test('GET /getImage?sceneName=bathroom&imageQuality=min', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=min`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+})
+
+test('GET /getImage?sceneName=bathroom&imageQuality=median', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=median`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+})
+
+test('GET /getImage?sceneName=bathroom&imageQuality=max', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=max`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+})
+
+test('GET /getImage?sceneName=bathroom&imageQuality=99999&nearestQuality=true', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=99999&nearestQuality=true`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+})
+
 test('GET /getImage?sceneName=bathroom&imageQuality=10', async t => {
   const res = await request(t.context.server)
     .get(`${apiPrefix}/getImage?sceneName=bathroom&imageQuality=10`)
 
   t.is(res.status, 200, json(res))
-  t.is(res.body.data, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+  t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
+  t.deepEqual(res.body.data, {
+    'link': `${imageServedUrl}/bathroom/bathroom_00010.png`,
+    'fileName': 'bathroom_00010.png',
+    'sceneName': 'bathroom',
+    'quality': 10,
+    'ext': 'png'
+  }, json(res.body))
 
   // Check link is accessible and is an image
   const res2 = await request(t.context.server)
@@ -60,3 +108,5 @@ test('GET /getImage?sceneName=bathroom&imageQuality=10', async t => {
   t.is(res2.status, 200, json(res2))
   t.is(res2.header['content-type'], 'image/png', json(res2))
 })
+
+

+ 53 - 2
test/api/getImageExtracts.js

@@ -37,6 +37,15 @@ test('GET /getImageExtracts?sceneName=/../&imageQuality=a&horizontalExtractCount
   t.truthy(res.body.data.find(x => x.includes('vertical axis is not an integer')), json(res.body))
 })
 
+test('GET /getImageExtracts?sceneName=bathroom&horizontalExtractCount=5&verticalExtractCount=2&imageQuality=max&nearestQuality=true', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&horizontalExtractCount=5&verticalExtractCount=2&imageQuality=max&nearestQuality=true`)
+
+  t.is(res.status, 400, json(res))
+  t.true(res.body.message.includes('Invalid query parameter'), json(res.body))
+  t.truthy(res.body.data.find(x => x.match(/Impossible to use.*min.*max.*median.*with.*nearestQuality/)), json(res.body))
+})
+
 test('GET /getImageExtracts?sceneName=unknown-scene-name&imageQuality=10&horizontalExtractCount=5&verticalExtractCount=2', async t => {
   const res = await request(t.context.server)
     .get(`${apiPrefix}/getImageExtracts?sceneName=unknown-scene-name&imageQuality=10&horizontalExtractCount=5&verticalExtractCount=2`)
@@ -63,13 +72,55 @@ test('GET /getImageExtracts?sceneName=bathroom&imageQuality=10&horizontalExtract
   t.truthy(res.body.data.find(x => x.includes('Incompatible number of vertical extracts')), json(res.body))
 })
 
+test('GET /getImageExtracts?sceneName=bathroom&imageQuality=min&horizontalExtractCount=5&verticalExtractCount=2', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&imageQuality=min&horizontalExtractCount=5&verticalExtractCount=2`)
+
+  t.is(res.status, 200, json(res))
+  t.true(Array.isArray(res.body.data.extracts), json(res.body))
+  t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+})
+
+test('GET /getImageExtracts?sceneName=bathroom&imageQuality=median&horizontalExtractCount=5&verticalExtractCount=2', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&imageQuality=median&horizontalExtractCount=5&verticalExtractCount=2`)
+
+  t.is(res.status, 200, json(res))
+  t.true(Array.isArray(res.body.data.extracts), json(res.body))
+  t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+})
+
+test('GET /getImageExtracts?sceneName=bathroom&imageQuality=max&horizontalExtractCount=5&verticalExtractCount=2', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&imageQuality=max&horizontalExtractCount=5&verticalExtractCount=2`)
+
+  t.is(res.status, 200, json(res))
+  t.true(Array.isArray(res.body.data.extracts), json(res.body))
+  t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+})
+
+test('GET /getImageExtracts?sceneName=bathroom&imageQuality=99999&horizontalExtractCount=5&verticalExtractCount=2&nearestQuality=true', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&imageQuality=99999&horizontalExtractCount=5&verticalExtractCount=2&nearestQuality=true`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+})
+
 test.serial('GET /getImageExtracts?sceneName=bathroom&imageQuality=10&horizontalExtractCount=5&verticalExtractCount=2', async t => {
   const res = await request(t.context.server)
     .get(`${apiPrefix}/getImageExtracts?sceneName=bathroom&imageQuality=10&horizontalExtractCount=5&verticalExtractCount=2`)
 
   t.is(res.status, 200, json(res))
-  t.true(Array.isArray(res.body.data), json(res.body))
-  t.is(res.body.data[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+  t.true(Array.isArray(res.body.data.extracts), json(res.body))
+  t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
+  t.deepEqual(res.body.data.info, {
+    link: `${imageServedUrl}/bathroom/bathroom_00010.png`,
+    fileName: 'bathroom_00010.png',
+    sceneName: 'bathroom',
+    quality: 10,
+    ext: 'png'
+  }, json(res.body))
 
   // Check link is accessible and is an image
   const res2 = await request(t.context.server)

+ 74 - 49
yarn.lock

@@ -772,9 +772,9 @@
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
 "@types/node@*":
-  version "11.13.8"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.8.tgz#e5d71173c95533be9842b2c798978f095f912aab"
-  integrity sha512-szA3x/3miL90ZJxUCzx9haNbK5/zmPieGraZEe4WI+3srN0eGLiT22NXeMHmyhNEopn+IrxqMc7wdVwvPl8meg==
+  version "11.13.9"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.9.tgz#f80697caca7f7fb2526527a5c5a2743487f05ccc"
+  integrity sha512-NJ4yuEVw5podZbINp3tEqUIImMSAEHaCXRiWCf3KC32l6hIKf0iPJEh2uZdT0fELfRYk310yLmMXqy2leZQUbg==
 
 "@types/normalize-package-data@^2.4.0":
   version "2.4.0"
@@ -1173,7 +1173,15 @@ abbrev@1:
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
   integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
 
-accepts@~1.3.4, accepts@~1.3.5:
+accepts@~1.3.4:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
+  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+  dependencies:
+    mime-types "~2.1.24"
+    negotiator "0.6.2"
+
+accepts@~1.3.5:
   version "1.3.6"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.6.tgz#27de8682f0833e966dde5c5d7a63ec8523106e4b"
   integrity sha512-QsaoUD2dpVpjENy8JFpQnXP9vyzoZPmAoKrE3S6HtSB7qzSebkJNnmdY4p004FQUSSiHXPueENpoeuUW/7a8Ig==
@@ -1509,7 +1517,7 @@ async-each@^1.0.1:
   resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
   integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
 
-async-limiter@~1.0.0:
+async-limiter@^1.0.0, async-limiter@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
   integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
@@ -1939,13 +1947,13 @@ browserify-zlib@^0.2.0:
     pako "~1.0.5"
 
 browserslist@^4.0.0, browserslist@^4.3.4, browserslist@^4.5.4:
-  version "4.5.5"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.5.5.tgz#fe1a352330d2490d5735574c149a85bc18ef9b82"
-  integrity sha512-0QFO1r/2c792Ohkit5XI8Cm8pDtZxgNl2H6HU4mHrpYz7314pEYcsAVVatM0l/YmxPnEzh9VygXouj4gkFUTKA==
+  version "4.5.6"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.5.6.tgz#ea42e8581ca2513fa7f371d4dd66da763938163d"
+  integrity sha512-o/hPOtbU9oX507lIqon+UvPYqpx3mHc8cV3QemSBTXwkG8gSQSK6UKvXcE/DcleU3+A59XTUHyCvZ5qGy8xVAg==
   dependencies:
-    caniuse-lite "^1.0.30000960"
-    electron-to-chromium "^1.3.124"
-    node-releases "^1.1.14"
+    caniuse-lite "^1.0.30000963"
+    electron-to-chromium "^1.3.127"
+    node-releases "^1.1.17"
 
 bson@^1.1.1, bson@~1.1.1:
   version "1.1.1"
@@ -2167,10 +2175,10 @@ caniuse-api@^3.0.0:
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000957, caniuse-lite@^1.0.30000960:
-  version "1.0.30000963"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000963.tgz#5be481d5292f22aff5ee0db4a6c049b65b5798b1"
-  integrity sha512-n4HUiullc7Lw0LyzpeLa2ffP8KxFBGdxqD/8G3bSL6oB758hZ2UE2CVK+tQN958tJIi0/tfpjAc67aAtoHgnrQ==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000957, caniuse-lite@^1.0.30000963:
+  version "1.0.30000966"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000966.tgz#f3c6fefacfbfbfb981df6dfa68f2aae7bff41b64"
+  integrity sha512-qqLQ/uYrpZmFhPY96VuBkMEo8NhVFBZ9y/Bh+KnvGzGJ5I8hvpIaWlF2pw5gqe4PLAL+ZjsPgMOvoXSpX21Keg==
 
 capture-stack-trace@^1.0.0:
   version "1.0.1"
@@ -3399,10 +3407,10 @@ ejs@^2.6.1:
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0"
   integrity sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==
 
-electron-to-chromium@^1.3.124:
-  version "1.3.127"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.127.tgz#9b34d3d63ee0f3747967205b953b25fe7feb0e10"
-  integrity sha512-1o25iFRf/dbgauTWalEzmD1EmRN3a2CzP/K7UVpYLEBduk96LF0FyUdCcf4Ry2mAWJ1VxyblFjC93q6qlLwA2A==
+electron-to-chromium@^1.3.127:
+  version "1.3.130"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.130.tgz#27f84e823bd80a5090e2baeca4fefbaf476cf7af"
+  integrity sha512-UY2DI+gsnqGtQJqO8wXN0DnpJY+29FwJafACj0h18ZShn5besKnrRq6+lXWUbKzdxw92QQcnTqRLgNByOKXcUg==
 
 elliptic@^6.0.0:
   version "6.4.1"
@@ -3841,9 +3849,9 @@ event-pubsub@4.3.0:
   integrity sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==
 
 eventemitter3@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.1.tgz#1ab02a344af74f5cbf528969601bf0fd6aeebf98"
-  integrity sha512-MXmFv3KYbv7MPjPeGlFCTieXB9zNvmHfy4fXzZbrdMeUUk3pxQ8SS0cJ6CcwUDZnIL3ZDa01qQFzhlusB8s51Q==
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
+  integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
 
 events@^3.0.0:
   version "3.0.0"
@@ -4475,9 +4483,9 @@ global-dirs@^0.1.0:
     ini "^1.3.4"
 
 globals@^11.0.1, globals@^11.1.0, globals@^11.7.0:
-  version "11.11.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-11.11.0.tgz#dcf93757fa2de5486fbeed7118538adf789e9c2e"
-  integrity sha512-WHq43gS+6ufNOEqlrDBxVEbb8ntfXrfAUU2ZOpCxrBdGKW3gyv8mCxAfIBD0DroPKGrJ2eSsXsLtY9MPntsyTw==
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
 globby@^6.1.0:
   version "6.1.0"
@@ -6270,10 +6278,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@
   dependencies:
     minimist "0.0.8"
 
-mongodb-core@3.2.2:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.2.2.tgz#d1c084b34c102a98b4734087800115e639d907c5"
-  integrity sha512-YRgC39MuzKL0uoGoRdTmV1e9m47NbMnYmuEx4IOkgWAGXPSEzRY7cwb3N0XMmrDMnD9vp7MysNyAriIIeGgIQg==
+mongodb-core@3.2.3, mongodb-core@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.2.3.tgz#eb9bcb876f169f5843fd135f7f7686dbac0e9e34"
+  integrity sha512-UyI0rmvPPkjOJV8XGWa9VCTq7R4hBVipimhnAXeSXnuAPjuTqbyfA5Ec9RcYJ1Hhu+ISnc8bJ1KfGZd4ZkYARQ==
   dependencies:
     bson "^1.1.1"
     require_optional "^1.0.1"
@@ -6281,12 +6289,12 @@ mongodb-core@3.2.2:
   optionalDependencies:
     saslprep "^1.0.0"
 
-mongodb@3.2.2:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.2.2.tgz#49b592be3cd50097f78e0964488d18c2e189de20"
-  integrity sha512-xQ6apOOV+w7VFApdaJpWhYhzartpjIDFQjG0AwgJkLh7dBs7PTsq4A3Bia2QWpDohmAzTBIdQVLMqqLy0mwt3Q==
+mongodb@3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.2.3.tgz#4610ee33d300caa74329c2dd03e137210723cd91"
+  integrity sha512-jw8UyPsq4QleZ9z+t/pIVy3L++51vKdaJ2Q/XXeYxk/3cnKioAH8H6f5tkkDivrQL4PUgUOHe9uZzkpRFH1XtQ==
   dependencies:
-    mongodb-core "3.2.2"
+    mongodb-core "^3.2.3"
     safe-buffer "^5.1.2"
 
 mongoose-legacy-pluralize@1.0.2:
@@ -6294,16 +6302,16 @@ mongoose-legacy-pluralize@1.0.2:
   resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
   integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==
 
-mongoose@^5.5.4:
-  version "5.5.4"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.5.4.tgz#77b0664dd7e49a0a158d1eb83f6e53433b57b48b"
-  integrity sha512-xzS7fJtXGjCOZozCtlyFS8graMub1L9knp37+1dJCDmWzOtXVHeLjV2XIC9tX0sE54cxeG5rHvSmIkLpeHjjmA==
+mongoose@^5.5.5:
+  version "5.5.5"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.5.5.tgz#d04b97a1ad0740671deeeee09ded5149fa47fe56"
+  integrity sha512-rov43FpXDMoE22q8/iUoeSdg6zBtyTE/ZIIKRpQomASl0JOyD0479Weu9w5g0GjmP8fHmNjsWneoLIbnyGcnDQ==
   dependencies:
     async "2.6.1"
     bson "~1.1.1"
     kareem "2.3.0"
-    mongodb "3.2.2"
-    mongodb-core "3.2.2"
+    mongodb "3.2.3"
+    mongodb-core "3.2.3"
     mongoose-legacy-pluralize "1.0.2"
     mpath "0.5.2"
     mquery "3.2.0"
@@ -6445,6 +6453,11 @@ negotiator@0.6.1:
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
   integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=
 
+negotiator@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
+  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
+
 neo-async@^2.5.0, neo-async@^2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.0.tgz#b9d15e4d71c6762908654b5183ed38b753340835"
@@ -6533,7 +6546,7 @@ node-pre-gyp@^0.12.0:
     semver "^5.3.0"
     tar "^4"
 
-node-releases@^1.1.14:
+node-releases@^1.1.17:
   version "1.1.17"
   resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.17.tgz#71ea4631f0a97d5cd4f65f7d04ecf9072eac711a"
   integrity sha512-/SCjetyta1m7YXLgtACZGDYJdCSIBAWorDWkGCGZlydP2Ll7J48l7j/JxNYZ+xsgSPbWfdulVS/aY+GdjUsQ7Q==
@@ -8210,9 +8223,9 @@ safe-regex@^1.1.0:
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
 saslprep@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.2.tgz#da5ab936e6ea0bbae911ffec77534be370c9f52d"
-  integrity sha512-4cDsYuAjXssUSjxHKRe4DTZC0agDwsCqcMqtJAQPzC74nJ7LfAJflAtC1Zed5hMzEQKj82d3tuzqdGNRsLJ4Gw==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
+  integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
   dependencies:
     sparse-bitfield "^3.0.3"
 
@@ -9624,6 +9637,11 @@ vue-loader@^15.7.0:
     vue-hot-reload-api "^2.3.0"
     vue-style-loader "^4.1.0"
 
+vue-native-websocket@^2.0.13:
+  version "2.0.13"
+  resolved "https://registry.yarnpkg.com/vue-native-websocket/-/vue-native-websocket-2.0.13.tgz#5eaba0e7ba08749d7bff331e3290cdf5e61ca918"
+  integrity sha512-w91n76ZcvjCUzWRKX7SkVTqr9YXTxbdYQmf4RX1LvMdsM0RQvpRdWvzTWaY6kw/egsdRKVSceqDsJKbr+WTyMQ==
+
 vue-router@^3.0.6:
   version "3.0.6"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.6.tgz#2e4f0f9cbb0b96d0205ab2690cfe588935136ac3"
@@ -9662,10 +9680,10 @@ vuetify-loader@^1.2.2:
   dependencies:
     loader-utils "^1.1.0"
 
-vuetify@^1.5.13:
-  version "1.5.13"
-  resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-1.5.13.tgz#b2e406ebdc59723c5ecbd2d0595d3d2ae0cac812"
-  integrity sha512-mUOs9znDmMO/A2I117B/ZHyFlwSqkKplhSL8G2EnS+m0rvZv2Uqjo9idOBbdhaJ3A9sJWT6wDUFywEYuuIcu+g==
+vuetify@^1.5.14:
+  version "1.5.14"
+  resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-1.5.14.tgz#ff67d0b8a398be5297da159b6cd1b31f4d2898b8"
+  integrity sha512-7iM+TfghR/wu/Gl+k37lKr0N8Ddr6SxzqHtoK1dIyHgCH6SJRkpaXPw2MC5/FsAg9aUDJbYNWrzSeu5eHw+Q/w==
 
 vuex-persist@^2.0.0:
   version "2.0.0"
@@ -9963,13 +9981,20 @@ write@^0.2.1:
   dependencies:
     mkdirp "^0.5.1"
 
-ws@^6.0.0, ws@^6.2.1:
+ws@^6.0.0:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb"
   integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==
   dependencies:
     async-limiter "~1.0.0"
 
+ws@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.0.0.tgz#79351cbc3f784b3c20d0821baf4b4ff809ffbf51"
+  integrity sha512-cknCal4k0EAOrh1SHHPPWWh4qm93g1IuGGGwBjWkXmCG7LsDtL8w9w+YVfaF+KSVwiHQKDIMsSLBVftKf9d1pg==
+  dependencies:
+    async-limiter "^1.0.0"
+
 x-xss-protection@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.1.0.tgz#4f1898c332deb1e7f2be1280efb3e2c53d69c1a7"