From bee7ecd15402a8b9dfa7c80cbc822c0a08d32eaa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Laurent?= <francois.laurent@posteo.net>
Date: Mon, 24 Feb 2025 17:51:56 +0100
Subject: [PATCH] feat: session reset

---
 routes.jl                    |  2 +-
 src/GenieExtras.jl           |  8 +++--
 src/Storage.jl               | 23 ++++++++----
 src/apps/larvatagger/app.jl  | 39 +++++++++++++++-----
 src/apps/muscles/Backbone.jl | 22 ++++++++++--
 src/apps/muscles/app.jl      | 69 +++++++++++++++++++++++++++---------
 src/nyxui.css                | 11 ++++++
 7 files changed, 136 insertions(+), 38 deletions(-)

diff --git a/routes.jl b/routes.jl
index fdcc47d..bca171d 100755
--- a/routes.jl
+++ b/routes.jl
@@ -31,7 +31,7 @@ if Genie.Configuration.isprod()
 end
 
 function modelview(model, view)
-    page(model, NyxUI.add_footer(view); title=nyxui_title)
+    page(model, view; title=nyxui_title)
 end
 
 Stipple.Layout.add_css(nyxui_css)
diff --git a/src/GenieExtras.jl b/src/GenieExtras.jl
index 3dc561c..40cf48a 100644
--- a/src/GenieExtras.jl
+++ b/src/GenieExtras.jl
@@ -2,7 +2,7 @@ module GenieExtras
 
 import Genie.Renderer.Html
 
-export publish_css, add_footer
+export publish_css, appinfo, add_footer
 
 const project_root = dirname(Base.current_project())
 
@@ -30,7 +30,7 @@ function publish_css(; clear=true)
     return css_dir
 end
 
-function footer()
+function appinfo()
     version = string(pkgversion(@__MODULE__))
     if endswith(version, ".0")
         version = version[1:end-2]
@@ -40,9 +40,11 @@ function footer()
         version = join((version, readchomp(versionfile)), "-")
     end
     tagurl = "https://gitlab.pasteur.fr/nyx/NyxUI/-/tags"
-    Html.footer("NyxUI.jl <a href=\"$tagurl\">v$version</a>")
+    return "NyxUI.jl <a href=\"$tagurl\">v$version</a>"
 end
 
+footer() = Html.footer(appinfo())
+
 add_footer(ui::AbstractString, footer=footer) = "$ui$(footer())"
 
 add_footer(ui::Function, footer=footer) = () -> add_footer(ui(), footer)
diff --git a/src/Storage.jl b/src/Storage.jl
index 24c879e..d037cbb 100644
--- a/src/Storage.jl
+++ b/src/Storage.jl
@@ -3,7 +3,7 @@ module Storage
 using Dates
 using TOML
 
-export getconfig, getbucket, clear_oldest
+export getconfig, getbucket, clear_oldest, purge_appdata
 
 const __storage_location__ = get(ENV, "STORAGE",
                                  joinpath(dirname(Base.current_project()), "storage"))
@@ -32,10 +32,12 @@ function getconfig(key, morekeys...; default=nothing)
     config isa Dict ? default : config
 end
 
-getbucket(session_id) = joinpath(__storage_location__, "exports", session_id)
+function getbucket(session_id, appname)
+    joinpath(__storage_location__, "exports", session_id, appname)
+end
 
-function getbucket(session_id, mode)
-    session_bucket = getbucket(session_id)
+function getbucket(session_id, appname, mode)
+    session_bucket = getbucket(session_id, appname)
     if mode === :read
         session_bucket
     elseif mode === :write
@@ -50,8 +52,10 @@ end
 
 function getbucket(; child_resource)
     parts = splitpath(child_resource)
-    session_id = parts[findfirst(==("exports"), parts) + 1]
-    return getbucket(session_id)
+    exports = findfirst(==("exports"), parts)
+    session_id = parts[exports + 1]
+    appname = parts[exports + 2]
+    return getbucket(session_id, appname)
 end
 
 function clear_oldest(session_bucket)
@@ -61,4 +65,11 @@ function clear_oldest(session_bucket)
     rm(oldest; recursive=true)
 end
 
+function purge_appdata(session_id, appname)
+    bucket = getbucket(session_id, appname, :read)
+    isdir(bucket) && rm(bucket; recursive=true)
+    exportdir = joinpath("public", session_id, appname)
+    isdir(exportdir) && rm(exportdir; recursive=true)
+end
+
 end
