From 0a2cb949bb2af6c050795173b678f9fb964b2548 Mon Sep 17 00:00:00 2001 From: Boris Sekachev <40690378+bsekachev@users.noreply.github.com> Date: Thu, 11 Jul 2019 22:53:24 +0300 Subject: [PATCH] CVAT.js implemented API methods and bug fixes (#564) * Increase/decrease ZOrder * ZOrder up/down * Some crutial bugs fixed * hasUnsavedChanges and merge * Added API description * Fixed bugs * Merge was tested * New file * Fixed unit tests * Fixed small bug which reproduced only after build --- cvatjs/src/annotations-collection.js | 351 +++++++++++++++++----- cvatjs/src/annotations-objects.js | 161 +++++----- cvatjs/src/annotations-saver.js | 9 +- cvatjs/src/annotations.js | 107 +++++-- cvatjs/src/api-implementation.js | 60 +--- cvatjs/src/common.js | 86 ++++++ cvatjs/src/object-state.js | 97 +++++- cvatjs/src/server-proxy.js | 29 +- cvatjs/src/session.js | 381 ++++++++++++++++-------- cvatjs/tests/mocks/server-proxy.mock.js | 27 +- 10 files changed, 917 insertions(+), 391 deletions(-) create mode 100644 cvatjs/src/common.js diff --git a/cvatjs/src/annotations-collection.js b/cvatjs/src/annotations-collection.js index 4251214c18b0..9715d8fbf5ce 100644 --- a/cvatjs/src/annotations-collection.js +++ b/cvatjs/src/annotations-collection.js @@ -17,9 +17,12 @@ PolygonTrack, PolylineTrack, PointsTrack, + Track, + Shape, Tag, objectStateFactory, } = require('./annotations-objects'); + const { checkObjectType } = require('./common'); const colors = [ '#0066FF', '#AF593E', '#01A368', '#FF861F', '#ED0A3F', '#FF3F34', '#76D7EA', @@ -39,6 +42,65 @@ '#006A93', '#867200', '#E2B631', '#D9D6CF', ]; + function shapeFactory(shapeData, clientID, injection) { + const { type } = shapeData; + const color = colors[clientID % colors.length]; + + let shapeModel = null; + switch (type) { + case 'rectangle': + shapeModel = new RectangleShape(shapeData, clientID, color, injection); + break; + case 'polygon': + shapeModel = new PolygonShape(shapeData, clientID, color, injection); + break; + case 'polyline': + shapeModel = new PolylineShape(shapeData, clientID, color, injection); + break; + case 'points': + shapeModel = new PointsShape(shapeData, clientID, color, injection); + break; + default: + throw new window.cvat.exceptions.DataError( + `An unexpected type of shape "${type}"`, + ); + } + + return shapeModel; + } + + + function trackFactory(trackData, clientID, injection) { + if (trackData.shapes.length) { + const { type } = trackData.shapes[0]; + const color = colors[clientID % colors.length]; + + let trackModel = null; + switch (type) { + case 'rectangle': + trackModel = new RectangleTrack(trackData, clientID, color, injection); + break; + case 'polygon': + trackModel = new PolygonTrack(trackData, clientID, color, injection); + break; + case 'polyline': + trackModel = new PolylineTrack(trackData, clientID, color, injection); + break; + case 'points': + trackModel = new PointsTrack(trackData, clientID, color, injection); + break; + default: + throw new window.cvat.exceptions.DataError( + `An unexpected type of track "${type}"`, + ); + } + + return trackModel; + } + + console.warn('The track without any shapes had been found. It was ignored.'); + return null; + } class Collection { constructor(labels) { @@ -47,82 +109,23 @@ return labelAccumulator; }, {}); - this.shapes = {}; // key is frame - this.tags = {}; // key is frame + this.shapes = {}; // key is a frame + this.tags = {}; // key is a frame this.tracks = []; - this.objects = {}; // key is client id + this.objects = {}; // key is a client id this.count = 0; this.flush = false; - } - - import(data) { - const injection = { + this.collectionZ = {}; // key is a frame + this.injection = { labels: this.labels, + collectionZ: this.collectionZ, }; + } - function shapeFactory(shapeData, clientID) { - const { type } = shapeData; - const color = colors[clientID % colors.length]; - let shapeModel = null; - switch (type) { - case 'rectangle': - shapeModel = new RectangleShape(shapeData, clientID, color, injection); - break; - case 'polygon': - shapeModel = new PolygonShape(shapeData, clientID, color, injection); - break; - case 'polyline': - shapeModel = new PolylineShape(shapeData, clientID, color, injection); - break; - case 'points': - shapeModel = new PointsShape(shapeData, clientID, color, injection); - break; - default: - throw new window.cvat.exceptions.DataError( - `An unexpected type of shape "${type}"`, - ); - } - - return shapeModel; - } - - - function trackFactory(trackData, clientID) { - if (trackData.shapes.length) { - const { type } = trackData.shapes[0]; - const color = colors[clientID % colors.length]; - - - let trackModel = null; - switch (type) { - case 'rectangle': - trackModel = new RectangleTrack(trackData, clientID, color, injection); - break; - case 'polygon': - trackModel = new PolygonTrack(trackData, clientID, color, injection); - break; - case 'polyline': - trackModel = new PolylineTrack(trackData, clientID, color, injection); - break; - case 'points': - trackModel = new PointsTrack(trackData, clientID, color, injection); - break; - default: - throw new window.cvat.exceptions.DataError( - `An unexpected type of track "${type}"`, - ); - } - - return trackModel; - } - - console.warn('The track without any shapes had been found. It was ignored.'); - return null; - } - + import(data) { for (const tag of data.tags) { const clientID = ++this.count; - const tagModel = new Tag(tag, clientID, injection); + const tagModel = new Tag(tag, clientID, this.injection); this.tags[tagModel.frame] = this.tags[tagModel.frame] || []; this.tags[tagModel.frame].push(tagModel); this.objects[clientID] = tagModel; @@ -130,7 +133,7 @@ for (const shape of data.shapes) { const clientID = ++this.count; - const shapeModel = shapeFactory(shape, clientID); + const shapeModel = shapeFactory(shape, clientID, this.injection); this.shapes[shapeModel.frame] = this.shapes[shapeModel.frame] || []; this.shapes[shapeModel.frame].push(shapeModel); this.objects[clientID] = shapeModel; @@ -138,7 +141,7 @@ for (const track of data.tracks) { const clientID = ++this.count; - const trackModel = trackFactory(track, clientID); + const trackModel = trackFactory(track, clientID, this.injection); // The function can return null if track doesn't have any shapes. // In this case a corresponded message will be sent to the console if (trackModel) { @@ -152,15 +155,19 @@ export() { const data = { - tracks: this.tracks.map(track => track.toJSON()), - shapes: Object.values(this.shapes).reduce((accumulator, value) => { - accumulator.push(...value); - return accumulator; - }, []).map(shape => shape.toJSON()), + tracks: this.tracks.filter(track => !track.removed) + .map(track => track.toJSON()), + shapes: Object.values(this.shapes) + .reduce((accumulator, value) => { + accumulator.push(...value); + return accumulator; + }, []).filter(shape => !shape.removed) + .map(shape => shape.toJSON()), tags: Object.values(this.tags).reduce((accumulator, value) => { accumulator.push(...value); return accumulator; - }, []).map(tag => tag.toJSON()), + }, []).filter(tag => !tag.removed) + .map(tag => tag.toJSON()), }; return data; @@ -186,12 +193,202 @@ const objectStates = []; for (const object of objects) { - const objectState = objectStateFactory.call(object, frame, object.get(frame)); + const stateData = object.get(frame); + if (stateData.outside && !stateData.keyframe) { + continue; + } + + const objectState = objectStateFactory.call(object, frame, stateData); objectStates.push(objectState); } return objectStates; } + + merge(objectStates) { + checkObjectType('merged shapes', objectStates, null, Array); + if (!objectStates.length) return; + const objectsForMerge = objectStates.map((state) => { + checkObjectType('object state', state, null, window.cvat.classes.ObjectState); + const object = this.objects[state.clientID]; + if (typeof (object) === 'undefined') { + throw new window.cvat.exceptions.ArgumentError( + 'The object has not been saved yet. Call ObjectState.save() before you can merge it', + ); + } + return object; + }); + + const keyframes = {}; // frame: position + const { label, shapeType } = objectStates[0]; + const labelAttributes = label.attributes.reduce((accumulator, attribute) => { + accumulator[attribute.id] = attribute; + return accumulator; + }, {}); + + for (let i = 0; i < objectsForMerge.length; i++) { + // For each state get corresponding object + const object = objectsForMerge[i]; + const state = objectStates[i]; + if (state.label.id !== label.id) { + throw new window.cvat.exceptions.ArgumentError( + `All shape labels are expected to be ${label.name}, but got ${state.label.name}`, + ); + } + + if (state.shapeType !== shapeType) { + throw new window.cvat.exceptions.ArgumentError( + `All shapes are expected to be ${shapeType}, but got ${state.shapeType}`, + ); + } + + // If this object is shape, get it position and save as a keyframe + if (object instanceof Shape) { + // Frame already saved and it is not outside + if (object.frame in keyframes && !keyframes[object.frame].outside) { + throw new window.cvat.exceptions.ArgumentError( + 'Expected only one visible shape per frame', + ); + } + + keyframes[object.frame] = { + type: shapeType, + frame: object.frame, + points: [...object.points], + occluded: object.occluded, + zOrder: object.zOrder, + outside: false, + attributes: Object.keys(object.attributes).reduce((accumulator, attrID) => { + // We save only mutable attributes inside a keyframe + if (attrID in labelAttributes && labelAttributes[attrID].mutable) { + accumulator.push({ + spec_id: +attrID, + value: object.attributes[attrID], + }); + } + return accumulator; + }, []), + }; + + // Push outside shape after each annotation shape + // Any not outside shape rewrites it + if (!((object.frame + 1) in keyframes)) { + keyframes[object.frame + 1] = JSON + .parse(JSON.stringify(keyframes[object.frame])); + keyframes[object.frame + 1].outside = true; + keyframes[object.frame + 1].frame++; + } + } else if (object instanceof Track) { + // If this object is track, iterate through all its + // keyframes and push copies to new keyframes + const attributes = {}; // id:value + for (const keyframe of Object.keys(object.shapes)) { + const shape = object.shapes[keyframe]; + // Frame already saved and it is not outside + if (keyframe in keyframes && !keyframes[keyframe].outside) { + // This shape is outside and non-outside shape already exists + if (shape.outside) { + continue; + } + + throw new window.cvat.exceptions.ArgumentError( + 'Expected only one visible shape per frame', + ); + } + + // We do not save an attribute if it has the same value + // We save only updates + let updatedAttributes = false; + for (const attrID in shape.attributes) { + if (!(attrID in attributes) + || attributes[attrID] !== shape.attributes[attrID]) { + updatedAttributes = true; + attributes[attrID] = shape.attributes[attrID]; + } + } + + keyframes[keyframe] = { + type: shapeType, + frame: +keyframe, + points: [...shape.points], + occluded: shape.occluded, + outside: shape.outside, + zOrder: shape.zOrder, + attributes: updatedAttributes ? Object.keys(attributes) + .reduce((accumulator, attrID) => { + accumulator.push({ + spec_id: +attrID, + value: attributes[attrID], + }); + + return accumulator; + }, []) : [], + }; + } + } else { + throw new window.cvat.exceptions.ArgumentError( + `Trying to merge unknown object type: ${object.constructor.name}. ` + + 'Only shapes and tracks are expected.', + ); + } + } + + let firstNonOutside = false; + for (const frame of Object.keys(keyframes).sort((a, b) => +a - +b)) { + // Remove all outside frames at the begin + firstNonOutside = firstNonOutside || keyframes[frame].outside; + if (!firstNonOutside && keyframes[frame].outside) { + delete keyframes[frame]; + } else { + break; + } + } + + const clientID = ++this.count; + const track = { + frame: Math.min.apply(null, Object.keys(keyframes).map(frame => +frame)), + shapes: Object.values(keyframes), + group: 0, + label_id: label.id, + attributes: Object.keys(objectStates[0].attributes) + .reduce((accumulator, attrID) => { + if (!labelAttributes[attrID].mutable) { + accumulator.push({ + spec_id: +attrID, + value: objectStates[0].attributes[attrID], + }); + } + + return accumulator; + }, []), + }; + + const trackModel = trackFactory(track, clientID, this.injection); + if (trackModel) { + this.tracks.push(trackModel); + this.objects[clientID] = trackModel; + } + + // Remove other shapes + for (const object of objectsForMerge) { + object.removed = true; + } + } + + split(objectState) { + checkObjectType('object state', objectState, window.cvat.classes.ObjectState, null); + + // TODO: split + } + + group(array) { + checkObjectType('merged shapes', array, Array, null); + for (const shape of array) { + checkObjectType('object state', shape, window.cvat.classes.ObjectState, null); + } + + // TODO: + } } module.exports = Collection; diff --git a/cvatjs/src/annotations-objects.js b/cvatjs/src/annotations-objects.js index e145fb73a797..23d7e383d9ce 100644 --- a/cvatjs/src/annotations-objects.js +++ b/cvatjs/src/annotations-objects.js @@ -9,54 +9,22 @@ (() => { const ObjectState = require('./object-state'); + const { checkObjectType } = require('./common'); + // Called with the Annotation context function objectStateFactory(frame, data) { const objectState = new ObjectState(data); - // Rewrite default implementations of save/delete - objectState.updateInCollection = this.save.bind(this, frame, objectState); - objectState.deleteFromCollection = this.delete.bind(this); + objectState.hidden = { + save: this.save.bind(this, frame, objectState), + delete: this.delete.bind(this), + up: this.up.bind(this, frame, objectState), + down: this.down.bind(this, frame, objectState), + }; return objectState; } - function checkObjectType(name, value, type, instance) { - if (type) { - if (typeof (value) !== type) { - // specific case for integers which aren't native type in JS - if (type === 'integer' && Number.isInteger(value)) { - return; - } - - if (value !== undefined) { - throw new window.cvat.exceptions.ArgumentError( - `Got ${typeof (value)} value for ${name}. ` - + `Expected ${type}`, - ); - } - - throw new window.cvat.exceptions.ArgumentError( - `Got undefined value for ${name}. ` - + `Expected ${type}`, - ); - } - } else if (instance) { - if (!(value instanceof instance)) { - if (value !== undefined) { - throw new window.cvat.exceptions.ArgumentError( - `Got ${value.constructor.name} value for ${name}. ` - + `Expected instance of ${instance.name}`, - ); - } - - throw new window.cvat.exceptions.ArgumentError( - `Got undefined value for ${name}. ` - + `Expected instance of ${instance.name}`, - ); - } - } - } - class Annotation { constructor(data, clientID, injection) { this.taskLabels = injection.labels; @@ -92,19 +60,73 @@ } } - class Shape extends Annotation { + class Drawn extends Annotation { constructor(data, clientID, color, injection) { super(data, clientID, injection); + + this.collectionZ = injection.collectionZ; + const z = this._getZ(this.frame); + z.max = Math.max(z.max, this.zOrder || 0); + z.min = Math.min(z.min, this.zOrder || 0); + + this.color = color; + this.shapeType = null; + } + + _getZ(frame) { + this.collectionZ[frame] = this.collectionZ[frame] || { + max: 0, + min: 0, + }; + + return this.collectionZ[frame]; + } + + save() { + throw window.cvat.exceptions.ScriptingError( + 'Is not implemented', + ); + } + + get() { + throw window.cvat.exceptions.ScriptingError( + 'Is not implemented', + ); + } + + toJSON() { + throw window.cvat.exceptions.ScriptingError( + 'Is not implemented', + ); + } + + // Increase ZOrder within frame + up(frame, objectState) { + const z = this._getZ(frame); + z.max++; + objectState.zOrder = z.max; + } + + // Decrease ZOrder within frame + down(frame, objectState) { + const z = this._getZ(frame); + z.min--; + objectState.zOrder = z.min; + } + } + + class Shape extends Drawn { + constructor(data, clientID, color, injection) { + super(data, clientID, color, injection); this.points = data.points; this.occluded = data.occluded; this.zOrder = data.z_order; - this.color = color; - this.shape = null; } // Method is used to export data to the server toJSON() { return { + type: this.shapeType, clientID: this.clientID, occluded: this.occluded, z_order: this.zOrder, @@ -133,9 +155,10 @@ } return { - type: window.cvat.enums.ObjectType.SHAPE, - shape: this.shape, + objectType: window.cvat.enums.ObjectType.SHAPE, + shapeType: this.shapeType, clientID: this.clientID, + serverID: this.serverID, occluded: this.occluded, lock: this.lock, zOrder: this.zOrder, @@ -232,16 +255,15 @@ } } - class Track extends Annotation { + class Track extends Drawn { constructor(data, clientID, color, injection) { - super(data, clientID, injection); + super(data, clientID, color, injection); this.shapes = data.shapes.reduce((shapeAccumulator, value) => { shapeAccumulator[value.frame] = { serverID: value.id, occluded: value.occluded, zOrder: value.z_order, points: value.points, - frame: value.frame, outside: value.outside, attributes: value.attributes.reduce((attributeAccumulator, attr) => { attributeAccumulator[attr.spec_id] = attr.value; @@ -249,17 +271,14 @@ }, {}), }; - return shapeAccumulator; - }, {}); + const z = this._getZ(value.frame); + z.max = Math.max(z.max, value.z_order); + z.min = Math.min(z.min, value.z_order); - this.attributes = data.attributes.reduce((attributeAccumulator, attr) => { - attributeAccumulator[attr.spec_id] = attr.value; - return attributeAccumulator; + return shapeAccumulator; }, {}); this.cache = {}; - this.color = color; - this.shape = null; } // Method is used to export data to the server @@ -280,7 +299,7 @@ }, []), shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => { shapesAccumulator.push({ - type: this.shape, + type: this.shapeType, occluded: this.shapes[frame].occluded, z_order: this.shapes[frame].zOrder, points: [...this.shapes[frame].points], @@ -312,9 +331,10 @@ attributes: this.getAttributes(frame), label: this.label, group: this.group, - type: window.cvat.enums.ObjectType.TRACK, - shape: this.shape, + objectType: window.cvat.enums.ObjectType.TRACK, + shapeType: this.shapeType, clientID: this.clientID, + serverID: this.serverID, lock: this.lock, color: this.color, }, @@ -630,8 +650,9 @@ } return { - type: window.cvat.enums.ObjectType.TAG, + objectType: window.cvat.enums.ObjectType.TAG, clientID: this.clientID, + serverID: this.serverID, lock: this.lock, attributes: Object.assign({}, this.attributes), label: this.label, @@ -697,7 +718,7 @@ class RectangleShape extends Shape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.RECTANGLE; + this.shapeType = window.cvat.enums.ObjectShape.RECTANGLE; } } @@ -710,28 +731,28 @@ class PolygonShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POLYGON; + this.shapeType = window.cvat.enums.ObjectShape.POLYGON; } } class PolylineShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POLYLINE; + this.shapeType = window.cvat.enums.ObjectShape.POLYLINE; } } class PointsShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POINTS; + this.shapeType = window.cvat.enums.ObjectShape.POINTS; } } class RectangleTrack extends Track { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.RECTANGLE; + this.shapeType = window.cvat.enums.ObjectShape.RECTANGLE; } interpolatePosition(leftPosition, rightPosition, targetFrame) { @@ -1142,25 +1163,21 @@ class PolygonTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POLYGON; + this.shapeType = window.cvat.enums.ObjectShape.POLYGON; } } class PolylineTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POLYLINE; - } - - appendMapping() { - // TODO after checking how it works with polygons + this.shapeType = window.cvat.enums.ObjectShape.POLYLINE; } } class PointsTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); - this.shape = window.cvat.enums.ObjectShape.POINTS; + this.shapeType = window.cvat.enums.ObjectShape.POINTS; } } @@ -1173,6 +1190,8 @@ PolygonTrack, PolylineTrack, PointsTrack, + Track, + Shape, Tag, objectStateFactory, }; diff --git a/cvatjs/src/annotations-saver.js b/cvatjs/src/annotations-saver.js index e8db605f9082..5da4d753b62a 100644 --- a/cvatjs/src/annotations-saver.js +++ b/cvatjs/src/annotations-saver.js @@ -12,7 +12,7 @@ class AnnotationsSaver { constructor(version, collection, session) { - this.session = session.constructor.name.toLowerCase(); + this.sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; this.id = session.id; this.version = version; this.collection = collection; @@ -42,7 +42,7 @@ async _request(data, action) { const result = await serverProxy.annotations.updateAnnotations( - this.session, + this.sessionType, this.id, data, action, @@ -249,6 +249,9 @@ delete this.initialObjects[object.id]; } } + + this.hash = this._getHash(); + onUpdate('Saving is done'); } catch (error) { onUpdate(`Can not save annotations: ${error.message}`); throw error; @@ -256,7 +259,7 @@ } hasUnsavedChanges() { - return this._getHash() !== this._hash; + return this._getHash() !== this.hash; } } diff --git a/cvatjs/src/annotations.js b/cvatjs/src/annotations.js index 181eabdf8c57..36160a5374c5 100644 --- a/cvatjs/src/annotations.js +++ b/cvatjs/src/annotations.js @@ -15,56 +15,107 @@ const jobCache = {}; const taskCache = {}; - async function getJobAnnotations(job, frame, filter) { - if (!(job.id in jobCache)) { - const rawAnnotations = await serverProxy.annotations.getJobAnnotations(job.id); - const collection = new Collection(job.task.labels).import(rawAnnotations); - const saver = new AnnotationsSaver(rawAnnotations.version, collection, job); + function getCache(sessionType) { + if (sessionType === 'task') { + return taskCache; + } - jobCache[job.id] = { - collection, - saver, - }; + if (sessionType === 'job') { + return jobCache; } - return jobCache[job.id].collection.get(frame, filter); + throw new window.cvat.exceptions.ScriptingError( + `Unknown session type was received ${sessionType}`, + ); } - async function getTaskAnnotations(task, frame, filter) { - if (!(task.id in jobCache)) { - const rawAnnotations = await serverProxy.annotations.getTaskAnnotations(task.id); - const collection = new Collection(task.labels).import(rawAnnotations); - const saver = new AnnotationsSaver(rawAnnotations.version, collection, task); + async function getAnnotations(session, frame, filter) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (!(session.id in cache)) { + const rawAnnotations = await serverProxy.annotations + .getAnnotations(sessionType, session.id); + const collection = new Collection(session.labels || session.task.labels) + .import(rawAnnotations); + const saver = new AnnotationsSaver(rawAnnotations.version, collection, session); - taskCache[task.id] = { + cache[session.id] = { collection, saver, }; } - return taskCache[task.id].collection.get(frame, filter); + return cache[session.id].collection.get(frame, filter); } - async function saveJobAnnotations(job, onUpdate) { - if (job.id in jobCache) { - await jobCache[job.id].saver.save(onUpdate); + async function saveAnnotations(session, onUpdate) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (session.id in cache) { + await cache[session.id].saver.save(onUpdate); } // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it } - async function saveTaskAnnotations(task, onUpdate) { - if (task.id in taskCache) { - await taskCache[task.id].saver.save(onUpdate); + function mergeAnnotations(session, objectStates) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (session.id in cache) { + return cache[session.id].collection.merge(objectStates); } - // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it + throw window.cvat.exceptions.DataError( + 'Collection has not been initialized yet. Call annotations.get() before', + ); + } + + function splitAnnotations(session, objectState, frame) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (session.id in cache) { + return cache[session.id].collection.split(objectState, frame); + } + + throw window.cvat.exceptions.DataError( + 'Collection has not been initialized yet. Call annotations.get() before', + ); + } + + function groupAnnotations(session, objectStates) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (session.id in cache) { + return cache[session.id].collection.group(objectStates); + } + + throw window.cvat.exceptions.DataError( + 'Collection has not been initialized yet. Call annotations.get() before', + ); + } + + function hasUnsavedChanges(session) { + const sessionType = session instanceof window.cvat.classes.Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (session.id in cache) { + return cache[session.id].saver.hasUnsavedChanges(); + } + + return false; } module.exports = { - getJobAnnotations, - getTaskAnnotations, - saveJobAnnotations, - saveTaskAnnotations, + getAnnotations, + saveAnnotations, + hasUnsavedChanges, + mergeAnnotations, + splitAnnotations, + groupAnnotations, }; })(); diff --git a/cvatjs/src/api-implementation.js b/cvatjs/src/api-implementation.js index e2d7505e8bc8..0a7d8bb72141 100644 --- a/cvatjs/src/api-implementation.js +++ b/cvatjs/src/api-implementation.js @@ -13,47 +13,13 @@ (() => { const PluginRegistry = require('./plugins'); const serverProxy = require('./server-proxy'); - - function isBoolean(value) { - return typeof (value) === 'boolean'; - } - - function isInteger(value) { - return typeof (value) === 'number' && Number.isInteger(value); - } - - function isEnum(value) { - // Called with specific Enum context - for (const key in this) { - if (Object.prototype.hasOwnProperty.call(this, key)) { - if (this[key] === value) { - return true; - } - } - } - - return false; - } - - function isString(value) { - return typeof (value) === 'string'; - } - - function checkFilter(filter, fields) { - for (const prop in filter) { - if (Object.prototype.hasOwnProperty.call(filter, prop)) { - if (!(prop in fields)) { - throw new window.cvat.exceptions.ArgumentError( - `Unsupported filter property has been recieved: "${prop}"`, - ); - } else if (!fields[prop](filter[prop])) { - throw new window.cvat.exceptions.ArgumentError( - `Received filter property ${prop} is not satisfied for checker`, - ); - } - } - } - } + const { + isBoolean, + isInteger, + isEnum, + isString, + checkFilter, + } = require('./common'); function implementAPI(cvat) { cvat.plugins.list.implementation = PluginRegistry.list; @@ -112,17 +78,19 @@ ); } - let task = null; + let tasks = null; if ('taskID' in filter) { - task = await serverProxy.tasks.getTasks(`id=${filter.taskID}`); + tasks = await serverProxy.tasks.getTasks(`id=${filter.taskID}`); } else { const job = await serverProxy.jobs.getJob(filter.jobID); - task = await serverProxy.tasks.getTasks(`id=${job.task_id}`); + if (typeof (job.task_id) !== 'undefined') { + tasks = await serverProxy.tasks.getTasks(`id=${job.task_id}`); + } } // If task was found by its id, then create task instance and get Job instance from it - if (task.length) { - task = new window.cvat.classes.Task(task[0]); + if (tasks !== null && tasks.length) { + const task = new window.cvat.classes.Task(tasks[0]); return filter.jobID ? task.jobs.filter(job => job.id === filter.jobID) : task.jobs; } diff --git a/cvatjs/src/common.js b/cvatjs/src/common.js new file mode 100644 index 000000000000..bd5b2e884684 --- /dev/null +++ b/cvatjs/src/common.js @@ -0,0 +1,86 @@ +/* +* Copyright (C) 2018 Intel Corporation +* SPDX-License-Identifier: MIT +*/ + +(() => { + function isBoolean(value) { + return typeof (value) === 'boolean'; + } + + function isInteger(value) { + return typeof (value) === 'number' && Number.isInteger(value); + } + + // Called with specific Enum context + function isEnum(value) { + for (const key in this) { + if (Object.prototype.hasOwnProperty.call(this, key)) { + if (this[key] === value) { + return true; + } + } + } + + return false; + } + + function isString(value) { + return typeof (value) === 'string'; + } + + function checkFilter(filter, fields) { + for (const prop in filter) { + if (Object.prototype.hasOwnProperty.call(filter, prop)) { + if (!(prop in fields)) { + throw new window.cvat.exceptions.ArgumentError( + `Unsupported filter property has been recieved: "${prop}"`, + ); + } else if (!fields[prop](filter[prop])) { + throw new window.cvat.exceptions.ArgumentError( + `Received filter property ${prop} is not satisfied for checker`, + ); + } + } + } + } + + function checkObjectType(name, value, type, instance) { + if (type) { + if (typeof (value) !== type) { + // specific case for integers which aren't native type in JS + if (type === 'integer' && Number.isInteger(value)) { + return; + } + + throw new window.cvat.exceptions.ArgumentError( + `Got "${name}" value of type: "${typeof (value)}". ` + + `Expected "${type}"`, + ); + } + } else if (instance) { + if (!(value instanceof instance)) { + if (value !== undefined) { + throw new window.cvat.exceptions.ArgumentError( + `Got ${value.constructor.name} value for ${name}. ` + + `Expected instance of ${instance.name}`, + ); + } + + throw new window.cvat.exceptions.ArgumentError( + `Got undefined value for ${name}. ` + + `Expected instance of ${instance.name}`, + ); + } + } + } + + module.exports = { + isBoolean, + isInteger, + isEnum, + isString, + checkFilter, + checkObjectType, + }; +})(); diff --git a/cvatjs/src/object-state.js b/cvatjs/src/object-state.js index 44acce924425..203874b08157 100644 --- a/cvatjs/src/object-state.js +++ b/cvatjs/src/object-state.js @@ -21,7 +21,7 @@ * Necessary fields: type, shape * Necessary fields for objects which haven't been added to collection yet: frame * Optional fields: points, group, zOrder, outside, occluded, - * attributes, lock, label, mode, color, keyframe + * attributes, lock, label, mode, color, keyframe, clientID, serverID * These fields can be set later via setters */ constructor(serialized) { @@ -39,9 +39,12 @@ lock: null, color: null, + clientID: serialized.clientID, + serverID: serialized.serverID, + frame: serialized.frame, - type: serialized.type, - shape: serialized.shape, + objectType: serialized.objectType, + shapeType: serialized.shapeType, updateFlags: {}, }; @@ -79,25 +82,45 @@ */ get: () => data.frame, }, - type: { + objectType: { /** - * @name type + * @name objectType * @type {module:API.cvat.enums.ObjectType} * @memberof module:API.cvat.classes.ObjectState * @readonly * @instance */ - get: () => data.type, + get: () => data.objectType, }, - shape: { + shapeType: { /** - * @name shape + * @name shapeType * @type {module:API.cvat.enums.ObjectShape} * @memberof module:API.cvat.classes.ObjectState * @readonly * @instance */ - get: () => data.shape, + get: () => data.shapeType, + }, + clientID: { + /** + * @name clientID + * @type {integer} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.clientID, + }, + serverID: { + /** + * @name serverID + * @type {integer} + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + */ + get: () => data.serverID, }, label: { /** @@ -287,7 +310,7 @@ * @instance * @param {boolean} [force=false] delete object even if it is locked * @async - * @returns {boolean} wheter object was deleted + * @returns {boolean} true if object has been deleted * @throws {module:API.cvat.exceptions.PluginError} */ async delete(force = false) { @@ -295,12 +318,42 @@ .apiWrapper.call(this, ObjectState.prototype.delete, force); return result; } + + /** + * Set the highest ZOrder within a frame + * @method up + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + */ + async up() { + const result = await PluginRegistry + .apiWrapper.call(this, ObjectState.prototype.up); + return result; + } + + /** + * Set the lowest ZOrder within a frame + * @method down + * @memberof module:API.cvat.classes.ObjectState + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + */ + async down() { + const result = await PluginRegistry + .apiWrapper.call(this, ObjectState.prototype.down); + return result; + } } // Default implementation saves element in collection ObjectState.prototype.save.implementation = async function () { - if (this.updateInCollection) { - return this.updateInCollection(); + if (this.hidden && this.hidden.save) { + return this.hidden.save(); } return this; @@ -308,8 +361,24 @@ // Default implementation do nothing ObjectState.prototype.delete.implementation = async function (force) { - if (this.deleteFromCollection) { - return this.deleteFromCollection(force); + if (this.hidden && this.hidden.delete) { + return this.hidden.delete(force); + } + + return false; + }; + + ObjectState.prototype.up.implementation = async function () { + if (this.hidden && this.hidden.up) { + return this.hidden.up(); + } + + return false; + }; + + ObjectState.prototype.down.implementation = async function () { + if (this.hidden && this.hidden.down) { + return this.hidden.down(); } return false; diff --git a/cvatjs/src/server-proxy.js b/cvatjs/src/server-proxy.js index b74aa1ee2963..7b7238f80e38 100644 --- a/cvatjs/src/server-proxy.js +++ b/cvatjs/src/server-proxy.js @@ -460,37 +460,19 @@ return response.data; } - async function getTaskAnnotations(tid) { - const { backendAPI } = window.cvat.config; - - let response = null; - try { - response = await Axios.get(`${backendAPI}/tasks/${tid}/annotations`, { - proxy: window.cvat.config.proxy, - }); - } catch (errorData) { - const code = errorData.response ? errorData.response.status : errorData.code; - throw new window.cvat.exceptions.ServerError( - `Could not get annotations for the task ${tid} from the server`, - code, - ); - } - - return response.data; - } - - async function getJobAnnotations(jid) { + // Session is 'task' or 'job' + async function getAnnotations(session, id) { const { backendAPI } = window.cvat.config; let response = null; try { - response = await Axios.get(`${backendAPI}/jobs/${jid}/annotations`, { + response = await Axios.get(`${backendAPI}/${session}s/${id}/annotations`, { proxy: window.cvat.config.proxy, }); } catch (errorData) { const code = errorData.response ? errorData.response.status : errorData.code; throw new window.cvat.exceptions.ServerError( - `Could not get annotations for the job ${jid} from the server`, + `Could not get annotations for the ${session} ${id} from the server`, code, ); } @@ -586,9 +568,8 @@ annotations: { value: Object.freeze({ - getTaskAnnotations, - getJobAnnotations, updateAnnotations, + getAnnotations, }), writable: false, }, diff --git a/cvatjs/src/session.js b/cvatjs/src/session.js index c4003634dd3b..939d2f552bb6 100644 --- a/cvatjs/src/session.js +++ b/cvatjs/src/session.js @@ -12,138 +12,159 @@ const serverProxy = require('./server-proxy'); const { getFrame } = require('./frames'); const { - getJobAnnotations, - getTaskAnnotations, - saveJobAnnotations, - saveTaskAnnotations, + getAnnotations, + saveAnnotations, + hasUnsavedChanges, + mergeAnnotations, + splitAnnotations, + groupAnnotations, } = require('./annotations'); - function buildDublicatedAPI() { - const annotations = Object.freeze({ - value: { - async upload(file) { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.upload, file); - return result; - }, + function buildDublicatedAPI(prototype) { + Object.defineProperties(prototype, { + annotations: Object.freeze({ + value: { + async upload(file) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.upload, file); + return result; + }, - async save() { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.save); - return result; - }, + async save() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.save); + return result; + }, - async clear() { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.clear); - return result; - }, + async clear() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.clear); + return result; + }, - async dump() { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.dump); - return result; - }, + async dump() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.dump); + return result; + }, - async statistics() { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.statistics); - return result; - }, + async statistics() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.statistics); + return result; + }, - async put(arrayOfObjects = []) { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.put, arrayOfObjects); - return result; - }, + async put(arrayOfObjects = []) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.put, arrayOfObjects); + return result; + }, - async get(frame, filter = {}) { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.get, frame, filter); - return result; - }, + async get(frame, filter = {}) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.get, frame, filter); + return result; + }, - async search(filter, frameFrom, frameTo) { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.search, - filter, frameFrom, frameTo); - return result; - }, + async search(filter, frameFrom, frameTo) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.search, + filter, frameFrom, frameTo); + return result; + }, - async select(frame, x, y) { - const result = await PluginRegistry - .apiWrapper.call(this, annotations.value.select, frame, x, y); - return result; - }, - }, - }); + async select(frame, x, y) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.select, frame, x, y); + return result; + }, - const frames = Object.freeze({ - value: { - async get(frame) { - const result = await PluginRegistry - .apiWrapper.call(this, frames.value.get, frame); - return result; - }, - }, - }); + async hasUnsavedChanges() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.hasUnsavedChanges); + return result; + }, - const logs = Object.freeze({ - value: { - async put(logType, details) { - const result = await PluginRegistry - .apiWrapper.call(this, logs.value.put, logType, details); - return result; - }, - async save(onUpdate) { - const result = await PluginRegistry - .apiWrapper.call(this, logs.value.save, onUpdate); - return result; - }, - }, - }); + async merge(objectStates) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.merge, objectStates); + return result; + }, - const actions = Object.freeze({ - value: { - async undo(count) { - const result = await PluginRegistry - .apiWrapper.call(this, actions.value.undo, count); - return result; + async split(objectState, frame) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.split, objectState, frame); + return result; + }, + + async group(objectStates) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.annotations.group, objectStates); + return result; + }, }, - async redo(count) { - const result = await PluginRegistry - .apiWrapper.call(this, actions.value.redo, count); - return result; + writable: true, + }), + frames: Object.freeze({ + value: { + async get(frame) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.frames.get, frame); + return result; + }, }, - async clear() { - const result = await PluginRegistry - .apiWrapper.call(this, actions.value.clear); - return result; + writable: true, + }), + logs: Object.freeze({ + value: { + async put(logType, details) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.logs.put, logType, details); + return result; + }, + async save(onUpdate) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.logs.save, onUpdate); + return result; + }, }, - }, - }); - - const events = Object.freeze({ - value: { - async subscribe(eventType, callback) { - const result = await PluginRegistry - .apiWrapper.call(this, events.value.subscribe, eventType, callback); - return result; + writable: true, + }), + actions: Object.freeze({ + value: { + async undo(count) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.actions.undo, count); + return result; + }, + async redo(count) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.actions.redo, count); + return result; + }, + async clear() { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.actions.clear); + return result; + }, }, - async unsubscribe(eventType, callback = null) { - const result = await PluginRegistry - .apiWrapper.call(this, events.value.unsubscribe, eventType, callback); - return result; + writable: true, + }), + events: Object.freeze({ + value: { + async subscribe(evType, callback) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.events.subscribe, evType, callback); + return result; + }, + async unsubscribe(evType, callback = null) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.events.unsubscribe, evType, callback); + return result; + }, }, - }, - }); - - return Object.freeze({ - annotations, - frames, - logs, - actions, - events, + writable: true, + }), }); } @@ -268,12 +289,60 @@ * @param {float} x horizontal coordinate * @param {float} y vertical coordinate * @returns {(integer|null)} - * identifier of a selected object or null if no one of objects is on position + * an ID of a selected object or null if no one of objects is on position + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ + /** + * Method unites several shapes and tracks into the one + * All shapes must be the same (rectangle, polygon, etc) + * All labels must be the same + * After successful merge you need to update object states on a frame + * @method merge + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} objectStates * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ArgumentError} * @instance * @async */ + /** + * Method splits a track into two parts + * (start frame: previous frame), (frame, last frame) + * After successful split you need to update object states on a frame + * @method split + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState} objectState + * @param {integer} frame + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ + /** + * Method creates a new group and put all passed objects into it + * After successful split you need to update object states on a frame + * @method group + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} objectStates + * @returns {integer} an ID of created group + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ + /** + * Indicate if there are any changes in + * annotations which haven't been saved on a server + * @method hasUnsavedChanges + * @memberof Session.annotations + * @returns {boolean} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ /** @@ -511,9 +580,22 @@ }, })); - this.frames.get.implementation = this.frames.get.implementation.bind(this); - this.annotations.get.implementation = this.annotations.get.implementation.bind(this); - this.annotations.save.implementation = this.annotations.save.implementation.bind(this); + // When we call a function, for example: task.annotations.get() + // In the method get we lose the task context + // So, we need return it + this.annotations = { + get: Object.getPrototypeOf(this).annotations.get.bind(this), + save: Object.getPrototypeOf(this).annotations.save.bind(this), + merge: Object.getPrototypeOf(this).annotations.merge.bind(this), + split: Object.getPrototypeOf(this).annotations.split.bind(this), + group: Object.getPrototypeOf(this).annotations.group.bind(this), + hasUnsavedChanges: Object.getPrototypeOf(this) + .annotations.hasUnsavedChanges.bind(this), + }; + + this.frames = { + get: Object.getPrototypeOf(this).frames.get.bind(this), + }; } /** @@ -535,7 +617,7 @@ // Fill up the prototype by properties. Class syntax doesn't allow do it // So, we do it seperately - Object.defineProperties(Job.prototype, buildDublicatedAPI()); + buildDublicatedAPI(Job.prototype); Job.prototype.save.implementation = async function () { // TODO: Add ability to change an assignee @@ -578,12 +660,28 @@ ); } - const annotationsData = await getJobAnnotations(this, frame, filter); + const annotationsData = await getAnnotations(this, frame, filter); return annotationsData; }; Job.prototype.annotations.save.implementation = async function (onUpdate) { - await saveJobAnnotations(this, onUpdate); + await saveAnnotations(this, onUpdate); + }; + + Job.prototype.annotations.merge.implementation = async function (objectStates) { + await mergeAnnotations(this, objectStates); + }; + + Job.prototype.annotations.split.implementation = async function (objectState, frame) { + await splitAnnotations(this, objectState, frame); + }; + + Job.prototype.annotations.group.implementation = async function (objectStates) { + await groupAnnotations(this, objectStates); + }; + + Job.prototype.annotations.hasUnsavedChanges.implementation = async function () { + return hasUnsavedChanges(this); }; /** @@ -987,9 +1085,22 @@ }, })); - this.frames.get.implementation = this.frames.get.implementation.bind(this); - this.annotations.get.implementation = this.annotations.get.implementation.bind(this); - this.annotations.save.implementation = this.annotations.save.implementation.bind(this); + // When we call a function, for example: task.annotations.get() + // In the method get we lose the task context + // So, we need return it + this.annotations = { + get: Object.getPrototypeOf(this).annotations.get.bind(this), + save: Object.getPrototypeOf(this).annotations.save.bind(this), + merge: Object.getPrototypeOf(this).annotations.merge.bind(this), + split: Object.getPrototypeOf(this).annotations.split.bind(this), + group: Object.getPrototypeOf(this).annotations.group.bind(this), + hasUnsavedChanges: Object.getPrototypeOf(this) + .annotations.hasUnsavedChanges.bind(this), + }; + + this.frames = { + get: Object.getPrototypeOf(this).frames.get.bind(this), + }; } /** @@ -1031,7 +1142,7 @@ // Fill up the prototype by properties. Class syntax doesn't allow do it // So, we do it seperately - Object.defineProperties(Task.prototype, buildDublicatedAPI()); + buildDublicatedAPI(Task.prototype); Task.prototype.save.implementation = async function saveTaskImplementation(onUpdate) { // TODO: Add ability to change an owner and an assignee @@ -1104,12 +1215,28 @@ ); } - const annotationsData = await getTaskAnnotations(this, frame, filter); + const annotationsData = await getAnnotations(this, frame, filter); return annotationsData; }; Task.prototype.annotations.save.implementation = async function (onUpdate) { - await saveTaskAnnotations(this, onUpdate); + await saveAnnotations(this, onUpdate); + }; + + Task.prototype.annotations.merge.implementation = async function (objectStates) { + await mergeAnnotations(this, objectStates); + }; + + Task.prototype.annotations.split.implementation = async function (objectState, frame) { + await splitAnnotations(this, objectState, frame); + }; + + Task.prototype.annotations.group.implementation = async function (objectStates) { + await groupAnnotations(this, objectStates); + }; + + Task.prototype.annotations.hasUnsavedChanges.implementation = async function () { + return hasUnsavedChanges(this); }; module.exports = { diff --git a/cvatjs/tests/mocks/server-proxy.mock.js b/cvatjs/tests/mocks/server-proxy.mock.js index 41e4c308cf45..5b5f0e5e8f10 100644 --- a/cvatjs/tests/mocks/server-proxy.mock.js +++ b/cvatjs/tests/mocks/server-proxy.mock.js @@ -54,6 +54,10 @@ class ServerProxy { return null; } + async function logout() { + return null; + } + async function getTasks(filter = '') { function QueryStringToJSON(query) { const pairs = [...new URLSearchParams(query).entries()]; @@ -134,7 +138,7 @@ class ServerProxy { } async function getJob(jobID) { - return tasksDummyData.results.reduce((acc, task) => { + const jobs = tasksDummyData.results.reduce((acc, task) => { for (const segment of task.segments) { for (const job of segment.jobs) { const copy = JSON.parse(JSON.stringify(job)); @@ -148,6 +152,10 @@ class ServerProxy { return acc; }, []).filter(job => job.id === jobID); + + return jobs[0] || { + detail: 'Not found.', + }; } async function saveJob(id, jobData) { @@ -185,6 +193,14 @@ class ServerProxy { return null; } + async function getAnnotations() { + return null; + } + + async function updateAnnotations() { + return null; + } + Object.defineProperties(this, Object.freeze({ server: { value: Object.freeze({ @@ -192,6 +208,7 @@ class ServerProxy { share, exception, login, + logout, }), writable: false, }, @@ -229,6 +246,14 @@ class ServerProxy { }), writable: false, }, + + annotations: { + value: Object.freeze({ + updateAnnotations, + getAnnotations, + }), + writable: false, + }, })); } }