From 9dc593af2bd58e9a28543a0309a2a04b8c450463 Mon Sep 17 00:00:00 2001
From: Emmanuel Raviart <emmanuel@raviart.com>
Date: Wed, 11 Aug 2021 23:32:27 +0200
Subject: [PATCH] Add link sharing.

---
 example.env                                   |  3 +
 package-lock.json                             | 13 +++
 package.json                                  |  1 +
 src/lib/auditors/config.ts                    |  1 +
 src/lib/components/CopyClipboard.svelte       | 14 +++
 src/lib/components/ShareLinkModal.svelte      | 51 ++++++++++
 .../components/test_cases/TestCaseView.svelte |  2 +-
 src/lib/server/config.ts                      |  2 +
 src/lib/situations.ts                         | 57 +++++++++++
 src/routes/__layout.svelte                    | 72 +++++---------
 src/routes/index.svelte                       | 66 +++++++++----
 src/routes/simulations/[simulation].json.ts   | 92 ++++++++++++++++++
 src/routes/simulations/[simulation].svelte    | 41 ++++++++
 src/routes/simulations/index.json.ts          | 95 +++++++++++++++++++
 svelte.config.js                              |  8 ++
 15 files changed, 451 insertions(+), 67 deletions(-)
 create mode 100644 src/lib/components/CopyClipboard.svelte
 create mode 100644 src/lib/components/ShareLinkModal.svelte
 create mode 100644 src/routes/simulations/[simulation].json.ts
 create mode 100644 src/routes/simulations/[simulation].svelte
 create mode 100644 src/routes/simulations/index.json.ts

diff --git a/example.env b/example.env
index c1dd4a048..ed965d086 100644
--- a/example.env
+++ b/example.env
@@ -57,6 +57,9 @@ PORTAL_URL="https://leximpact.an.fr/"
 # Set to true when application is behind a proxy
 PROXY=false
 
+# Directory containing JSON of simulations saved by users
+SIMULATIONS_DIR="../simulations"
+
 TITLE="Simulateur socio-fiscal"
 
 # Path to file containing description of waterfalls in JSON format
diff --git a/package-lock.json b/package-lock.json
index 306a36092..1de8d15c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,6 +43,7 @@
         "svelte-check": "^2.0.0",
         "svelte-dnd-action": "^0.9.8",
         "svelte-json-tree": "github:eraviart/svelte-json-tree",
+        "svelte-modals": "^1.0.4",
         "svelte-preprocess": "^4.6.9",
         "tailwindcss": "^2.0.3",
         "tslib": "^2.0.0",
@@ -6303,6 +6304,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/svelte-modals": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.0.4.tgz",
+      "integrity": "sha512-nwYEF7PlxX4uqKU1zRCi+fcECxlEOEFGNGcazdyGsFK0LMngrxwqq3q77xBabGNU7DXYxGyDXq/9YHzyHyCAFg==",
+      "dev": true
+    },
     "node_modules/svelte-preprocess": {
       "version": "4.7.4",
       "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.7.4.tgz",
@@ -11496,6 +11503,12 @@
       "dev": true,
       "from": "svelte-json-tree@github:eraviart/svelte-json-tree"
     },
