Bläddra i källkod

Merge branch 'master' of https://github.com/molstar/molstar

Alexander Rose 5 år sedan
förälder
incheckning
35a56bf37c

+ 24 - 8
src/mol-model-props/rcsb/assembly-symmetry.ts

@@ -9,26 +9,42 @@ import query from './graphql/symmetry.gql';
 
 import { ParamDefinition as PD } from '../../mol-util/param-definition'
 import { CustomPropertyDescriptor, Structure } from '../../mol-model/structure';
-import { Database as _Database } from '../../mol-data/db'
+import { Database as _Database, Column } from '../../mol-data/db'
 import { GraphQLClient } from '../../mol-util/graphql-client';
 import { CustomProperty } from '../common/custom-property';
 import { NonNullableArray } from '../../mol-util/type-helpers';
 import { CustomStructureProperty } from '../common/custom-structure-property';
 
+const BiologicalAssemblyNames = new Set([
+    'author_and_software_defined_assembly',
+    'author_defined_assembly',
+    'complete icosahedral assembly',
+    'complete point assembly',
+    'representative helical assembly',
+    'software_defined_assembly'
+])
+
 export namespace AssemblySymmetry {
     export const DefaultServerUrl = 'http://data-beta.rcsb.org/graphql'
 
     export function isApplicable(structure?: Structure): boolean {
-        return (
-            !!structure &&
-            structure.models.length === 1 &&
-            structure.models[0].sourceData.kind === 'mmCIF' &&
-            (structure.models[0].sourceData.data.database_2.database_id.isDefined ||
-                structure.models[0].entryId.length === 4)
-        )
+        // check if structure is from pdb entry
+        if (!structure || structure.models.length !== 1 || structure.models[0].sourceData.kind !== 'mmCIF' || (!structure.models[0].sourceData.data.database_2.database_id.isDefined &&
+        structure.models[0].entryId.length !== 4)) return false
+
+        // check if assembly is 'biological'
+        const mmcif = structure.models[0].sourceData.data
+        if (!mmcif.pdbx_struct_assembly.details.isDefined) return false
+        const id = structure.units[0].conformation.operator.assembly.id
+        const indices = Column.indicesOf(mmcif.pdbx_struct_assembly.id, e => e === id)
+        if (indices.length !== 1) return false
+        const details = mmcif.pdbx_struct_assembly.details.value(indices[0])
+        return BiologicalAssemblyNames.has(details)
     }
 
     export async function fetch(ctx: CustomProperty.Context, structure: Structure, props: AssemblySymmetryProps): Promise<AssemblySymmetryValue> {
+        if (!isApplicable(structure)) return []
+
         const client = new GraphQLClient(props.serverUrl, ctx.fetch)
         const variables: AssemblySymmetryQueryVariables = {
             assembly_id: structure.units[0].conformation.operator.assembly.id,

+ 22 - 8
src/mol-model-props/rcsb/validation-report.ts

@@ -6,7 +6,6 @@
 
 import { ParamDefinition as PD } from '../../mol-util/param-definition'
 import { CustomPropertyDescriptor, Structure, Unit } from '../../mol-model/structure';
-// import { Database as _Database } from '../../mol-data/db'
 import { CustomProperty } from '../common/custom-property';
 import { CustomModelProperty } from '../common/custom-model-property';
 import { Model, ElementIndex, ResidueIndex } from '../../mol-model/structure/model';
@@ -203,7 +202,7 @@ function createInterUnitClashes(structure: Structure, clashes: ValidationReport[
                     builder.add(indexA as UnitIndex, indexB as UnitIndex, {
                         id: id[i],
                         magnitude: magnitude[i],
-                        distance: distance[i]
+                        distance: distance[i],
                     })
                 }
             }
@@ -335,11 +334,12 @@ function ClashesBuilder(elementsCount: number) {
     const magnitudes: number[] = []
     const distances: number[] = []
 
-    const seen = new Map<number, ElementIndex>()
+    const seen = new Map<string, ElementIndex>()
 
     return {
-        add(element: ElementIndex, id: number, magnitude: number, distance: number) {
-            const other = seen.get(id)
+        add(element: ElementIndex, id: number, magnitude: number, distance: number, isSymop: boolean) {
+            const hash = `${id}|${isSymop ? 's' : ''}`
+            const other = seen.get(hash)
             if (other !== undefined) {
                 aIndices[aIndices.length] = element
                 bIndices[bIndices.length] = other
@@ -347,7 +347,7 @@ function ClashesBuilder(elementsCount: number) {
                 magnitudes[magnitudes.length] = magnitude
                 distances[distances.length] = distance
             } else {
-                seen.set(id, element)
+                seen.set(hash, element)
             }
         },
         get() {
@@ -513,7 +513,22 @@ function parseValidationReportXml(xml: XMLDocument, model: Model): ValidationRep
             const label_atom_id = getItem(ca, 'atom')
             const element = index.findAtomOnResidue(rI, label_atom_id, label_alt_id)
             if (element !== -1) {
-                clashesBuilder.add(element, id, magnitude, distance)
+                clashesBuilder.add(element, id, magnitude, distance, false)
+            }
+        }
+
+        const symmClashes = g.getElementsByTagName('symm-clash')
+        if (symmClashes.length) issues.add('symm-clash')
+
+        for (let j = 0, jl = symmClashes.length; j < jl; ++j) {
+            const sca = symmClashes[j].attributes
+            const id = parseInt(getItem(sca, 'scid'))
+            const magnitude = parseFloat(getItem(sca, 'clashmag'))
+            const distance = parseFloat(getItem(sca, 'dist'))
+            const label_atom_id = getItem(sca, 'atom')
+            const element = index.findAtomOnResidue(rI, label_atom_id, label_alt_id)
+            if (element !== -1) {
+                clashesBuilder.add(element, id, magnitude, distance, true)
             }
         }
 
@@ -527,7 +542,6 @@ function parseValidationReportXml(xml: XMLDocument, model: Model): ValidationRep
         bondOutliers, angleOutliers,
         clashes
     }
-    console.log(validationReport)
 
     return validationReport
 }

+ 8 - 4
src/mol-plugin-ui/state/snapshots.tsx

@@ -7,7 +7,7 @@
 import { PluginCommands } from '../../mol-plugin/command';
 import * as React from 'react';
 import { PluginUIComponent, PurePluginUIComponent } from '../base';
-import { shallowEqual } from '../../mol-util';
+import { shallowEqualObjects } from '../../mol-util';
 import { OrderedMap } from 'immutable';
 import { ParameterControls } from '../controls/parameters';
 import { ParamDefinition as PD} from '../../mol-util/param-definition';
@@ -81,7 +81,7 @@ class LocalStateSnapshots extends PluginUIComponent<
     }
 
     shouldComponentUpdate(nextProps: any, nextState: any) {
-        return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
+        return !shallowEqualObjects(this.props, nextProps) || !shallowEqualObjects(this.state, nextState);
     }
 
     render() {
@@ -169,7 +169,7 @@ export class RemoteStateSnapshots extends PluginUIComponent<
         options: PD.Group({
             description: PD.Text(),
             playOnLoad: PD.Boolean(false),
-            serverUrl: PD.Text(this.plugin.config.get(PluginConfig.PluginState.Server))
+            serverUrl: PD.Text(this.plugin.config.get(PluginConfig.State.CurrentServer))
         })
     };
 
@@ -177,7 +177,7 @@ export class RemoteStateSnapshots extends PluginUIComponent<
 
     ListOnlyParams = {
         options: PD.Group({
-            serverUrl: PD.Text(this.plugin.config.get(PluginConfig.PluginState.Server))
+            serverUrl: PD.Text(this.plugin.config.get(PluginConfig.State.CurrentServer))
         }, { isFlat: true })
     };
 
@@ -194,6 +194,8 @@ export class RemoteStateSnapshots extends PluginUIComponent<
     refresh = async () => {
         try {
             this.setState({ isBusy: true });
+            this.plugin.config.set(PluginConfig.State.CurrentServer, this.state.params.options.serverUrl);
+
             const json = (await this.plugin.runTask<RemoteEntry[]>(this.plugin.fetch({ url: this.serverUrl('list'), type: 'json'  }))) || [];
 
             json.sort((a, b) => {
@@ -219,6 +221,8 @@ export class RemoteStateSnapshots extends PluginUIComponent<
 
     upload = async () => {
         this.setState({ isBusy: true });
+        this.plugin.config.set(PluginConfig.State.CurrentServer, this.state.params.options.serverUrl);
+
         if (this.plugin.state.snapshots.state.entries.size === 0) {
             await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, {
                 name: this.state.params.name,

+ 2 - 2
src/mol-plugin/behavior/behavior.ts

@@ -11,7 +11,7 @@ import { PluginContext } from '../../mol-plugin/context';
 import { PluginCommand } from '../command';
 import { Observable } from 'rxjs';
 import { ParamDefinition } from '../../mol-util/param-definition';
-import { shallowEqual } from '../../mol-util';
+import { shallowEqualObjects } from '../../mol-util';
 
 export { PluginBehavior }
 
@@ -128,7 +128,7 @@ namespace PluginBehavior {
             this.subs = [];
         }
         update(params: P): boolean | Promise<boolean> {
-            if (shallowEqual(params, this.params)) return false;
+            if (shallowEqualObjects(params, this.params)) return false;
             this.params = params;
             return true;
         }

+ 3 - 3
src/mol-plugin/behavior/dynamic/custom-props/computed/accessible-surface-area.ts

@@ -76,14 +76,14 @@ function accessibleSurfaceAreaLabel(loci: Loci): string | undefined {
             })
         }
         if (seen.size === 0) return
-        const residueCount = `<small>(${seen.size} ${seen.size > 1 ? 'Residues Sum' : 'Residue'})</small>`
+        const residueCount = `<small>(${seen.size} ${seen.size > 1 ? 'Residues sum' : 'Residue'})</small>`
 
-        return `Accessible Surface Area ${residueCount}: ${cummulativeArea.toFixed(2)} \u212B<sup>3</sup>`;
+        return `Accessible Surface Area ${residueCount}: ${cummulativeArea.toFixed(2)} \u212B<sup>2</sup>`;
 
     } else if(loci.kind === 'structure-loci') {
         const accessibleSurfaceArea = AccessibleSurfaceAreaProvider.get(loci.structure).value
         if (!accessibleSurfaceArea) return;
 
-        return `Accessible Surface Area <small>(Whole Structure)</small>: ${arraySum(accessibleSurfaceArea.area).toFixed(2)} \u212B<sup>3</sup>`;
+        return `Accessible Surface Area <small>(Whole Structure)</small>: ${arraySum(accessibleSurfaceArea.area).toFixed(2)} \u212B<sup>2</sup>`;
     }
 }

+ 25 - 4
src/mol-plugin/behavior/dynamic/custom-props/rcsb/assembly-symmetry.ts

@@ -12,7 +12,7 @@ import { AssemblySymmetryClusterColorThemeProvider } from '../../../../../mol-mo
 import { PluginStateTransform, PluginStateObject } from '../../../../state/objects';
 import { Task } from '../../../../../mol-task';
 import { PluginContext } from '../../../../context';
-import { StateTransformer } from '../../../../../mol-state';
+import { StateTransformer, StateAction, StateObject } from '../../../../../mol-state';
 
 export const RCSBAssemblySymmetry = PluginBehavior.create<{ autoAttach: boolean }>({
     name: 'rcsb-assembly-symmetry-prop',
@@ -22,7 +22,7 @@ export const RCSBAssemblySymmetry = PluginBehavior.create<{ autoAttach: boolean
         private provider = AssemblySymmetryProvider
 
         register(): void {
-            this.ctx.state.dataState.actions.add(AssemblySymmetry3D)
+            this.ctx.state.dataState.actions.add(InitAssemblySymmetry3D)
             this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
             this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.add('rcsb-assembly-symmetry-cluster', AssemblySymmetryClusterColorThemeProvider)
         }
@@ -35,7 +35,7 @@ export const RCSBAssemblySymmetry = PluginBehavior.create<{ autoAttach: boolean
         }
 
         unregister() {
-            this.ctx.state.dataState.actions.remove(AssemblySymmetry3D)
+            this.ctx.state.dataState.actions.remove(InitAssemblySymmetry3D)
             this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
             this.ctx.structureRepresentation.themeCtx.colorThemeRegistry.remove('rcsb-assembly-symmetry-cluster')
         }
@@ -46,13 +46,28 @@ export const RCSBAssemblySymmetry = PluginBehavior.create<{ autoAttach: boolean
     })
 });
 
+const InitAssemblySymmetry3D = StateAction.build({
+    display: { name: 'RCSB Assembly Symmetry' },
+    from: PluginStateObject.Molecule.Structure,
+    isApplicable: (a) => AssemblySymmetry.isApplicable(a.data)
+})(({ a, ref, state }, plugin: PluginContext) => Task.create('Init RCSB Assembly Symmetry', async ctx => {
+    try {
+        await AssemblySymmetryProvider.attach({ runtime: ctx, fetch: plugin.fetch }, a.data)
+    } catch(e) {
+        plugin.log.error(`RCSB Assembly Symmetry: ${e}`)
+        return
+    }
+    const tree = state.build().to(ref).apply(AssemblySymmetry3D);
+    await state.updateTree(tree).runInContext(ctx);
+}));
+
 type AssemblySymmetry3D = typeof AssemblySymmetry3D
 const AssemblySymmetry3D = PluginStateTransform.BuiltIn({
     name: 'rcsb-assembly-symmetry-3d',
     display: 'RCSB Assembly Symmetry',
     from: PluginStateObject.Molecule.Structure,
     to: PluginStateObject.Shape.Representation3D,
-    params: (a, ctx: PluginContext) => {
+    params: (a) => {
         return {
             ...AssemblySymmetryParams,
             symmetryIndex: getSymmetrySelectParam(a?.data),
@@ -66,6 +81,9 @@ const AssemblySymmetry3D = PluginStateTransform.BuiltIn({
         return Task.create('RCSB Assembly Symmetry', async ctx => {
             await AssemblySymmetryProvider.attach({ runtime: ctx, fetch: plugin.fetch }, a.data)
             const assemblySymmetry = AssemblySymmetryProvider.get(a.data).value
+            if (!assemblySymmetry || assemblySymmetry.length === 0) {
+                return StateObject.Null;
+            }
             const repr = AssemblySymmetryRepresentation({ webgl: plugin.canvas3d?.webgl, ...plugin.structureRepresentation.themeCtx }, () => AssemblySymmetryParams)
             await repr.createOrUpdate(params, a.data).runInContext(ctx);
             const { type, kind, symbol } = assemblySymmetry![params.symmetryIndex]
@@ -76,6 +94,9 @@ const AssemblySymmetry3D = PluginStateTransform.BuiltIn({
         return Task.create('RCSB Assembly Symmetry', async ctx => {
             await AssemblySymmetryProvider.attach({ runtime: ctx, fetch: plugin.fetch }, a.data)
             const assemblySymmetry = AssemblySymmetryProvider.get(a.data).value
+            if (!assemblySymmetry || assemblySymmetry.length === 0) {
+                return StateTransformer.UpdateResult.Recreate
+            }
             const props = { ...b.data.repr.props, ...newParams }
             await b.data.repr.createOrUpdate(props, a.data).runInContext(ctx);
             const { type, kind, symbol } = assemblySymmetry![newParams.symmetryIndex]

+ 3 - 3
src/mol-plugin/behavior/dynamic/custom-props/rcsb/validation-report.ts

@@ -191,12 +191,12 @@ function densityFitLabel(loci: Loci): string | undefined {
         const summary: string[] = []
 
         if (rsrzSeen.size) {
-            const rsrzCount = `<small>(${rsrzSeen.size} ${rsrzSeen.size > 1 ? 'Residues Avg.' : 'Residue'})</small>`
+            const rsrzCount = `<small>(${rsrzSeen.size} ${rsrzSeen.size > 1 ? 'Residues avg.' : 'Residue'})</small>`
             const rsrzAvg = rsrzSum / rsrzSeen.size
             summary.push(`Real Space R ${rsrzCount}: ${rsrzAvg.toFixed(2)}`)
         }
         if (rsccSeen.size) {
-            const rsccCount = `<small>(${rsccSeen.size} ${rsccSeen.size > 1 ? 'Residues Avg.' : 'Residue'})</small>`
+            const rsccCount = `<small>(${rsccSeen.size} ${rsccSeen.size > 1 ? 'Residues avg.' : 'Residue'})</small>`
             const rsccAvg = rsccSum / rsccSeen.size
             summary.push(`Real Space Correlation Coefficient ${rsccCount}: ${rsccAvg.toFixed(2)}`)
         }
@@ -239,7 +239,7 @@ function randomCoilIndexLabel(loci: Loci): string | undefined {
 
         if (seen.size === 0) return
 
-        const residueCount = `<small>(${seen.size} ${seen.size > 1 ? 'Residues Avg.' : 'Residue'})</small>`
+        const residueCount = `<small>(${seen.size} ${seen.size > 1 ? 'Residues avg.' : 'Residue'})</small>`
         const rciAvg = sum / seen.size
 
         return `Random Coil Index ${residueCount}: ${rciAvg.toFixed(2)}`

+ 5 - 1
src/mol-plugin/behavior/static/state.ts

@@ -14,6 +14,8 @@ import { getFormattedTime } from '../../../mol-util/date';
 import { readFromFile } from '../../../mol-util/data-source';
 import { download } from '../../../mol-util/download';
 import { Structure } from '../../../mol-model/structure';
+import { urlCombine } from '../../../mol-util/url';
+import { PluginConfig } from '../../config';
 
 export function registerDefault(ctx: PluginContext) {
     SyncBehaviors(ctx);
@@ -129,6 +131,8 @@ export function ClearHighlight(ctx: PluginContext) {
 }
 
 export function Snapshots(ctx: PluginContext) {
+    ctx.config.set(PluginConfig.State.CurrentServer, ctx.config.get(PluginConfig.State.DefaultServer));
+
     PluginCommands.State.Snapshots.Clear.subscribe(ctx, () => {
         ctx.state.snapshots.clear();
     });
@@ -157,7 +161,7 @@ export function Snapshots(ctx: PluginContext) {
     });
 
     PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, playOnLoad, serverUrl }) => {
-        return fetch(`${serverUrl}/set?name=${encodeURIComponent(name || '')}&description=${encodeURIComponent(description || '')}`, {
+        return fetch(urlCombine(serverUrl, `set?name=${encodeURIComponent(name || '')}&description=${encodeURIComponent(description || '')}`), {
             method: 'POST',
             mode: 'cors',
             referrer: 'no-referrer',

+ 4 - 1
src/mol-plugin/config.ts

@@ -14,7 +14,10 @@ function item<T>(key: string, defaultValue?: T) { return new PluginConfigItem(ke
 
 export const PluginConfig = {
     item,
-    PluginState: { Server: item('plugin-state.server', 'https://webchem.ncbr.muni.cz/molstar-state') }
+    State: { 
+        DefaultServer: item('plugin-state.server', 'https://webchem.ncbr.muni.cz/molstar-state'),
+        CurrentServer: item('plugin-state.server', 'https://webchem.ncbr.muni.cz/molstar-state')
+    }
 }
 
 export class PluginConfigManager {    

+ 1 - 1
src/mol-plugin/index.ts

@@ -89,7 +89,7 @@ export const DefaultPluginSpec: PluginSpec = {
         AnimateStateInterpolation
     ],
     config: new Map([
-        [PluginConfig.PluginState.Server, 'https://webchem.ncbr.muni.cz/molstar-state']
+        [PluginConfig.State.DefaultServer, 'https://webchem.ncbr.muni.cz/molstar-state']
     ])
 }
 

+ 2 - 2
src/mol-plugin/state/transforms/misc.ts

@@ -5,7 +5,7 @@
  */
 
 import { StateTransformer } from '../../../mol-state';
-import { shallowEqual } from '../../../mol-util';
+import { shallowEqualObjects } from '../../../mol-util';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { PluginStateObject as SO, PluginStateTransform } from '../objects';
 
@@ -25,7 +25,7 @@ const CreateGroup = PluginStateTransform.BuiltIn({
         return new SO.Group({}, params);
     },
     update({ oldParams, newParams, b }) {
-        if (shallowEqual(oldParams, newParams)) return StateTransformer.UpdateResult.Unchanged;
+        if (shallowEqualObjects(oldParams, newParams)) return StateTransformer.UpdateResult.Unchanged;
         b.label = newParams.label;
         b.description = newParams.description;
         return StateTransformer.UpdateResult.Updated;

+ 5 - 4
src/mol-plugin/util/loci-label-manager.ts

@@ -8,6 +8,7 @@
 import { PluginContext } from '../../mol-plugin/context';
 import { Loci } from '../../mol-model/loci';
 import { Representation } from '../../mol-repr/representation';
+import { MarkerAction } from '../../mol-util/marker-action';
 
 export type LociLabelEntry = JSX.Element | string
 export type LociLabelProvider = (info: Loci, repr?: Representation<any>) => LociLabelEntry | undefined
@@ -24,9 +25,9 @@ export class LociLabelManager {
         // Event.Interactivity.Highlight.dispatch(this.ctx, []);
     }
 
-    private empty: any[] = [];
-    private getInfo({ loci, repr }: Representation.Loci) {
-        if (!loci || loci.kind === 'empty-loci') return this.empty;
+    private empty: LociLabelEntry[] = [];
+    private getInfo({ loci, repr }: Representation.Loci, action: MarkerAction) {
+        if (Loci.isEmpty(loci) || action !== MarkerAction.Highlight) return this.empty;
         const info: LociLabelEntry[] = [];
         for (let p of this.providers) {
             const e = p(loci, repr);
@@ -36,6 +37,6 @@ export class LociLabelManager {
     }
 
     constructor(public ctx: PluginContext) {
-        ctx.interactivity.lociHighlights.addProvider((loci) => ctx.behaviors.labels.highlight.next({ entries: this.getInfo(loci) }))
+        ctx.interactivity.lociHighlights.addProvider((loci, action) => ctx.behaviors.labels.highlight.next({ entries: this.getInfo(loci, action) }))
     }
 }

+ 25 - 8
src/mol-util/index.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -79,19 +79,36 @@ export function deepEqual(a: any, b: any) {
     return false
 }
 
-export function shallowEqual<T>(a: T, b: T) {
-    if (!a) {
-        if (!b) return true;
-        return false;
+export function shallowEqual(a: any, b: any) {
+    if (a === b) return true;
+    const arrA = Array.isArray(a)
+    const arrB = Array.isArray(b)
+    if (arrA && arrB) return shallowEqualArrays(a, b)
+    if (arrA !== arrB) return false
+    if (a && b && typeof a === 'object' && typeof b === 'object') {
+        return shallowEqualObjects(a, b)
     }
-    if (!b) return false;
+    return false
+}
 
-    let keys = Object.keys(a);
+export function shallowEqualObjects(a: {}, b: {}) {
+    if (a === b) return true;
+    if (!a || !b) return false;
+    const keys = Object.keys(a);
     if (Object.keys(b).length !== keys.length) return false;
-    for (let k of keys) {
+    for (const k of keys) {
         if (!hasOwnProperty.call(a, k) || (a as any)[k] !== (b as any)[k]) return false;
     }
+    return true;
+}
 
+export default function shallowEqualArrays(a: any[], b: any[]) {
+    if (a === b) return true;
+    if (!a || !b) return false;
+    if (a.length !== b.length) return false;
+    for (let i = 0, il = a.length; i < il; ++i) {
+        if (a[i] !== b[i]) return false;
+    }
     return true;
 }
 

+ 2 - 2
src/mol-util/param-definition.ts

@@ -6,7 +6,7 @@
  */
 
 import { Color as ColorData } from './color';
-import { shallowEqual } from './index';
+import { shallowEqualObjects } from './index';
 import { Vec2 as Vec2Data, Vec3 as Vec3Data } from '../mol-math/linear-algebra';
 import { deepClone } from './object';
 import { Script as ScriptData } from '../mol-script/script';
@@ -356,7 +356,7 @@ export namespace ParamDefinition {
             }
             return true;
         } else if (typeof a === 'object' && typeof b === 'object') {
-            return shallowEqual(a, b);
+            return shallowEqualObjects(a, b);
         }
 
         // a === b was checked at the top.

+ 134 - 0
src/servers/plugin-state/api-schema.ts

@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import VERSION from './version'
+import { Config } from './config';
+
+export function getSchema(config: Config) {
+    function mapPath(path: string) {
+        return `${config.api_prefix}/${path}`;
+    }
+
+    return {
+        openapi: '3.0.0',
+        info: {
+            version: VERSION,
+            title: 'PluginState Server',
+            description: 'The PluginState Server is a simple service for storing and retreiving states of the Mol* Viewer app.',
+        },
+        tags: [
+            {
+                name: 'General',
+            }
+        ],
+        paths: {
+            [mapPath(`list/`)]: {
+                get: {
+                    tags: ['General'],
+                    summary: 'Returns a JSON response with the list of currently stored states.',
+                    operationId: 'list',
+                    parameters: [],
+                    responses: {
+                        200: {
+                            description: 'A list of stored states',
+                            content: {
+                                'application/json': { }
+                            }
+                        }
+                    },
+                }
+            },
+            [mapPath(`get/{id}`)]: {
+                get: {
+                    tags: ['General'],
+                    summary: 'Returns the Mol* Viewer state with the given id.',
+                    operationId: 'get',
+                    parameters: [
+                        {
+                            name: 'id',
+                            in: 'path',
+                            description: `Id of the state.`,
+                            required: true,
+                            schema: { type: 'string' },
+                            style: 'simple'
+                        }
+                    ],
+                    responses: {
+                        200: {
+                            description: 'A JSON object with the state.',
+                            content: {
+                                'application/json': { }
+                            }
+                        }
+                    },
+                }
+            },
+            [mapPath(`remove/{id}`)]: {
+                get: {
+                    tags: ['General'],
+                    summary: 'Removes the Mol* Viewer state with the given id.',
+                    operationId: 'remove',
+                    parameters: [
+                        {
+                            name: 'id',
+                            in: 'path',
+                            description: `Id of the state.`,
+                            required: true,
+                            schema: { type: 'string' },
+                            style: 'simple'
+                        }
+                    ],
+                    responses: {
+                        200: {
+                            description: 'Empty response.',
+                            content: { 'text/plain': { } }
+                        }
+                    },
+                }
+            },
+            [mapPath(`set/`)]: {
+                post: {
+                    tags: ['General'],
+                    summary: `Post Mol* Viewer state to the server. At most ${config.max_states} states can be stored. If the limit is reached, older states will be removed.`,
+                    operationId: 'set',
+                    requestBody: {
+                        content: {
+                            'application/json': {
+                                schema: { type: 'object' }
+                            }
+                        }
+                    },
+                    parameters: [
+                        {
+                            name: 'name',
+                            in: 'query',
+                            description: `Name of the state. If none provided, current UTC date-time is used.`,
+                            required: false,
+                            schema: { type: 'string' },
+                            style: 'simple'
+                        },
+                        {
+                            name: 'description',
+                            in: 'query',
+                            description: `Description of the state.`,
+                            required: false,
+                            schema: { type: 'string' },
+                            style: 'simple'
+                        }
+                    ],
+                    responses: {
+                        200: {
+                            description: 'Empty response.',
+                            content: { 'text/plain': { } }
+                        }
+                    },
+                }
+            },
+        }
+    }
+}
+
+export const shortcutIconLink = `<link rel='shortcut icon' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAnUExURQAAAMIrHrspHr0oH7soILonHrwqH7onILsoHrsoH7soH7woILwpIKgVokoAAAAMdFJOUwAQHzNxWmBHS5XO6jdtAmoAAACZSURBVDjLxZNRCsQgDAVNXmwb9f7nXZEaLRgXloXOhwQdjMYYwpOLw55fBT46KhbOKhmRR2zLcFJQj8UR+HxFgArIF5BKJbEncC6NDEdI5SatBRSDJwGAoiFDONrEJXWYhGMIcRJGCrb1TOtDahfUuQXd10jkFYq0ViIrbUpNcVT6redeC1+b9tH2WLR93Sx2VCzkv/7NjfABxjQHksGB7lAAAAAASUVORK5CYII=' />`

+ 28 - 0
src/servers/plugin-state/config.ts

@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as argparse from 'argparse'
+
+export interface Config {
+    working_folder: string,
+    port?: string | number,
+    api_prefix: string,
+    max_states: number
+}
+
+export function getConfig() {
+    const cmdParser = new argparse.ArgumentParser({
+        addHelp: true
+    });
+    cmdParser.addArgument(['--working-folder'], { help: 'Working forlder path.', required: true });
+    cmdParser.addArgument(['--port'], { help: 'Server port. Altenatively use ENV variable PORT.', type: 'int', required: false });
+    cmdParser.addArgument(['--api-prefix'], { help: 'Server API prefix.', defaultValue: '', required: false });
+    cmdParser.addArgument(['--max-states'], { help: 'Maxinum number of states that could be saved.', defaultValue: 40, type: 'int', required: false });
+    
+    const config = cmdParser.parseArgs() as Config;    
+    if (!config.port) config.port = process.env.port || 1339;
+    return config;
+}

+ 49 - 68
src/servers/plugin-state/index.ts

@@ -8,29 +8,15 @@ import * as express from 'express'
 import * as compression from 'compression'
 import * as cors from 'cors'
 import * as bodyParser from 'body-parser'
-import * as argparse from 'argparse'
 import * as fs from 'fs'
 import * as path from 'path'
+import { swaggerUiIndexHandler, swaggerUiAssetsHandler } from '../common/swagger-ui';
 import { makeDir } from '../../mol-util/make-dir'
+import { getConfig } from './config'
+import { UUID } from '../../mol-util'
+import { shortcutIconLink, getSchema } from './api-schema'
 
-interface Config {
-    working_folder: string,
-    port?: string | number,
-    app_prefix: string,
-    max_states: number
-}
-
-const cmdParser = new argparse.ArgumentParser({
-    addHelp: true
-});
-cmdParser.addArgument(['--working-folder'], { help: 'Working forlder path.', required: true });
-cmdParser.addArgument(['--port'], { help: 'Server port. Altenatively use ENV variable PORT.', type: 'int', required: false });
-cmdParser.addArgument(['--app-prefix'], { help: 'Server app prefix.', defaultValue: '', required: false });
-cmdParser.addArgument(['--max-states'], { help: 'Maxinum number of states that could be saved.', defaultValue: 40, type: 'int', required: false });
-
-const Config = cmdParser.parseArgs() as Config;
-
-if (!Config.port) Config.port = process.env.port || 1339;
+const Config = getConfig();
 
 const app = express();
 app.use(compression(<any>{ level: 6, memLevel: 9, chunkSize: 16 * 16384, filter: () => true }));
@@ -70,7 +56,7 @@ function validateIndex(index: Index) {
                 newIndex.push(e);
             }
         }
-        // index.slice(0, index.length - 30);
+
         for (const d of deletes) {
             try {
                 fs.unlinkSync(path.join(Config.working_folder, d.id + '.json'))
@@ -113,19 +99,9 @@ function clear() {
     writeIndex([]);
 }
 
-export function createv4() {
-    let d = (+new Date());
-    const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
-        const r = (d + Math.random()*16)%16 | 0;
-        d = Math.floor(d/16);
-        return (c==='x' ? r : (r&0x3|0x8)).toString(16);
-    });
-    return uuid.toLowerCase();
-}
-
 function mapPath(path: string) {
-    if (!Config.app_prefix) return path;
-    return `/${Config.app_prefix}/${path}`;
+    if (!Config.api_prefix) return path;
+    return `/${Config.api_prefix}/${path}`;
 }
 
 app.get(mapPath(`/get/:id`), (req, res) => {
@@ -164,30 +140,30 @@ app.get(mapPath(`/remove/:id`), (req, res) => {
     res.end();
 });
 
-app.get(mapPath(`/latest`), (req, res) => {
-    const index = readIndex();
-    const id: string = index.length > 0 ? index[index.length - 1].id : '';
-    console.log('Reading', id);
-    if (id.length === 0 || id.indexOf('.') >= 0 || id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) {
-        res.status(404);
-        res.end();
-        return;
-    }
-
-    fs.readFile(path.join(Config.working_folder, id + '.json'), 'utf-8', (err, data) => {
-        if (err) {
-            res.status(404);
-            res.end();
-            return;
-        }
-
-        res.writeHead(200, {
-            'Content-Type': 'application/json; charset=utf-8',
-        });
-        res.write(data);
-        res.end();
-    });
-});
+// app.get(mapPath(`/latest`), (req, res) => {
+//     const index = readIndex();
+//     const id: string = index.length > 0 ? index[index.length - 1].id : '';
+//     console.log('Reading', id);
+//     if (id.length === 0 || id.indexOf('.') >= 0 || id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) {
+//         res.status(404);
+//         res.end();
+//         return;
+//     }
+
+//     fs.readFile(path.join(Config.working_folder, id + '.json'), 'utf-8', (err, data) => {
+//         if (err) {
+//             res.status(404);
+//             res.end();
+//             return;
+//         }
+
+//         res.writeHead(200, {
+//             'Content-Type': 'application/json; charset=utf-8',
+//         });
+//         res.write(data);
+//         res.end();
+//     });
+// });
 
 app.get(mapPath(`/list`), (req, res) => {
     const index = readIndex();
@@ -206,7 +182,7 @@ app.post(mapPath(`/set`), (req, res) => {
     const name = (req.query.name as string || new Date().toUTCString()).substr(0, 50);
     const description = (req.query.description as string || '').substr(0, 100);
 
-    index.push({ timestamp: +new Date(), id: createv4(), name, description });
+    index.push({ timestamp: +new Date(), id: UUID.createv4(), name, description });
     const entry = index[index.length - 1];
 
     const data = JSON.stringify({
@@ -220,19 +196,24 @@ app.post(mapPath(`/set`), (req, res) => {
     writeIndex(index);
 });
 
-app.get(`*`, (req, res) => {
+const schema = getSchema(Config);
+app.get(mapPath('/openapi.json'), (req, res) => {
     res.writeHead(200, {
-        'Content-Type': 'text/plain; charset=utf-8'
+        'Content-Type': 'application/json; charset=utf-8',
+        'Access-Control-Allow-Origin': '*',
+        'Access-Control-Allow-Headers': 'X-Requested-With'
     });
-    res.write(`
-GET /list
-GET /get/:id
-GET /remove/:id
-GET /latest
-POST /set?name=...&description=... [JSON data]
-`);
-    res.end();
-})
+    res.end(JSON.stringify(schema));
+});
+
+app.use(mapPath('/'), swaggerUiAssetsHandler());
+app.get(mapPath('/'), swaggerUiIndexHandler({
+    openapiJsonUrl: mapPath('/openapi.json'),
+    apiPrefix: Config.api_prefix,
+    title: 'PluginState Server API',
+    shortcutIconLink
+}));
+
 
 createIndex();
 app.listen(Config.port);

+ 1 - 0
src/servers/plugin-state/version.ts

@@ -0,0 +1 @@
+export default '0.1.0'