From 5c125390d0197bd6ad39b30bedf9018570e12bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20R=C3=A9gis=20Gianas?= Date: Sat, 5 Mar 2022 18:44:14 +0100 Subject: [PATCH] feat: Offer better protections against solution overwriting (#372) * Mechanism 1: We disable the automatic and implicit saving of the student answer when the browser tab is closed. Instead, we ask the user to confirm that she wants to leave the page (unless the answer has already been synchronized). Related: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example * Mechanism 2: When an answer has not been modified for 3 minutes, we check (upon next keystroke) if a more recent solution exists on the server. In that case, we ask the user if she/he wants to download the most recent version. * Mechanism 3: To avoid overloading the server with many synchronization requests, we disable the synchronization button when the answer is synchronized, and reactive it only when a modification is made on the answer. Close #316 Fix #467 Co-authored-by: Yann Regis-Gianas Co-authored-by: Erik Martin-Dorel --- src/ace-lib/ace.ml | 34 +++++- src/ace-lib/ace.mli | 9 +- src/ace-lib/ocaml_mode.ml | 4 +- src/ace-lib/ocaml_mode.mli | 4 +- src/app/learnocaml_common.ml | 108 +++++++++++++++--- src/app/learnocaml_common.mli | 20 ++-- src/app/learnocaml_exercise_main.ml | 36 ++++-- src/app/learnocaml_index_main.ml | 2 +- src/app/learnocaml_playground_main.ml | 10 +- translations/fr.po | 153 +++++++++++++++----------- 10 files changed, 272 insertions(+), 108 deletions(-) diff --git a/src/ace-lib/ace.ml b/src/ace-lib/ace.ml index 8d2f8f0a6..2bbe01e06 100644 --- a/src/ace-lib/ace.ml +++ b/src/ace-lib/ace.ml @@ -1,6 +1,6 @@ (* This file is part of Learn-OCaml. * - * Copyright (C) 2019 OCaml Software Foundation. + * Copyright (C) 2019-2022 OCaml Software Foundation. * Copyright (C) 2016-2018 OCamlPro. * * Learn-OCaml is distributed under the terms of the MIT license. See the @@ -20,6 +20,8 @@ type 'a editor = { editor: ('a editor * 'a option) Ace_types.editor Js.t; mutable marks: int list; mutable keybinding_menu: bool; + mutable synchronized : bool; + mutable sync_observers : (bool -> unit) list; } let ace : Ace_types.ace Js.t = Js.Unsafe.variable "ace" @@ -30,10 +32,13 @@ let create_position r c = pos##.row := r; pos##.column := c; pos + let greater_position p1 p2 = p1##.row > p2##.row || (p1##.row = p2##.row && p1##.column > p2##.column) +let register_sync_observer editor obs = + editor.sync_observers <- obs :: editor.sync_observers let create_range s e = let range : range Js.t = Js.Unsafe.obj [||] in @@ -77,15 +82,37 @@ let get_contents ?range e = let r = create_range (create_position r1 c1) (create_position r2 c2) in Js.to_string @@ document##(getTextRange r) -let create_editor editor_div = +let set_synchronized_status editor status = + List.iter (fun obs -> obs status) editor.sync_observers; + editor.synchronized <- status + +let focus { editor } = editor##focus + +let create_editor editor_div check_valid_state = let editor = edit editor_div in Js.Unsafe.set editor "$blockScrolling" (Js.Unsafe.variable "Infinity"); let data = - { editor; editor_div; marks = []; keybinding_menu = false; } in + { editor; editor_div; + marks = []; + keybinding_menu = false; + synchronized = true; + sync_observers = [] + } + in editor##.customData := (data, None); editor##setOption (Js.string "displayIndentGuides") (Js.bool false); + editor##on (Js.string "change") (fun () -> + check_valid_state (set_contents data) (fun () -> focus data) + (fun () -> set_synchronized_status data true); + set_synchronized_status data false); data +let set_synchronized editor = + set_synchronized_status editor true + +let is_synchronized editor = + editor.synchronized + let get_custom_data { editor } = match snd editor##.customData with | None -> raise Not_found @@ -168,7 +195,6 @@ let clear_marks editor = let record_event_handler editor event handler = editor.editor##(on (Js.string event) handler) -let focus { editor } = editor##focus let resize { editor } force = editor##(resize (Js.bool force)) let get_keybinding_menu e = diff --git a/src/ace-lib/ace.mli b/src/ace-lib/ace.mli index 6bc3b423e..6b922b2c1 100644 --- a/src/ace-lib/ace.mli +++ b/src/ace-lib/ace.mli @@ -17,7 +17,14 @@ type loc = { loc_end: int * int; } -val create_editor: Dom_html.divElement Js.t -> 'a editor +val create_editor: Dom_html.divElement Js.t + -> ((string -> unit) -> (unit -> unit) -> (unit -> unit) -> unit) -> 'a editor + +val is_synchronized : 'a editor -> bool + +val set_synchronized : 'a editor -> unit + +val register_sync_observer : 'a editor -> (bool -> unit) -> unit val set_mode: 'a editor -> string -> unit diff --git a/src/ace-lib/ocaml_mode.ml b/src/ace-lib/ocaml_mode.ml index c5bd1fecb..46278c8a7 100644 --- a/src/ace-lib/ocaml_mode.ml +++ b/src/ace-lib/ocaml_mode.ml @@ -514,8 +514,8 @@ let do_delete ace_editor = Ace.remove ace_editor "left" end -let create_ocaml_editor div = - let ace = Ace.create_editor div in +let create_ocaml_editor div check_valid_state = + let ace = Ace.create_editor div check_valid_state in Ace.set_mode ace "ace/mode/ocaml.ocp"; Ace.set_tab_size ace !config.indent.IndentConfig.i_base; let editor = { ace; current_error = None; current_warnings = [] } in diff --git a/src/ace-lib/ocaml_mode.mli b/src/ace-lib/ocaml_mode.mli index d2d2f78f8..c86baae05 100644 --- a/src/ace-lib/ocaml_mode.mli +++ b/src/ace-lib/ocaml_mode.mli @@ -20,12 +20,12 @@ type msg = { msg: string; } - type error = msg list type warning = error -val create_ocaml_editor: Dom_html.divElement Js.t -> editor +val create_ocaml_editor: + Dom_html.divElement Js.t -> ((string -> unit) -> (unit -> unit) -> (unit -> unit) -> unit) -> editor val get_editor: editor -> editor Ace.editor val report_error: editor -> ?set_class: bool -> error option -> warning list -> unit Lwt.t diff --git a/src/app/learnocaml_common.ml b/src/app/learnocaml_common.ml index cc8b4fcca..a7ccd0eb5 100644 --- a/src/app/learnocaml_common.ml +++ b/src/app/learnocaml_common.ml @@ -1,6 +1,6 @@ (* This file is part of Learn-OCaml. * - * Copyright (C) 2019 OCaml Software Foundation. + * Copyright (C) 2019-2020 OCaml Software Foundation. * Copyright (C) 2016-2018 OCamlPro. * * Learn-OCaml is distributed under the terms of the MIT license. See the @@ -434,10 +434,13 @@ let get_state_as_save_file ?(include_reports = false) () = all_exercise_toplevel_histories = retrieve all_exercise_toplevel_histories; } -let rec sync_save token save_file = +let rec sync_save token save_file on_sync = Server_caller.request (Learnocaml_api.Update_save (token, save_file)) >>= function - | Ok save -> set_state_from_save_file ~token save; Lwt.return save + | Ok save -> + set_state_from_save_file ~token save; + on_sync (); + Lwt.return save | Error (`Not_found _) -> Server_caller.request_exn (Learnocaml_api.Create_token ("", Some token, None)) >>= fun _token -> @@ -445,19 +448,20 @@ let rec sync_save token save_file = Server_caller.request_exn (Learnocaml_api.Update_save (token, save_file)) >>= fun save -> set_state_from_save_file ~token save; + on_sync (); Lwt.return save | Error e -> lwt_alert ~title:[%i"SYNC FAILED"] [ H.p [H.txt [%i"Could not synchronise save with the server"]]; H.code [H.txt (Server_caller.string_of_error e)]; ] ~buttons:[ - [%i"Retry"], (fun () -> sync_save token save_file); - [%i"Ignore"], (fun () -> Lwt.return save_file); + [%i"Retry"], (fun () -> sync_save token save_file on_sync); + [%i"Ignore"], (fun () -> Lwt.return save_file); ] -let sync token = sync_save token (get_state_as_save_file ()) +let sync token on_sync = sync_save token (get_state_as_save_file ()) on_sync -let sync_exercise token ?answer ?editor id = +let sync_exercise token ?answer ?editor id on_sync = let handle_serverless () = (* save the text at least locally (but not the report & grade, that could be misleading) *) @@ -494,7 +498,7 @@ let sync_exercise token ?answer ?editor id = } in match token with | Some token -> - Lwt.catch (fun () -> sync_save token save_file) + Lwt.catch (fun () -> sync_save token save_file on_sync) (fun e -> handle_serverless (); raise e) @@ -708,11 +712,72 @@ let mouseover_toggle_signal elt sigvalue setter = in Manip.Ev.onmouseover elt hdl +(* + + If a user has made no change to a solution for the exercise [id] + for 180 seconds, [check_valid_editor_state id] ensures that there is + no more recent version of this solution in the server. If this is + the case, the user is asked if we should download this solution + from the server. + + This function reduces the risk of an involuntary overwriting of a + student solution when the solution is open in several clients. + +*) +let is_synchronized_with_server_callback = ref (fun () -> false) + +let is_synchronized_with_server () = !is_synchronized_with_server_callback () + +let check_valid_editor_state id = + let last_changed = ref (Unix.gettimeofday ()) in + fun update_content focus_back on_sync -> + let update_local_copy checking_time () = + let get_solution () = + Learnocaml_local_storage.(retrieve (exercise_state id)).Answer.solution in + try let mtime = + Learnocaml_local_storage.(retrieve (exercise_state id)).Answer.mtime in + if mtime > checking_time then begin + let buttons = + if is_synchronized_with_server () then + [ + [%i "Fetch from server"], + (fun () -> let solution = get_solution () in + Lwt.return (focus_back (); update_content solution; on_sync ())); + [%i "Ignore & keep editing"], + (fun () -> Lwt.return (focus_back ())); + ] + else + [ + [%i "Ignore & keep editing"], + (fun () -> Lwt.return (focus_back ())); + [%i "Fetch from server & overwrite"], + (fun () -> let solution = get_solution () in + Lwt.return (focus_back (); update_content solution; on_sync ())); + ] + in + lwt_alert ~title:"Question" + ~buttons + [ H.p [H.txt [%i "A more recent answer exists on the server. \ + Do you want to fetch the new version?"] ] ] + end else Lwt.return_unit + with + | Not_found -> Lwt.return () + in + let now = Unix.gettimeofday () in + if now -. !last_changed > 180. then ( + let checking_time = !last_changed in + last_changed := now; + Lwt.async (update_local_copy checking_time) + ) else + last_changed := now + + let ace_display tab = let ace = lazy ( let answer = Ocaml_mode.create_ocaml_editor (Tyxml_js.To_dom.of_div tab) + (fun _ _ _ -> ()) in let ace = Ocaml_mode.get_editor answer in Ace.set_font_size ace 16; @@ -874,7 +939,8 @@ end module Editor_button (E : Editor_info) = struct - let editor_button = button ~container:E.buttons_container ~theme:"light" + let editor_button = + button ~container:E.buttons_container ~theme:"light" let cleanup template = editor_button @@ -901,16 +967,26 @@ module Editor_button (E : Editor_info) = struct select_tab "toplevel"; Lwt.return_unit - let sync token id = - editor_button + let sync token id on_sync = + let state = button_state () in + (editor_button + ~state ~icon: "sync" [%i"Sync"] @@ fun () -> token >>= fun token -> - sync_exercise token id ~editor:(Ace.get_contents E.ace) >|= fun _save -> () + sync_exercise token id ~editor:(Ace.get_contents E.ace) on_sync + >|= fun _save -> ()); + Ace.register_sync_observer E.ace (fun sync -> + if sync then disable_button state else enable_button state) + end -let setup_editor solution = +let setup_editor id solution = let editor_pane = find_component "learnocaml-exo-editor-pane" in - let editor = Ocaml_mode.create_ocaml_editor (Tyxml_js.To_dom.of_div editor_pane) in + let editor = + Ocaml_mode.create_ocaml_editor + (Tyxml_js.To_dom.of_div editor_pane) + (check_valid_editor_state id) + in let ace = Ocaml_mode.get_editor editor in Ace.set_contents ace ~reset_undo:true solution; Ace.set_font_size ace 18; @@ -1022,7 +1098,7 @@ let setup_prelude_pane ace prelude = (fun _ -> state := not !state ; update () ; true) ; Manip.appendChildren prelude_pane [ prelude_title ; prelude_container ] - + let get_token ?(has_server = true) () = if not has_server then Lwt.return None @@ -1041,7 +1117,7 @@ let get_token ?(has_server = true) () = >|= fun token -> Learnocaml_local_storage.(store sync_token) token; Some token - + module Display_exercise = functor ( Q: sig diff --git a/src/app/learnocaml_common.mli b/src/app/learnocaml_common.mli index 5ace9a36f..90d917aaa 100644 --- a/src/app/learnocaml_common.mli +++ b/src/app/learnocaml_common.mli @@ -119,18 +119,22 @@ val set_state_from_save_file : (** Gets a save file containing the locally stored data *) val get_state_as_save_file : ?include_reports:bool -> unit -> Save.t -(** Sync the local save state with the server state, and returns the merged save - file. The save will be created on the server if it doesn't exist. +(** + [sync token on_sync] synchronizes the local save state with the server state, + and returns the merged save file. The save will be created on the server + if it doesn't exist. [on_sync ()] is called when this is done. - This syncs student {b content}, but never the reports which are only synched - on "Grade" *) -val sync: Token.t -> Save.t Lwt.t + Notice that this function synchronizes student {b,content} but not the + reports which are only synchronized when an actual "grading" is done. +*) +val sync: Token.t -> (unit -> unit) -> Save.t Lwt.t (** The same, but limiting the submission to the given exercise, using the given answer if any, and the given editor text, if any. *) val sync_exercise: Token.t option -> ?answer:Learnocaml_data.Answer.t -> ?editor:string -> Learnocaml_data.Exercise.id -> + (unit -> unit) -> Save.t Lwt.t val countdown: @@ -211,10 +215,12 @@ module Editor_button (_ : Editor_info) : sig val cleanup : string -> unit val download : string -> unit val eval : Learnocaml_toplevel.t -> (string -> unit) -> unit - val sync : Token.t option Lwt.t -> Learnocaml_data.SMap.key -> unit + val sync : Token.t option Lwt.t -> Learnocaml_data.SMap.key -> (unit -> unit) -> unit end -val setup_editor : string -> Ocaml_mode.editor * Ocaml_mode.editor Ace.editor +val setup_editor : string -> string -> Ocaml_mode.editor * Ocaml_mode.editor Ace.editor + +val is_synchronized_with_server_callback : (unit -> bool) ref val typecheck : Learnocaml_toplevel.t -> diff --git a/src/app/learnocaml_exercise_main.ml b/src/app/learnocaml_exercise_main.ml index f0492a58b..bea8bde16 100644 --- a/src/app/learnocaml_exercise_main.ml +++ b/src/app/learnocaml_exercise_main.ml @@ -1,6 +1,6 @@ (* This file is part of Learn-OCaml. * - * Copyright (C) 2019 OCaml Software Foundation. + * Copyright (C) 2019-2020 OCaml Software Foundation. * Copyright (C) 2016-2018 OCamlPro. * * Learn-OCaml is distributed under the terms of the MIT license. See the @@ -81,9 +81,9 @@ module Exercise_link = ] content end - -module Display = Display_exercise(Exercise_link) -open Display + +module Display = Display_exercise(Exercise_link) +open Display let is_readonly = ref false @@ -179,10 +179,11 @@ let () = Tyxml_js.Html5.[ h1 [ txt ex_meta.Exercise.Meta.title ] ; Tyxml_js.Of_dom.of_iFrame text_iframe ] ; (* ---- editor pane --------------------------------------------------- *) - let editor, ace = setup_editor solution in + let editor, ace = setup_editor id solution in + is_synchronized_with_server_callback := (fun () -> Ace.is_synchronized ace); let module EB = Editor_button (struct let ace = ace let buttons_container = editor_toolbar end) in EB.cleanup (Learnocaml_exercise.(access File.template exo)); - EB.sync token id; + EB.sync token id (fun () -> Ace.focus ace; Ace.set_synchronized ace) ; EB.download id; EB.eval top select_tab; let typecheck = typecheck top ace editor in @@ -267,7 +268,8 @@ let () = Some solution, None in token >>= fun token -> - sync_exercise token id ?answer ?editor >>= fun _save -> + sync_exercise token id ?answer ?editor (fun () -> Ace.set_synchronized ace) + >>= fun _save -> select_tab "report" ; Lwt_js.yield () >>= fun () -> Ace.focus ace ; @@ -286,7 +288,25 @@ let () = Ace.focus ace ; typecheck true end ; - Window.onunload (fun _ev -> local_save ace id; true); + (* Small but cross-compatible hack (tested with Firefox-ESR, Chromium, Safari) + * that reuses part of this commit: + * https://github.com/pfitaxel/learn-ocaml/commit/15780b5b7c91689a26cfeaf33f3ed2cdb3a5e801 + * For details on this event, see: + * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example + * + * Ideally, we might have wanted to use a variant of [Dom.handler] + * that is compatible with "unbeforeunload". + * For further discussion on this issue, see: + * https://github.com/ocaml-sf/learn-ocaml/issues/467 + *) + let prompt_before_unload () : unit = + Js.Unsafe.js_expr "window.onbeforeunload = function(e) {e.preventDefault(); return false;}" in + let resume_before_unload () : unit = + Js.Unsafe.js_expr "window.onbeforeunload = null" in + let () = + Ace.register_sync_observer ace (fun sync -> + if not sync then prompt_before_unload () + else resume_before_unload ()) in (* ---- return -------------------------------------------------------- *) toplevel_launch >>= fun _ -> typecheck false >>= fun () -> diff --git a/src/app/learnocaml_index_main.ml b/src/app/learnocaml_index_main.ml index d3eda82e2..1019e8bf1 100644 --- a/src/app/learnocaml_index_main.ml +++ b/src/app/learnocaml_index_main.ml @@ -815,7 +815,7 @@ let () = Lwt.return_unit); [%i"Sync workspace"], "sync", (fun () -> catch_with_alert @@ fun () -> - sync () >>= fun _ -> Lwt.return_unit); + sync () ignore >>= fun _ -> Lwt.return_unit); [%i"Export to file"], "download", download_save; [%i"Import"], "upload", import_save; [%i"Download all source files"], "download", download_all; diff --git a/src/app/learnocaml_playground_main.ml b/src/app/learnocaml_playground_main.ml index d877017b0..fd12cc7e0 100644 --- a/src/app/learnocaml_playground_main.ml +++ b/src/app/learnocaml_playground_main.ml @@ -27,8 +27,12 @@ let main () = disable_button_group toplevel_buttons_group (* enabled after init *) ; let toplevel_toolbar = find_component "learnocaml-exo-toplevel-toolbar" in let editor_toolbar = find_component "learnocaml-exo-editor-toolbar" in - let toplevel_button = - button ~container: toplevel_toolbar ~theme: "dark" ~group:toplevel_buttons_group ?state:None in + let toplevel_button ~icon label cb = + ignore @@ + button + ~icon ~container: toplevel_toolbar + ~theme: "dark" ~group:toplevel_buttons_group ?state:None label cb + in let id = match Url.Current.path with | "" :: "playground" :: p | "playground" :: p -> String.concat "/" (List.map Url.urldecode (List.filter ((<>) "") p)) @@ -60,7 +64,7 @@ let main () = (* ---- toplevel pane ------------------------------------------------- *) init_toplevel_pane toplevel_launch top toplevel_buttons_group toplevel_button ; (* ---- editor pane --------------------------------------------------- *) - let editor, ace = setup_editor solution in + let editor, ace = setup_editor id solution in let module EB = Editor_button (struct let ace = ace let buttons_container = editor_toolbar end) in EB.cleanup playground.Playground.template; EB.download id; diff --git a/translations/fr.po b/translations/fr.po index d84b10f73..7aae463af 100644 --- a/translations/fr.po +++ b/translations/fr.po @@ -1,4 +1,5 @@ # LEARN-OCAML FRENCH TRANSLATION +# Copyright (C) 2020 OCaml Software Foundation. # Copyright (C) 2018 OCamlPro # Louis Gesbert , 2018. # @@ -99,197 +100,217 @@ msgstr "ERREUR DE REQUÊTE" msgid "Could not retrieve data from server" msgstr "Échec lors du téléchargement des données du serveur" -#: File "src/app/learnocaml_common.ml", line 414, characters 12-19 454, 11-18 +#: File "src/app/learnocaml_common.ml", line 414, characters 12-19 458, 13-20 #: "src/app/learnocaml_index_main.ml", 582, 17-24 msgid "Retry" msgstr "Réessayer" -#: File "src/app/learnocaml_common.ml", line 417, characters 25-33 455, 11-19 +#: File "src/app/learnocaml_common.ml", line 417, characters 25-33 459, 13-21 msgid "Ignore" msgstr "Ignorer" -#: File "src/app/learnocaml_common.ml", line 450, characters 26-39 +#: File "src/app/learnocaml_common.ml", line 454, characters 26-39 msgid "SYNC FAILED" msgstr "ECHEC DE LA SYNCHRONISATION" -#: File "src/app/learnocaml_common.ml", line 451, characters 22-66 +#: File "src/app/learnocaml_common.ml", line 455, characters 22-66 msgid "Could not synchronise save with the server" msgstr "Les données n'ont pas pu être synchronisées avec le serveur" -#: File "src/app/learnocaml_common.ml", line 510, characters 39-50 +#: File "src/app/learnocaml_common.ml", line 514, characters 39-50 msgid "%dd %02dh" msgstr "%dj %02dh" -#: File "src/app/learnocaml_common.ml", line 511, characters 40-51 +#: File "src/app/learnocaml_common.ml", line 515, characters 40-51 msgid "%02d:%02d" msgstr "%02d:%02d" -#: File "src/app/learnocaml_common.ml", line 512, characters 23-36 +#: File "src/app/learnocaml_common.ml", line 516, characters 23-36 msgid "0:%02d:%02d" msgstr "0:%02d:%02d" -#: File "src/app/learnocaml_common.ml", line 543, characters 34-55 1071, 38-59 +#: File "src/app/learnocaml_common.ml", line 547, characters 34-55 1147, 38-59 msgid "difficulty: %d / 40" msgstr "difficulté: %d / 40" -#: File "src/app/learnocaml_common.ml", line 578, characters 30-75 +#: File "src/app/learnocaml_common.ml", line 582, characters 30-75 msgid "No description available for this exercise." msgstr "Aucune description pour cet exercice." -#: File "src/app/learnocaml_common.ml", line 601, characters 32-41 +#: File "src/app/learnocaml_common.ml", line 605, characters 32-41 #: "src/app/learnocaml_index_main.ml", 132, 54-63 msgid "project" msgstr "projet" -#: File "src/app/learnocaml_common.ml", line 602, characters 32-41 +#: File "src/app/learnocaml_common.ml", line 606, characters 32-41 #: "src/app/learnocaml_index_main.ml", 133, 54-63 msgid "problem" msgstr "problème" -#: File "src/app/learnocaml_common.ml", line 603, characters 33-43 +#: File "src/app/learnocaml_common.ml", line 607, characters 33-43 #: "src/app/learnocaml_index_main.ml", 134, 55-65 msgid "exercise" msgstr "exercice" -#: File "src/app/learnocaml_common.ml", line 755, characters 26-33 +#: File "src/app/learnocaml_common.ml", line 743, characters 20-39 +msgid "Fetch from server" +msgstr "Télécharger du serveur" + +#: File "src/app/learnocaml_common.ml", line 746, characters 20-43 751, +msgid "Ignore & keep editing" +msgstr "Ignorer & continuer d'éditer" + +#: File "src/app/learnocaml_common.ml", line 753, characters 20-51 +msgid "Fetch from server & overwrite" +msgstr "Télécharger du serveur & écraser" + +#: File "src/app/learnocaml_common.ml", lines 760-761, characters 28-67 +msgid "" +"A more recent answer exists on the server. Do you want to fetch the new " +"version?" +msgstr "" +"Une version plus récente de cette réponse existe sur le serveur. Voulez-vous " +"télécharger la nouvelle version ?" + +#: File "src/app/learnocaml_common.ml", line 820, characters 26-33 msgid "Clear" msgstr "Effacer" -#: File "src/app/learnocaml_common.ml", line 760, characters 25-32 881, 24-31 +#: File "src/app/learnocaml_common.ml", line 825, characters 25-32 947, 24-31 msgid "Reset" msgstr "Réinitialiser" -#: File "src/app/learnocaml_common.ml", line 765, characters 22-35 +#: File "src/app/learnocaml_common.ml", line 830, characters 22-35 msgid "Eval phrase" msgstr "Évaluer la phrase" -#: File "src/app/learnocaml_common.ml", line 780, characters 24-51 +#: File "src/app/learnocaml_common.ml", line 845, characters 24-51 msgid "Preparing the environment" msgstr "Préparation de l'environnement" -#: File "src/app/learnocaml_common.ml", line 781, characters 39-47 786, 37-45 +#: File "src/app/learnocaml_common.ml", line 846, characters 39-47 851, 37-45 msgid "Editor" msgstr "Éditeur" -#: File "src/app/learnocaml_common.ml", line 782, characters 41-51 +#: File "src/app/learnocaml_common.ml", line 847, characters 41-51 #: "src/app/learnocaml_index_main.ml", 691, 30-40 msgid "Toplevel" msgstr "Toplevel" -#: File "src/app/learnocaml_common.ml", line 783, characters 39-47 795, +#: File "src/app/learnocaml_common.ml", line 848, characters 39-47 860, #: "src/app/learnocaml_exercise_main.ml", 58, 30-38 62, 67, #: "src/app/learnocaml_student_view.ml", 383, 28-36 396, 400, 405, msgid "Report" msgstr "Rapport" -#: File "src/app/learnocaml_common.ml", line 784, characters 37-47 +#: File "src/app/learnocaml_common.ml", line 849, characters 37-47 msgid "Exercise" msgstr "Exercice" -#: File "src/app/learnocaml_common.ml", line 785, characters 37-46 +#: File "src/app/learnocaml_common.ml", line 850, characters 37-46 msgid "Details" msgstr "Détails" -#: File "src/app/learnocaml_common.ml", line 787, characters 27-70 +#: File "src/app/learnocaml_common.ml", line 852, characters 27-70 msgid "Click the Grade button to get your report" msgstr "Cliquez sur le bouton Noter pour obtenir votre rapport" -#: File "src/app/learnocaml_common.ml", line 792, characters 22-44 +#: File "src/app/learnocaml_common.ml", line 857, characters 22-44 msgid "Loading student data" msgstr "Chargement des informations sur les étudiants" -#: File "src/app/learnocaml_common.ml", line 793, characters 38-45 +#: File "src/app/learnocaml_common.ml", line 858, characters 38-45 msgid "Stats" msgstr "Statistiques" -#: File "src/app/learnocaml_common.ml", line 794, characters 37-48 +#: File "src/app/learnocaml_common.ml", line 859, characters 37-48 #: "src/app/learnocaml_index_main.ml", 688, 29-40 #: "src/app/learnocaml_teacher_tab.ml", 329, 18-29 -#: "src/app/learnocaml_exercise_main.ml", 202, 23-34 +#: "src/app/learnocaml_exercise_main.ml", 203, 23-34 msgid "Exercises" msgstr "Exercices" -#: File "src/app/learnocaml_common.ml", line 796, characters 37-46 +#: File "src/app/learnocaml_common.ml", line 861, characters 37-46 msgid "Subject" msgstr "Énoncé" -#: File "src/app/learnocaml_common.ml", line 797, characters 39-47 +#: File "src/app/learnocaml_common.ml", line 862, characters 39-47 msgid "Answer" msgstr "Réponse" -#: File "src/app/learnocaml_common.ml", line 882, characters 22-42 +#: File "src/app/learnocaml_common.ml", line 948, characters 22-42 msgid "START FROM SCRATCH" msgstr "TOUT RECOMMENCER" -#: File "src/app/learnocaml_common.ml", line 883, characters 16-65 +#: File "src/app/learnocaml_common.ml", line 949, characters 16-65 msgid "This will discard all your edits. Are you sure?" msgstr "Toutes vos modifications seront perdues. Vous êtes sûr·e ?" -#: File "src/app/learnocaml_common.ml", line 890, characters 27-37 +#: File "src/app/learnocaml_common.ml", line 956, characters 27-37 msgid "Download" msgstr "Télécharger" -#: File "src/app/learnocaml_common.ml", line 898, characters 22-33 +#: File "src/app/learnocaml_common.ml", line 964, characters 22-33 msgid "Eval code" msgstr "Exécuter le code" -#: File "src/app/learnocaml_common.ml", line 906, characters 23-29 +#: File "src/app/learnocaml_common.ml", line 974, characters 23-29 msgid "Sync" msgstr "Sync" -#: File "src/app/learnocaml_common.ml", line 964, characters 34-49 999, +#: File "src/app/learnocaml_common.ml", line 1040, characters 34-49 1075, msgid "OCaml prelude" msgstr "Prélude OCaml" -#: File "src/app/learnocaml_common.ml", line 971, characters 59-65 1006, +#: File "src/app/learnocaml_common.ml", line 1047, characters 59-65 1082, msgid "Hide" msgstr "Cacher" -#: File "src/app/learnocaml_common.ml", line 976, characters 59-65 1013, +#: File "src/app/learnocaml_common.ml", line 1052, characters 59-65 1089, msgid "Show" msgstr "Montrer" -#: File "src/app/learnocaml_common.ml", line 1037, characters 18-36 +#: File "src/app/learnocaml_common.ml", line 1113, characters 18-36 msgid "Enter the secret" msgstr "Entrez le secret" -#: File "src/app/learnocaml_common.ml", line 1077, characters 22-35 +#: File "src/app/learnocaml_common.ml", line 1153, characters 22-35 msgid "Difficulty:" msgstr "Difficulté :" -#: File "src/app/learnocaml_common.ml", line 1091, characters 39-49 +#: File "src/app/learnocaml_common.ml", line 1167, characters 39-49 msgid "Kind: %s" msgstr "Type : %s" -#: File "src/app/learnocaml_common.ml", line 1232, characters 46-59 +#: File "src/app/learnocaml_common.ml", line 1308, characters 46-59 msgid "Identifier:" msgstr "Identifiant de l'exercice :" -#: File "src/app/learnocaml_common.ml", line 1236, characters 48-57 +#: File "src/app/learnocaml_common.ml", line 1312, characters 48-57 msgid "Author:" msgstr "Auteur :" -#: File "src/app/learnocaml_common.ml", line 1237, characters 47-57 +#: File "src/app/learnocaml_common.ml", line 1313, characters 47-57 msgid "Authors:" msgstr "Auteurs :" -#: File "src/app/learnocaml_common.ml", line 1242, characters 31-48 +#: File "src/app/learnocaml_common.ml", line 1318, characters 31-48 msgid "Skills trained:" msgstr "Compétences pratiquées :" -#: File "src/app/learnocaml_common.ml", line 1246, characters 31-49 +#: File "src/app/learnocaml_common.ml", line 1322, characters 31-49 msgid "Skills required:" msgstr "Compétences requises :" -#: File "src/app/learnocaml_common.ml", line 1251, characters 36-57 +#: File "src/app/learnocaml_common.ml", line 1327, characters 36-57 msgid "Previous exercises:" msgstr "Exercices précédents :" -#: File "src/app/learnocaml_common.ml", line 1254, characters 35-52 +#: File "src/app/learnocaml_common.ml", line 1330, characters 35-52 msgid "Next exercises:" msgstr "Exercices suivants :" -#: File "src/app/learnocaml_common.ml", line 1259, characters 26-36 +#: File "src/app/learnocaml_common.ml", line 1335, characters 26-36 msgid "Metadata" msgstr "Métadonnées" @@ -517,7 +538,7 @@ msgid "Lessons" msgstr "Cours" #: File "src/app/learnocaml_index_main.ml", line 693, characters 32-44 -#: "src/app/learnocaml_playground_main.ml", 73, 23-35 +#: "src/app/learnocaml_playground_main.ml", 77, 23-35 msgid "Playground" msgstr "Bac-à-sable" @@ -725,45 +746,46 @@ msgstr "" "seront plus sauvegardés sur le serveur." #: File "src/app/learnocaml_exercise_main.ml", line 130, characters 25-49 -#: "src/app/learnocaml_playground_main.ml", 43, 19-43 +#: "src/app/learnocaml_playground_main.ml", 47, 19-43 msgid "loading the prelude..." msgstr "Chargement du prélude..." #: File "src/app/learnocaml_exercise_main.ml", line 135, characters 41-59 -#: "src/app/learnocaml_playground_main.ml", 46, 31-49 +#: "src/app/learnocaml_playground_main.ml", 50, 31-49 msgid "error in prelude" msgstr "erreur dans le prélude" -#: File "src/app/learnocaml_exercise_main.ml", line 214, characters 28-37 -#: "src/app/learnocaml_playground_main.ml", 80, +#: File "src/app/learnocaml_exercise_main.ml", line 215, characters 28-37 +#: "src/app/learnocaml_playground_main.ml", 84, msgid "Compile" msgstr "Compiler" -#: File "src/app/learnocaml_exercise_main.ml", line 218, characters 29-37 +#: File "src/app/learnocaml_exercise_main.ml", line 219, characters 29-37 msgid "Grade!" msgstr "Noter!" -#: File "src/app/learnocaml_exercise_main.ml", line 222, characters 48-55 +#: File "src/app/learnocaml_exercise_main.ml", line 223, characters 48-55 msgid "abort" msgstr "abandonner" -#: File "src/app/learnocaml_exercise_main.ml", lines 226-227, characters 35-65 +#: File "src/app/learnocaml_exercise_main.ml", lines 227-228, characters 35-65 msgid "Grading is taking a lot of time, maybe your code is looping? " -msgstr "La notation prend du temps, peut-être une boucle infinie dans votre code ? " +msgstr "" +"La notation prend du temps, peut-être une boucle infinie dans votre code ? " -#: File "src/app/learnocaml_exercise_main.ml", line 233, characters 35-57 +#: File "src/app/learnocaml_exercise_main.ml", line 234, characters 35-57 msgid "Launching the grader" msgstr "Lancement de la notation" -#: File "src/app/learnocaml_exercise_main.ml", line 256, characters 60-86 +#: File "src/app/learnocaml_exercise_main.ml", line 257, characters 60-86 msgid "Grading aborted by user." msgstr "Notation annulée par l'utilisateur." -#: File "src/app/learnocaml_exercise_main.ml", line 277, characters 38-59 +#: File "src/app/learnocaml_exercise_main.ml", line 279, characters 38-59 msgid "Error in your code." msgstr "Erreur dans le code." -#: File "src/app/learnocaml_exercise_main.ml", line 278, characters 27-85 +#: File "src/app/learnocaml_exercise_main.ml", line 280, characters 27-85 msgid "Cannot start the grader if your code does not typecheck." msgstr "La notation ne peut être lancée si le code ne compile pas." @@ -893,7 +915,7 @@ msgstr "" "%a\n" "%!" -#: File "src/grader/grading.ml", line 96, characters 38-65 106, 131, 139, +#: File "src/grader/grading.ml", line 96, characters 38-65 106, 131, 139, 143, msgid "while preparing the tests" msgstr "lors de la préparation des tests" @@ -925,15 +947,15 @@ msgstr "lors du chargement de la solution" msgid "Preparing to launch the tests." msgstr "Préparation du lancement des tests." -#: File "src/grader/grading.ml", line 142, characters 22-49 +#: File "src/grader/grading.ml", line 146, characters 22-49 msgid "Launching the test bench." msgstr "Lancement du banc de test." -#: File "src/grader/grading.ml", line 171, characters 45-78 +#: File "src/grader/grading.ml", line 175, characters 45-78 msgid "while loading user dependencies" msgstr "lors du chargement des dépendances" -#: File "src/grader/grading.ml", line 187, characters 38-67 +#: File "src/grader/grading.ml", line 191, characters 38-67 msgid "while testing your solution" msgstr "lors du test de la solution utilisateur" @@ -945,6 +967,9 @@ msgstr "lors du test de la solution utilisateur" #~ "Le téléchargement de l'archive a échoué. Veuillez réessayer " #~ "ulterieurement!" +#~ msgid "Do you want to save your work before closing?" +#~ msgstr "Souhaitez-vous enregistrer votre travail avant de quitter?" + #~ msgid "No description available." #~ msgstr "Aucune description."