diff --git a/src/apps/larvatagger/app.jl b/src/apps/larvatagger/app.jl
index 4dddc29..dbdea62 100644
--- a/src/apps/larvatagger/app.jl
+++ b/src/apps/larvatagger/app.jl
@@ -4,7 +4,8 @@ using NyxUI, NyxUI.Storage, NyxUI.GenieExtras
 using NyxWidgets.Base: Cache
 import LarvaTagger as LT
 using GenieSession, Stipple
-import Stipple: @app, @init, @private, @in, @onchange
+import Stipple: @app, @init, @private, @in, @onchange, @onbutton, @click
+import StippleUI: tooltip
 
 const sizefactors = Dict("1.0"=>1., "1.5"=>1.5, "2.0"=>2.)
 const sizefactors_str = Dict(v=>k for (k,v) in pairs(sizefactors))
@@ -19,9 +20,9 @@ end
 
 const bonito_models = Cache{String, Model}()
 
-const bonito_app = NamedApp(:inherit,
+const bonito_app = NamedApp("larvatagger",
     function (session)
-        bucket = joinpath(getbucket(session, :read), "larvatagger")
+        bucket = getbucket(session, "larvatagger", :read)
         mkpath(bucket)
         model = lock(bonito_models) do
             if haskey(bonito_models, session)
@@ -31,7 +32,7 @@ const bonito_app = NamedApp(:inherit,
                 bonito_models.cache[session] = Model(1.0, nothing, inputfile)
             end
         end
-        exportdir = joinpath("public", session)
+        exportdir = joinpath("public", session, "larvatagger")
         function prepare_download(srcfile)
             mkpath(exportdir)
             filename = basename(srcfile)
@@ -43,7 +44,7 @@ const bonito_app = NamedApp(:inherit,
                     end
                 end
             end
-            return "/$session/$filename"
+            return "/$session/larvatagger/$filename"
         end
         isnothing(model.app) || close(model.app)
         model.app = app = LT.larvaeditor(model.appdata;
@@ -58,9 +59,20 @@ const bonito_app = NamedApp(:inherit,
     end
 )
 
+function purgesession(session)
+    model = lock(bonito_models) do
+        pop!(bonito_models.cache, session)
+    end
+    isnothing(model.app) || close(model.app)
+    purge_appdata(session, "larvatagger")
+    BonitoServer.addsession(bonito_app, session; restart=true)
+end
+
+
 @app begin
     @private bonito_session_id = ""
     @in sizefactor = "1.0"
+    @in reset_bonito_session = false
 
     @onchange isready begin
         genie_session = GenieSession.session()
@@ -98,6 +110,15 @@ const bonito_app = NamedApp(:inherit,
             """)
         end
     end
+
+    @onbutton reset_bonito_session begin
+        purgesession(bonito_session_id)
+        # reset UI to defaults
+        bonito_session_id[!] = ""
+        sizefactor[!] = "1.0"
+        # reload page
+        run(__model__, "location.reload(true);")
+    end
 end
 
 function view()
@@ -113,14 +134,16 @@ function view()
                             multiple=false, clearable=false, counter=false, usechips=false,
                             dense=true, var"options-dense"=true)];
                 style="display:flex;flex-direction:horizontal;align-items:center;"),
-            Html.span(footer())];
+            Html.span("Reset session", @click(:reset_bonito_session),
+                      style="cursor:pointer;",
+                      [tooltip("Useful when the app crashes. All data will be lost!")]),
+            Html.span(appinfo())];
             id="footer",
-            style="display:flex;flex-direction:horizontal;justify-content:space-between;align-items:center;height:40px;max-width:100%;padding:0 0.5rem;",
         ),
     ]
 end
 
-function footer()
+function appinfo()
     version = string(pkgversion(LT))
     if endswith(version, ".0")
         version = version[1:end-2]
diff --git a/src/apps/muscles/Backbone.jl b/src/apps/muscles/Backbone.jl
index eb4758a..5547f55 100644
--- a/src/apps/muscles/Backbone.jl
+++ b/src/apps/muscles/Backbone.jl
@@ -9,7 +9,7 @@ include("MuscleWidgets.jl")
 using .MuscleWidgets
 
 export hasmodel, getmodel, setmodel, newsequence, getsequence, withsequences,
-       exportsequence, loadsequence, deletesequence
+       exportsequence, loadsequence, deletesequence, purgesession
 
 # mutable struct Backbone{W}
 #     widget::Union{Nothing, W}
@@ -132,10 +132,10 @@ end
 
 ## persistent storage
 
-function exportsequence(session_id)
+function exportsequence(session_id; appname="muscles")
     sequence = getsequence(session_id)
     isempty(sequence.program_name) && return ""
-    dir = getbucket(session_id, :write)
+    dir = getbucket(session_id, appname, :write)
     filepath = joinpath(dir, sequence.program_name * ".json")
     MuscleActivities.to_json_file(filepath, sequence)
     return filepath
@@ -166,4 +166,20 @@ function deletesequence(session_id)
     end
 end
 
+function purgesession(session_id; appname="muscles")
+   model = lock(__backbone_cache__) do
+        if session_id in keys(__backbone_cache__.cache)
+            #pop!(__valid_sessions__, session_id) # not sure what to do with it
+            pop!(__backbone_cache__.cache, session_id)
+        end
+    end
+    lock(__sequences__) do
+        if session_id in keys(__sequences__.cache)
+            pop!(__sequences__.cache, session_id)
+        end
+    end
+    purge_appdata(session_id, appname)
+    return model
+end
+
 end
diff --git a/src/apps/muscles/app.jl b/src/apps/muscles/app.jl
index 30eb0d0..6140bd2 100644
--- a/src/apps/muscles/app.jl
+++ b/src/apps/muscles/app.jl
@@ -1,6 +1,6 @@
 module MuscleApp
 
-using NyxUI, NyxUI.Storage
+using NyxUI, NyxUI.Storage, NyxUI.GenieExtras
 using Observables
 using Genie, GenieSession, Stipple, StippleUI
 import Stipple: @app, @init, @private, @in, @out, @onchange, @onbutton, @notify
@@ -21,7 +21,7 @@ const maxlength = getconfig("muscle-activity", "maxlength")
 const bonito_app = NamedApp(:inherit, Backbone.app)
 
 @app begin
-    @private back_session_id = ""
+    @private bonito_session_id = ""
 
     @private sequence = MuscleActivity("")
     @in new_sequence_click = false
@@ -51,6 +51,8 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     @in delete_sequence_click = false
     @out no_sequences_yet = true
 
+    @in reset_bonito_session = false
+
     @onchange isready begin
         _, url = init_model(__model__)
         run(__model__, """
@@ -60,7 +62,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
         loader.style.display = 'block';
         iframe.src = '$url';
         """)
-        session_id = back_session_id
+        session_id = bonito_session_id
         model = getmodel(session_id)
         if true # TODO: diagnose missing Stipple initialization
             # restore state after page reload (Ctrl+R)
@@ -92,8 +94,33 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
         end
     end
 
+    @onbutton reset_bonito_session begin
+        model = purgesession(bonito_session_id; appname=bonito_app.name)
+        # reset UI to defaults
+        #bonito_session_id[!] = ""
+        sequence[!] = MuscleActivity("")
+        new_sequence_type[!] = "Empty"
+        make_new_sequences_random[!] = false
+        sequence_names[!] = String[]
+        selected_sequence_name[!] = ""
+        selected_sequence_index[!] = 0
+        sequence_name[!] = ""
+        export_sequence_link[!] = ""
+        colormap[!] = default_colormap
+        first_time_in_editmode[!] = true
+        editmode[!] = false
+        series_length[!] = 21
+        time_interval[!] = 0.05
+        start_time[!] = 0.0
+        heatmap_view[!] = false
+        no_sequences_yet[!] = true
+        # reload page
+        setmodel(bonito_session_id, sequence)
+        run(__model__, "location.reload(true);")
+    end
+
     @onchange start_time, time_interval, series_length begin
-        model = getmodel(back_session_id)
+        model = getmodel(bonito_session_id)
         seq = model.sequence[]
         # input validation
         valid = true
@@ -138,7 +165,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     end
 
     @onbutton new_sequence_click begin
-        sequence = newsequence(back_session_id, new_sequence_type, sequence_names,
+        sequence = newsequence(bonito_session_id, new_sequence_type, sequence_names,
                                start_time, time_interval, round(Int, series_length))
         # propagate
         name = sequence.program_name
@@ -167,7 +194,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
 
     @onchange selected_sequence_name begin
         isempty(selected_sequence_name) && return
-        seq = getsequence(back_session_id, selected_sequence_name)
+        seq = getsequence(bonito_session_id, selected_sequence_name)
         if !isnothing(seq)
             ix = findfirst(==(selected_sequence_name), sequence_names)
             selected_sequence_index[!] = ix
@@ -202,7 +229,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
                 break
             end
         end
-        model = getmodel(back_session_id)
+        model = getmodel(bonito_session_id)
         if valid
             model.sequence[].program_name = sequence[!].program_name = sequence_name
             sequence_names[!][selected_sequence_index] = sequence_name
@@ -214,7 +241,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     end
 
     @onchange colormap begin
-        model = getmodel(back_session_id)
+        model = getmodel(bonito_session_id)
         model.colormap[] = colormap
     end
 
@@ -224,7 +251,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
             editmode = false
             return
         end
-        model = getmodel(back_session_id)
+        model = getmodel(bonito_session_id)
         model.editmode[] = editmode
         editmode_disable = !editmode
         if editmode && first_time_in_editmode && heatmap_view
@@ -244,18 +271,19 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     end
 
     @onbutton export_sequence_click begin
-        filepath = exportsequence(back_session_id)
+        filepath = exportsequence(bonito_session_id; appname=bonito_app.name)
         if isempty(filepath)
             @notify "No available motor programs; create one first" :error
         else
             filename = basename(filepath)
-            mkpath("./public/$back_session_id")
-            open("./public/$back_session_id/$filename", "w") do o
+            appname = bonito_app.name
+            mkpath("./public/$bonito_session_id/$appname")
+            open("./public/$bonito_session_id/$appname/$filename", "w") do o
                 open(filepath, "r") do i
                     write(o, read(i))
                 end
             end
-            export_sequence_link = "/$back_session_id/$filename"
+            export_sequence_link = "/$bonito_session_id/$appname/$filename"
         end
     end
 
@@ -275,7 +303,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
         if !isempty(fileuploads)
             file = fileuploads["path"]
             try
-                sequence = loadsequence(back_session_id, file)
+                sequence = loadsequence(bonito_session_id, file)
                 if !(sequence.program_name in sequence_names)
                     push!(sequence_names, sequence.program_name)
                 end
@@ -302,7 +330,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
     @onbutton delete_sequence_click begin
         isempty(selected_sequence_name) && return
         # update the model
-        deletesequence(back_session_id)
+        deletesequence(bonito_session_id)
         popat!(sequence_names, selected_sequence_index)
         if length(sequence_names) < selected_sequence_index
             selected_sequence_index[!] -= 1
@@ -311,7 +339,7 @@ const bonito_app = NamedApp(:inherit, Backbone.app)
             n = round(Int, series_length)
             stop_time = start_time + (n-1) * time_interval
             time_support = start_time:time_interval:stop_time
-            model = getmodel(back_session_id)
+            model = getmodel(bonito_session_id)
             muscles = model.overview
             sequence = MuscleActivity("", time_support, muscles)
             # update the view
@@ -381,6 +409,12 @@ function muscle_view(bonito_url=""; channel=":channel")
                  disable=:no_sequences_yet)),
     ])
 
+    footer = Html.div([
+        Html.span("Reset session", @click(:reset_bonito_session), style="cursor:pointer;",
+                  [tooltip("Useful when the app crashes. All data will be lost!")]),
+        Html.span(appinfo()),
+    ]; id="footer", style="width:1354.617px;")
+
     Html.div(style=col, [
         topbar,
         Html.div(style=row, [
@@ -391,6 +425,7 @@ function muscle_view(bonito_url=""; channel=":channel")
             ]),
             sidepanel,
         ]),
+        footer,
     ])
 end
 
@@ -422,7 +457,7 @@ function init_model(muscle_model=nothing)
         setmodel(session_id, muscle_model.sequence[!])
     end
     BonitoServer.addsession(bonito_app, session_id)
-    muscle_model.back_session_id[!] = session_id
+    muscle_model.bonito_session_id[!] = session_id
     url = bonito_url(bonito_app, session_id)
     return muscle_model, url
 end
diff --git a/src/nyxui.css b/src/nyxui.css
index 12eb979..6535400 100644
--- a/src/nyxui.css
+++ b/src/nyxui.css
@@ -54,3 +54,14 @@ footer {
   text-align: right;
   padding-right: 1rem;
 }
+
+#footer {
+  display: flex;
+  flex-direction: horizontal;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  max-width: 100%;
+  padding: 0;
+  padding-left: 1rem;
+}
-- 
GitLab