From d7ebb30e05da107025d3e77f246f09ded77da686 Mon Sep 17 00:00:00 2001
From: David Sehnal <david.sehnal@gmail.com>
Date: Sat, 8 Feb 2020 16:26:29 +0100
Subject: [PATCH] servers/plugin-state

---
 README.md                             |   1 +
 package-lock.json                     | Bin 764447 -> 765664 bytes
 package.json                          |   3 +
 src/servers/model/server/api-local.ts |  15 +-
 src/servers/plugin-state/index.ts     | 242 ++++++++++++++++++++++++++
 5 files changed, 247 insertions(+), 14 deletions(-)
 create mode 100644 src/servers/plugin-state/index.ts

diff --git a/README.md b/README.md
index d5dd8c109..91d48e5e5 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ Moreover, the project contains the imlementation of `servers`, including
 
 - `servers/model` A tool for accessing coordinate and annotation data of molecular structures.
 - `servers/volume` A tool for accessing volumetric experimental data related to molecular structures.
+- `servers/plugin-state` A basic server to store Mol* Plugin states.
 
 The project also contains performance tests (`perf-tests`), `examples`, and basic proof of concept `apps` (CIF to BinaryCIF converter and JSON domain annotation to CIF converter).
 
diff --git a/package-lock.json b/package-lock.json
index 3cc465afa6de76dc269c4ad9dab41dd3bfd21d0c..9c313aac8c467562d01a37da6bac6b63406507e1 100644
GIT binary patch
delta 711
zcmbP#Meo5~y@nRXElj)SF(>C2P2R{X$!w%&F<o&6tI2eQLMC?p<ou#yeO-MJr3;dA
zoW-gdnweMTpPOGCVc=4jRN$zc>}ph*@8KSr<EEdKQ<-L-=VajT9_i$i>0xA^T$CJ^
zYwDNlmKc@o6JQdOXqe%im|C2i?VM?v6Xugsnd0Z=T#+5(A3S~G1}2B;XBRWEPY;^Q
zq`H0AJf;UPraMevR@%P1ojHyb!nEpTR!N<_iCJQL@KsiG4CfjCWK|9I_Vh^d@hU7e
zHx6<y($C0CD)P51bxO^w@N+LM3l7XG&C$<FaVrbf_jU{R^f1f_4@)u3OE<6b_o&d0
zEOJfr%=2+KFEq(BbN2|eOm-=$DANxxoBnYLlj?K>Zw~h9f97)VOuNS{${07@@ferX
z_NY(H|9PiRn93o!wUA{8Bbc?_k%`68e0qZ;i$wcOOP1|3Em_}5O&5q`HQ7Edgq4X2
z%$)u~kd<}%o-o$@=?<~17TevzSwk2hGCP`>d8hA-U=4<FYnnMYrW-`Erh>WK*F>`J
z=7uoqQdv0|!P=)+zv8r+em9NvAB3x5!w5E<cREJ~YZqAc^pzQ`IbhlC2AQl2!TM~r
zf5>80<ABKP7PI~b%TK?V!fLR+yo9xZV|qa?tJ!veT2>!sh}7+a%+t5?HM07F^;=AT
zkjTzDeZovej_LblICQ7yA7W;me!qfMV7gB#7w7i#O{|P85Yv4fIk=|RYcNYsU$B8u
zXj(q2^K^j_R_^Hn-x(#^)7x3w)7#nF)7#nG)7v?=r?+!DM@>&qVv?MGAcaF{`g~88
brtM*mIH&7RS2)Nl(as{owVg$XTY3fnnxpYV

delta 543
zcmaEGS8x6my@nRXElj)SZRcFT^x(zx02vOkZJo@StYFsmhCXKf)a{?YF#qNS35aiJ
zV`kaO2w`4lVR14CF~!;qY*@A%*s#8rnr<+iLt=YKI4dL5^atlzWTy8;vgS`N5R%?*
z8pRsIIQ>B}i^TMO(X8<hmUJ1b<n)B+%$(Efb(sXG2Sl@4LL^SsvGPs7`-z!zx?TdS
z(DZ~#)|~17yqt>DC(LGLnci@gNnrYgRMwp7AMP<4ZjXv%4d$9I@Rn0@`{N8&Hpb}+
z?>WP#$7Zqq1KB)X!G@6)<PP5HY?&Ma(|<`Y%1-B7$E7^IVIeEabm=x0f$0W`tZLIg
za59NcPtIY@0qc_QWHsAvo6DNbINhO_QFgM88rSwWd8~3A5OsFttp6eEGFT0^FRfs0
z082=0k7{J~WuA7JU3R+OVdkmRd+S+wr~4;yaZW$b&LuF-l9_*cLKv&Z^a)+8TGJIe
zS^1_nBy;gkU!TD(Gd*E8hZV>vCesT}F>6h4P~^~_e&8@O>vY~)R)OifSzMgc&v`Qn
zO)qd@5(24<nx5Lo#XtSuEJlItAKO_|m?2K%apT~ceom8FdisJ5j9`Uo(;GUNmD|;N
wSliWl*xJ>4*xS{5IJT?xa5_hUBXE1eGfrKD=?bkJ;_XMpxV9e^<CdNQ00%<1`~Uy|

diff --git a/package.json b/package.json
index f6d8ef285..9f4a69ce9 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "model-server": "node lib/servers/model/server.js",
     "model-server-watch": "nodemon --watch lib lib/servers/model/server.js",
     "volume-server": "node lib/servers/volume/server.js --idMap em 'test/${id}.mdb' --defaultPort 1336",
+    "plugin-state": "node lib/servers/plugin-state/index.js",
     "preversion": "npm run test",
     "postversion": "git push && git push --tags",
     "prepublishOnly": "npm run test && npm run build"
@@ -70,6 +71,7 @@
     "@graphql-codegen/typescript-graphql-files-modules": "^1.11.2",
     "@graphql-codegen/typescript-graphql-request": "^1.11.2",
     "@graphql-codegen/typescript-operations": "^1.11.2",
+    "@types/cors": "^2.8.6",
     "@typescript-eslint/eslint-plugin": "^2.17.0",
     "@typescript-eslint/eslint-plugin-tslint": "^2.17.0",
     "@typescript-eslint/parser": "^2.17.0",
@@ -112,6 +114,7 @@
     "argparse": "^1.0.10",
     "body-parser": "^1.19.0",
     "compression": "^1.7.4",
+    "cors": "^2.8.5",
     "express": "^4.17.1",
     "graphql": "^14.5.8",
     "immutable": "^3.8.2",
diff --git a/src/servers/model/server/api-local.ts b/src/servers/model/server/api-local.ts
index 5b27b9859..31aca674e 100644
--- a/src/servers/model/server/api-local.ts
+++ b/src/servers/model/server/api-local.ts
@@ -13,6 +13,7 @@ import { StructureCache } from './structure-wrapper';
 import { now } from '../../../mol-util/now';
 import { PerformanceMonitor } from '../../../mol-util/performance-monitor';
 import { QueryName } from './api';
+import { makeDir } from '../../../mol-util/make-dir';
 
 export type LocalInput = {
     input: string,
@@ -103,18 +104,4 @@ export function wrapFileToWriter(fn: string) {
     };
 
     return w;
-}
-
-function makeDir(path: string, root?: string): boolean {
-    let dirs = path.split(/\/|\\/g),
-        dir = dirs.shift();
-
-    root = (root || '') + dir + '/';
-
-    try { fs.mkdirSync(root); }
-    catch (e) {
-        if (!fs.statSync(root).isDirectory()) throw new Error(e);
-    }
-
-    return !dirs.length || makeDir(dirs.join('/'), root);
 }
\ No newline at end of file
diff --git a/src/servers/plugin-state/index.ts b/src/servers/plugin-state/index.ts
new file mode 100644
index 000000000..0c37c4959
--- /dev/null
+++ b/src/servers/plugin-state/index.ts
@@ -0,0 +1,242 @@
+/**
+ * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+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 { makeDir } from '../../mol-util/make-dir'
+
+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 app = express();
+app.use(compression(<any>{ level: 6, memLevel: 9, chunkSize: 16 * 16384, filter: () => true }));
+app.use(cors({ methods: ['GET', 'PUT'] }));
+app.use(bodyParser.json({ limit: '20mb' }));
+
+type Index = { timestamp: number, id: string, name: string, description: string, isSticky?: boolean }[]
+
+function createIndex() {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (fs.existsSync(fn)) return;
+    if (!fs.existsSync(Config.working_folder)) makeDir(Config.working_folder);
+    fs.writeFileSync(fn, '[]', 'utf-8');
+}
+
+function writeIndex(index: Index) {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (!fs.existsSync(Config.working_folder)) makeDir(Config.working_folder);
+    fs.writeFileSync(fn, JSON.stringify(index, null, 2), 'utf-8');
+}
+
+function readIndex() {
+    const fn = path.join(Config.working_folder, 'index.json');
+    if (!fs.existsSync(fn)) return [];
+    return JSON.parse(fs.readFileSync(fn, 'utf-8')) as Index;
+}
+
+function validateIndex(index: Index) {
+    if (index.length > Config.max_states) {
+        const deletes: Index = [], newIndex: Index = [];
+        const toDelete = index.length - Config.max_states; 
+        
+        for (const e of index) {
+            if (!e.isSticky && deletes.length < toDelete) {
+                deletes.push(e);
+            } else {
+                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'))
+            } catch { }
+        }
+        return newIndex;
+    }
+    return index;
+}
+
+function remove(id: string) {
+    let index = readIndex();
+    let i = 0;
+    for (const e of index) {
+        if (e.id !== id) {            
+            i++;
+            continue;
+        }
+        try {
+            for (let j = i + 1; j < index.length; j++) {
+                index[j - 1] = index[j];
+            }
+            index.pop();
+            writeIndex(index);
+        } catch { }
+        try {
+            fs.unlinkSync(path.join(Config.working_folder, e.id + '.json'))
+        } catch { }
+        return;
+    }
+}
+
+function clear() {
+    let index = readIndex();
+    for (const e of index) {
+        try {
+            fs.unlinkSync(path.join(Config.working_folder, e.id + '.json'))
+        } catch { }
+    }
+    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}`;
+}
+
+app.get(mapPath(`/get/:id`), (req, res) => {
+    const id: string = req.params.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(`/clear`), (req, res) => {
+    clear();
+    res.status(200);
+    res.end();
+});
+
+app.get(mapPath(`/remove/:id`), (req, res) => {
+    remove((req.params.id as string || '').toLowerCase());
+    res.status(200);
+    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();
+    res.writeHead(200, {
+        'Content-Type': 'application/json; charset=utf-8',
+    });
+    res.write(JSON.stringify(index, null, 2));
+    res.end();
+});
+
+app.post(mapPath(`/set`), (req, res) => {
+    console.log('SET', req.query.name, req.query.description);
+    const index = readIndex();
+    validateIndex(index);
+
+    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 });
+    const entry = index[index.length - 1];
+
+    const data = JSON.stringify({
+        id: entry.id,
+        name,
+        description,
+        data: req.body
+    });
+
+    fs.writeFile(path.join(Config.working_folder, entry.id + '.json'), data, { encoding: 'utf8' }, () => res.end());
+    writeIndex(index);
+});
+
+app.get(`*`, (req, res) => {
+    res.writeHead(200, {
+        'Content-Type': 'text/plain; charset=utf-8'
+    });
+    res.write(`
+GET /list
+GET /get/:id
+GET /remove/:id
+GET /latest
+POST /set?name=...&description=... [JSON data]
+`);
+    res.end();
+})
+
+createIndex();
+app.listen(Config.port);
+
+console.log(`Mol* PluginState Server`);
+console.log('');
+console.log(JSON.stringify(Config, null, 2));
\ No newline at end of file
-- 
GitLab