+    "svelte-modals": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.0.4.tgz",
+      "integrity": "sha512-nwYEF7PlxX4uqKU1zRCi+fcECxlEOEFGNGcazdyGsFK0LMngrxwqq3q77xBabGNU7DXYxGyDXq/9YHzyHyCAFg==",
+      "dev": true
+    },
     "svelte-preprocess": {
       "version": "4.7.4",
       "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.7.4.tgz",
diff --git a/package.json b/package.json
index b1bb72974..bdf3a071f 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
     "svelte-check": "^2.0.0",
     "svelte-dnd-action": "^0.9.8",
     "svelte-json-tree": "github:eraviart/svelte-json-tree",
+    "svelte-modals": "^1.0.4",
     "svelte-preprocess": "^4.6.9",
     "tailwindcss": "^2.0.3",
     "tslib": "^2.0.0",
diff --git a/src/lib/auditors/config.ts b/src/lib/auditors/config.ts
index 90c8ed974..122f80aee 100644
--- a/src/lib/auditors/config.ts
+++ b/src/lib/auditors/config.ts
@@ -70,6 +70,7 @@ export function auditConfig(
     "decompositionsPath",
     "familyEntityKey",
     "jsonDir",
+    "simulationsDir",
     "title",
     "waterfallsPath",
   ]) {
diff --git a/src/lib/components/CopyClipboard.svelte b/src/lib/components/CopyClipboard.svelte
new file mode 100644
index 000000000..01341090a
--- /dev/null
+++ b/src/lib/components/CopyClipboard.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+  import { onMount } from "svelte"
+
+  export let value
+
+  let textarea
+
+  onMount(() => {
+    textarea.select()
+    document.execCommand("copy")
+  })
+</script>
+
+<textarea bind:value bind:this={textarea} />
diff --git a/src/lib/components/ShareLinkModal.svelte b/src/lib/components/ShareLinkModal.svelte
new file mode 100644
index 000000000..7b376518c
--- /dev/null
+++ b/src/lib/components/ShareLinkModal.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+  import { session } from "$app/stores"
+
+  import { closeModal } from "svelte-modals"
+
+  import CopyClipboard from "$lib/components/CopyClipboard.svelte"
+
+  export let isOpen: boolean // provided by Modals
+  export let token: string
+
+  let clipboardElement: HTMLElement
+
+  $: url = new URL(`/simulations/${token}`, $session.baseUrl).toString()
+
+  function copyLink() {
+    const copyClipboard = new CopyClipboard({
+      target: clipboardElement,
+      props: { value: url },
+    })
+    copyClipboard.$destroy()
+  }
+</script>
+
+{#if isOpen}
+  <div
+    role="dialog"
+    class="bottom-0 fixed flex items-center justify-center left-0 pointer-events-none right-0 top-0"
+  >
+    <div
+      class="bg-white flex flex-col justify-between max-w-3xl min-w-[240px] p-4 pointer-events-auto rounded"
+    >
+      <h2 class="text-2xl text-center">Sauvegarder / partager</h2>
+      <section class="text-base text-center">
+        <p>Votre simulation est enregistrée.</p>
+        <p>Vous pouvez y accéder à tout moment à cette adresse :</p>
+        <code class="text-xs">{url}</code>
+        <div bind:this={clipboardElement} />
+        <button
+          class="bg-le-bleu p-2 rounded shadow-md text-sm text-white uppercase hover:bg-blue-900"
+          on:click={copyLink}>Copier le lien</button
+        >
+        <p>
+          Ce lien est public, vous pouvez le partager sur les réseaux sociaux.
+        </p>
+      </section>
+      <div class="flex justify-end mt-8">
+        <button on:click={closeModal}>OK</button>
+      </div>
+    </div>
+  </div>
+{/if}
diff --git a/src/lib/components/test_cases/TestCaseView.svelte b/src/lib/components/test_cases/TestCaseView.svelte
index 3780271fb..874ee6df4 100644
--- a/src/lib/components/test_cases/TestCaseView.svelte
+++ b/src/lib/components/test_cases/TestCaseView.svelte
@@ -441,7 +441,7 @@
     </div>
   </div>
   <div>
-    {#if open}
+    {#if open && variableSummaryByName !== undefined}
       <div class="flex justify-between bg-gray-100 text-gray-500 ">
         {#each waterfalls as { advanced: advancedOnly, icon, label, name }}
           {#if !advancedOnly || advanced}
diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts
index cbc0a482f..5261d1211 100644
--- a/src/lib/server/config.ts
+++ b/src/lib/server/config.ts
@@ -36,6 +36,7 @@ export interface Config {
   }
   portalUrl: string
   proxy: boolean
+  simulationsDir: string
   title: string
   waterfallsPath: string
 }
@@ -78,6 +79,7 @@ const [validConfig, error] = validateConfig({
   },
   portalUrl: process.env["PORTAL_URL"],
   proxy: process.env["PROXY"],
+  simulationsDir: process.env["SIMULATIONS_DIR"],
   waterfallsPath: process.env["WATERFALLS_PATH"],
   title: process.env["TITLE"],
 })
diff --git a/src/lib/situations.ts b/src/lib/situations.ts
index cc3a29d4f..135dfbb4e 100644
--- a/src/lib/situations.ts
+++ b/src/lib/situations.ts
@@ -38,6 +38,63 @@ export type SituationWithAxes = Situation & {
   axes?: Axis[][]
 }
 
+export function buildTestCasesWithoutNonInputVariables(
+  entityByKey: EntityByKey,
+  inputInstantsByVariableNameArray: Array<{
+    [name: string]: Set<string>
+  }>,
+  testCases: Situation[],
+): Situation[] {
+  const entities = Object.values(entityByKey)
+  const situations = [...testCases]
+  for (const [situationIndex, situation] of situations.entries()) {
+    const inputInstantsByVariableName =
+      inputInstantsByVariableNameArray[situationIndex]
+    for (const entity of entities) {
+      let entitySituation = situation[entity.key_plural]
+      if (entitySituation === undefined) {
+        continue
+      }
+      entitySituation = situation[entity.key_plural] = { ...entitySituation }
+      const reservedKeys = getPopulationReservedKeys(entity)
+      for (let [populationId, population] of Object.entries(
+        entitySituation,
+      ).sort(([populationId1], [populationId2]) =>
+        populationId1.localeCompare(populationId2),
+      )) {
+        population = entitySituation[populationId] = { ...population }
+        for (const [variableName, variableValueByInstant] of Object.entries(
+          population,
+        )) {
+          if (reservedKeys.has(variableName)) {
+            continue
+          }
+          const inputVariableValueByInstant: {
+            [instant: string]: boolean | number | string | null
+          } = {}
+          const inputInstants =
+            inputInstantsByVariableName[variableName] ?? new Set<string>()
+          for (const [instant, variableValue] of Object.entries(
+            variableValueByInstant,
+          )) {
+            if (!inputInstants.has(instant)) {
+              // Remove calculated value.
+              continue
+            }
+            inputVariableValueByInstant[instant] = variableValue
+          }
+          if (Object.keys(inputVariableValueByInstant).length > 0) {
+            population[variableName] = inputVariableValueByInstant
+          } else {
+            delete population[variableName]
+          }
+        }
+      }
+    }
+  }
+  return testCases
+}
+
 export function getPopulationReservedKeys(entity: Entity): Set<string> {
   return new Set(
     entity.is_person
diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte
index 5f81a663b..3462664e7 100644
--- a/src/routes/__layout.svelte
+++ b/src/routes/__layout.svelte
@@ -6,6 +6,7 @@
   import type { Writable } from "svelte/store"
   import { writable } from "svelte/store"
   import Sockette from "sockette"
+  import { Modals, closeModal } from "svelte-modals"
 
   import { browser } from "$app/env"
   import { page, session } from "$app/stores"
@@ -23,7 +24,10 @@
   } from "$lib/decompositions"
   import type { Reform } from "$lib/reforms"
   import type { PopulationWithoutId, Situation } from "$lib/situations"
-  import { getPopulationReservedKeys } from "$lib/situations"
+  import {
+    buildTestCasesWithoutNonInputVariables,
+    getPopulationReservedKeys,
+  } from "$lib/situations"
   import type { VariableValues } from "$lib/variables"
   import type { WebSocketByName, WebSocketOpenByName } from "$lib/websockets"
 
@@ -205,54 +209,16 @@
 
   $: if (browser) {
     // Remove non input variables from test cases.
-    const entities = Object.values($session.entityByKey as EntityByKey)
-    const situations = [...$testCases]
-    for (const [situationIndex, situation] of situations.entries()) {
-      const inputInstantsByVariableName =
-        $inputInstantsByVariableNameArray[situationIndex]
-      for (const entity of entities) {
-        let entitySituation = situation[entity.key_plural]
-        if (entitySituation === undefined) {
-          continue
-        }
-        entitySituation = situation[entity.key_plural] = { ...entitySituation }
-        const reservedKeys = getPopulationReservedKeys(entity)
-        for (let [populationId, population] of Object.entries(
-          entitySituation,
-        ).sort(([populationId1], [populationId2]) =>
-          populationId1.localeCompare(populationId2),
-        )) {
-          population = entitySituation[populationId] = { ...population }
-          for (const [variableName, variableValueByInstant] of Object.entries(
-            population,
-          )) {
-            if (reservedKeys.has(variableName)) {
-              continue
-            }
-            const inputVariableValueByInstant: {
-              [instant: string]: boolean | number | string | null
-            } = {}
-            const inputInstants =
-              inputInstantsByVariableName[variableName] ?? new Set<string>()
-            for (const [instant, variableValue] of Object.entries(
-              variableValueByInstant,
-            )) {
-              if (!inputInstants.has(instant)) {
-                // Remove calculated value.
-                continue
-              }
-              inputVariableValueByInstant[instant] = variableValue
-            }
-            if (Object.keys(inputVariableValueByInstant).length > 0) {
-              population[variableName] = inputVariableValueByInstant
-            } else {
-              delete population[variableName]
-            }
-          }
-        }
-      }
-    }
-    localStorage.setItem("testCases", JSON.stringify(situations))
+    localStorage.setItem(
+      "testCases",
+      JSON.stringify(
+        buildTestCasesWithoutNonInputVariables(
+          $session.entityByKey,
+          $inputInstantsByVariableNameArray,
+          $testCases,
+        ),
+      ),
+    )
   }
 
   $: if (browser && matomoConfig !== undefined && $page) {
@@ -580,3 +546,11 @@
 <div class="mt-[5.5rem]">
   <slot />
 </div>
+
+<Modals>
+  <div
+    class="bg-black bg-opacity-50 bottom-0 fixed left-0 right-0 top-0"
+    on:click={closeModal}
+    slot="backdrop"
+  />
+</Modals>
diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index 0c95f8a5d..b7a03c476 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -11,6 +11,7 @@
   import type { EntityByKey, GroupEntity, Waterfall } from "@openfisca/ast"
   import { getRolePersonsIdKey } from "@openfisca/ast"
   import { getContext, setContext } from "svelte"
+  import { openModal } from "svelte-modals"
   import type { Writable } from "svelte/store"
   import { writable } from "svelte/store"
   import { v4 as uuidv4 } from "uuid"
@@ -19,6 +20,7 @@
   import { page, session } from "$app/stores"
   import type { ValidSimulationQuery } from "$lib/calculations"
   import { newSimulationUrl } from "$lib/calculations"
+  import ShareLinkModal from "$lib/components/ShareLinkModal.svelte"
   import TestCaseEdit from "$lib/components/test_cases/TestCaseEdit.svelte"
   import TestCasesPane from "$lib/components/test_cases/TestCasesPane.svelte"
   import VariableReferredInputsPane from "$lib/components/variables/VariableReferredInputsPane.svelte"
@@ -42,7 +44,10 @@
     Situation,
     SituationWithAxes,
   } from "$lib/situations"
-  import { getPopulationReservedKeys } from "$lib/situations"
+  import {
+    buildTestCasesWithoutNonInputVariables,
+    getPopulationReservedKeys,
+  } from "$lib/situations"
   import type { SelfTargetAProps } from "$lib/urls"
   import type { WebSocketByName, WebSocketOpenByName } from "$lib/websockets"
 
@@ -403,6 +408,34 @@
     )
   }
 
+  async function shareLink(): Promise<string | null> {
+    const url = "/simulations.json"
+    const res = await fetch(url, {
+      body: JSON.stringify({
+        reform: $reform,
+        testCases: buildTestCasesWithoutNonInputVariables(
+          $session.entityByKey,
+          $inputInstantsByVariableNameArray,
+          $testCases,
+        ),
+      }),
+      headers: { "Content-Type": "application/json; charset=utf-8" },
+      method: "POST",
+    })
+    if (!res.ok) {
+      console.error(
+        `Error ${
+          res.status
+        } while creating a share link at ${url}\n\n${await res.text()}`,
+      )
+      return
+    }
+    const { token } = await res.json()
+    openModal(ShareLinkModal, {
+      token,
+    })
+  }
+
   function submit() {
     // Aggregate every situations into a single one without calculated variables.
     const aggregatedSituation: SituationWithAxes = {}
@@ -691,23 +724,22 @@
 
         <div class="flex">
           <button
-            class="bg-gray-200 text-gray-900 shadow-md hover:bg-gray-400 px-5 mr-2 rounded p-2 uppercase text-sm"
-            on:click={submit}
+            class="bg-gray-200 flex justify-center text-gray-900 shadow-md hover:bg-gray-400 px-5 mr-2 rounded p-2 uppercase text-sm"
+            on:click={shareLink}
           >
-            <div class="flex justify-center">
-              <!-- Material Icon Share --><svg
-                class="fill-current lg:mr-2"
-                xmlns="http://www.w3.org/2000/svg"
-                height="18px"
-                viewBox="0 0 24 24"
-                width="18px"
-                fill="#000000"
-                ><path d="M0 0h24v24H0z" fill="none" /><path
-                  d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"
-                /></svg
-              >
-              <span class="hidden lg:flex"> Sauvegarder / partager</span>
-            </div>
+            <!-- Material Icon Share -->
+            <svg
+              class="fill-current lg:mr-2"
+              xmlns="http://www.w3.org/2000/svg"
+              height="18px"
+              viewBox="0 0 24 24"
+              width="18px"
+              fill="#000000"
+              ><path d="M0 0h24v24H0z" fill="none" /><path
+                d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"
+              />
+            </svg>
+            <span class="hidden lg:flex"> Sauvegarder / partager</span>
           </button>
 
           <div class="justify-self-end">
diff --git a/src/routes/simulations/[simulation].json.ts b/src/routes/simulations/[simulation].json.ts
new file mode 100644
index 000000000..702fb5fb6
--- /dev/null
+++ b/src/routes/simulations/[simulation].json.ts
@@ -0,0 +1,92 @@
+import type { Audit } from "@auditors/core"
+import {
+  auditRequire,
+  auditTest,
+  auditTrimString,
+  cleanAudit,
+} from "@auditors/core"
+import type { JsonValue } from "@openfisca/ast"
+import type { RequestHandler } from "@sveltejs/kit"
+import { createHash } from "crypto"
+import fs from "fs-extra"
+import path from "path"
+import sanitizeFilename from "sanitize-filename"
+
+import { walkDecompositionsCoreName } from "$lib/decompositions"
+import type { Reform } from "$lib/reforms"
+import config from "$lib/server/config"
+import { decompositionCoreByName, waterfalls } from "$lib/server/decompositions"
+import { iterVariableInputVariables } from "$lib/server/variables"
+import type { Situation } from "$lib/situations"
+
+const { simulationsDir } = config
+
+function auditParams(audit: Audit, dataUnknown: unknown): [unknown, unknown] {
+  if (dataUnknown == null) {
+    return [dataUnknown, null]
+  }
+  if (typeof dataUnknown !== "object") {
+    return audit.unexpectedType(dataUnknown, "object")
+  }
+
+  const data = { ...dataUnknown }
+  const errors: { [key: string]: unknown } = {}
+  const remainingKeys = new Set(Object.keys(data))
+
+  audit.attribute(
+    data,
+    "simulation",
+    true,
+    errors,
+    remainingKeys,
+    auditTrimString,
+    auditTest(
+      (value) => /^[0-9a-f]{64}$/.test(value),
+      "Invalid simulation token",
+    ),
+    auditRequire,
+  )
+
+  return audit.reduceRemaining(data, errors, remainingKeys)
+}
+
+export const get: RequestHandler = async ({
+  params: requestParams,
+  path: requestPath,
+}) => {
+  const [params, paramsError] = auditParams(cleanAudit, requestParams)
+  if (paramsError !== null) {
+    console.error(
+      `Error in ${requestPath} params:\n${JSON.stringify(
+        params,
+        null,
+        2,
+      )}\n\nError:\n${JSON.stringify(paramsError, null, 2)}`,
+    )
+    return {
+      status: 400,
+      body: {
+        error: {
+          code: 400,
+          details: paramsError as JsonValue,
+          message: "Invalid parameters",
+          path: requestPath,
+        },
+        params: params as JsonValue,
+      },
+    }
+  }
+  const { simulation: digest } = params as { simulation: string }
+
+  const simulationFilePath = path.join(
+    simulationsDir,
+    digest.substring(0, 2),
+    `${digest}.json`,
+  )
+  if (!(await fs.pathExists(simulationFilePath))) {
+    return { body: null, status: 404 }
+  }
+  return {
+    body: await fs.readJson(simulationFilePath),
+  }
+}
diff --git a/src/routes/simulations/[simulation].svelte b/src/routes/simulations/[simulation].svelte
new file mode 100644
index 000000000..525c19c87
--- /dev/null
+++ b/src/routes/simulations/[simulation].svelte
@@ -0,0 +1,41 @@
+<script context="module" lang="ts">
+  import type { LoadInput, LoadOutput } from "@sveltejs/kit/types/page"
+
+  export async function load({ fetch, page }: LoadInput): Promise<LoadOutput> {
+    const { simulation: token } = page.params
+    const url = `/simulations/${token}.json`
+    const res = await fetch(url)
+    if (!res.ok) {
+      return {
+        status: res.status,
+        error: new Error(`Could not load ${url}`),
+      }
+    }
+    const simulation = await res.json()
+    return {
+      props: {
+        simulation,
+      },
+    }
+  }
+</script>
+
+<script lang="ts">
+  import { getContext, onMount } from "svelte"
+  import type { Writable } from "svelte/store"
+
+  import { goto } from "$app/navigation"
+  import type { Reform } from "$lib/reforms"
+  import type { Situation } from "$lib/situations"
+
+  export let simulation: { reform: Reform; testCases: Situation[] }
+
+  const reform = getContext("reform") as Writable<Reform>
+  const testCases = getContext("testCases") as Writable<Situation[]>
+
+  onMount(() => {
+    $reform = simulation.reform
+    $testCases = simulation.testCases
+    goto("/")
+  })
+</script>
diff --git a/src/routes/simulations/index.json.ts b/src/routes/simulations/index.json.ts
new file mode 100644
index 000000000..854f28f65
--- /dev/null
+++ b/src/routes/simulations/index.json.ts
@@ -0,0 +1,95 @@
+import type { Audit } from "@auditors/core"
+import { auditCleanArray, auditRequire, cleanAudit } from "@auditors/core"
+import type { JsonValue } from "@openfisca/ast"
+import type { RequestHandler } from "@sveltejs/kit"
+import { createHash } from "crypto"
+import fs from "fs-extra"
+import path from "path"
+import sanitizeFilename from "sanitize-filename"
+
+import { walkDecompositionsCoreName } from "$lib/decompositions"
+import type { Reform } from "$lib/reforms"
+import config from "$lib/server/config"
+import { decompositionCoreByName, waterfalls } from "$lib/server/decompositions"
+import { iterVariableInputVariables } from "$lib/server/variables"
+import type { Situation } from "$lib/situations"
+
+const { simulationsDir } = config
+
+function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] {
+  if (dataUnknown == null) {
+    return [dataUnknown, null]
+  }
+  if (typeof dataUnknown !== "object") {
+    return audit.unexpectedType(dataUnknown, "object")
+  }
+
+  const data = { ...dataUnknown }
+  const errors: { [key: string]: unknown } = {}
+  const remainingKeys = new Set(Object.keys(data))
+
+  audit.attribute(
+    data,
+    "reform",
+    true,
+    errors,
+    remainingKeys,
+    // TODO
+    auditRequire,
+  )
+  audit.attribute(
+    data,
+    "testCases",
+    true,
+    errors,
+    remainingKeys,
+    auditCleanArray(),
+    // TODO
+    auditRequire,
+  )
+
+  return audit.reduceRemaining(data, errors, remainingKeys)
+}
+
+export const post: RequestHandler = async ({
+  body: requestBody,
+  path: requestPath,
+}) => {
+  const [body, bodyError] = auditBody(cleanAudit, requestBody)
+  if (bodyError !== null) {
+    console.error(
+      `Error in ${requestPath} body:\n${JSON.stringify(
+        body,
+        null,
+        2,
+      )}\n\nError:\n${JSON.stringify(bodyError, null, 2)}`,
+    )
+    return {
+      status: 400,
+      body: {
+        error: {
+          code: 400,
+          details: bodyError as JsonValue,
+          message: "Invalid body",
+          path: requestPath,
+        },
+        body: body as JsonValue,
+      },
+    }
+  }
+  const bodyJson = JSON.stringify(body, null, 2)
+  const hash = createHash("sha256")
+  hash.update(bodyJson)
+  const digest = hash.digest("hex")
+
+  const simulationDir = path.join(simulationsDir, digest.substring(0, 2))
+  const simulationFilePath = path.join(simulationDir, `${digest}.json`)
+  if (!(await fs.pathExists(simulationFilePath))) {
+    await fs.ensureDir(simulationDir)
+    await fs.writeFile(simulationFilePath, bodyJson)
+  }
+
+  return {
+    body: { token: digest },
+  }
+}
diff --git a/svelte.config.js b/svelte.config.js
index a5a2d5b65..070e68dff 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -13,6 +13,14 @@ const config = {
 
     // hydrate the <div id="svelte"> element in src/app.html
     target: "#svelte",
+
+    vite: {
+      optimizeDeps: {
+        // See https://svelte-modals.mattjennings.io/
+        // and https://github.com/sveltejs/vite-plugin-svelte/issues/124.
+        exclude: ["svelte-modals"],
+      },
+    },
   },
 
   // Consult https://github.com/sveltejs/svelte-preprocess
-- 
GitLab