port module App exposing (..) import Browser import Dict exposing (Dict) import Html as H exposing (Html) import Html.Keyed import Html.Attributes as HA import Html.Events as HE import Json.Decode as JD import Json.Decode.Pipeline as JDP exposing (required, requiredAt) import Json.Encode as JE exposing (Value) import Tuple exposing (pair, first, second) port sendMessage : Value -> Cmd msg port receiveMessage : (Value -> msg) -> Sub msg port receiveClick : (Value -> msg) -> Sub msg main = Browser.element { init = init , update = update , subscriptions = subscriptions , view = view } type alias Exam = { object : JE.Value , currentQuestion : Int , questions : List Question } type alias Question = { isDirty : Bool , score : Float , object : JE.Value , name : String , parts : List Part , number : Int , statement : JE.Value } type alias QuestionMessage = { questionNumber : Int , type_ : String , msg : JE.Value } type PartType = InformationOnlyPart | NumberEntryPart | MathematicalExpressionPart | PatternMatchPart | GapFillPart | CustomPart String type alias Part = { object : JE.Value , name : String , type_ : PartType , path : String , score : Float , marks: Float , prompt : JE.Value , marking_feedback : List FeedbackMessage , gaps : Gaps } type Gaps = Gaps (List Part) type alias FeedbackMessage = { op : String , credit : Maybe Float , message : JE.Value , reason : Maybe String , credit_change : Maybe String } type alias PartMessage = { partPath : String , type_ : String , msg : JE.Value } type ViewMode = ViewWholeExam | ViewQuestion Int | ViewPart Int String | ViewPartAnswer Int String decode_default : JD.Decoder a -> a -> JE.Value -> a decode_default decoder default = JD.decodeValue decoder >> Result.withDefault default decode_view_mode = JD.field "mode" JD.string |> JD.andThen (\mode -> case mode of "exam" -> JD.succeed ViewWholeExam "question" -> JD.field "questionNumber" JD.int |> JD.map ViewQuestion "part" -> JD.map2 ViewPart (JD.field "questionNumber" JD.int) (JD.field "partPath" JD.string) "part_answer" -> JD.map2 ViewPartAnswer (JD.field "questionNumber" JD.int) (JD.field "partPath" JD.string) _ -> JD.fail <| "Unrecognised view mode: "++mode ) type alias Model = { exam : Maybe Exam , messages : List String , view_mode : ViewMode } init_model : JE.Value -> Model init_model flags = let exam = JD.decodeValue (JD.field "exam" JD.value) flags |> Result.toMaybe |> Maybe.map (\e -> { object = e , currentQuestion = -1 , questions = get_questions e } ) view_mode = JD.decodeValue (JD.field "view_mode" decode_view_mode) flags |> Result.withDefault ViewWholeExam in { exam = exam , messages = [] , view_mode = view_mode } type Msg = ReceiveMessage Value | SendPartMessage Question Part String (List (String, JE.Value)) init : JE.Value -> (Model, Cmd msg) init flags = (init_model flags, Cmd.none) listGet : Int -> List a -> Maybe a listGet i = List.drop i >> List.head decode_message = JD.map2 pair (JD.field "type" JD.string) (JD.field "arg" JD.value) update msg model = case msg of ReceiveMessage value -> case JD.decodeValue decode_message value of Ok (kind,arg) -> ({ model | messages = if kind /= "showTiming" then kind::model.messages else model.messages } |> handle_exam_message kind arg, Cmd.none) Err s -> ({ model | messages = (Debug.toString s)::model.messages }, Cmd.none) SendPartMessage question part msgtype extras -> (model, sendMessage (encode_part_message question part msgtype extras)) encode_message msgtype extras = JE.object ([("msgtype", JE.string msgtype)]++extras) encode_question_message question msgtype extras = encode_message "question" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("questionNumber", JE.int question.number) ] encode_part_message question part msgtype extras = encode_question_message question "part" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("partPath", JE.string part.path)] {- update the ith item in a list using the given map function -} update_list : Int -> (a -> a) -> List a -> List a update_list i fn list = list |> List.indexedMap pair |> List.map (\(j,a) -> if i==j then fn a else a) map_exam : (Exam -> Exam) -> Model -> Model map_exam fn model = { model | exam = Maybe.map fn model.exam } map_question : Int -> (Question -> Question) -> Model -> Model map_question i fn model = model |> map_exam (\exam -> { exam | questions = update_list i fn exam.questions }) map_part : String -> (Part -> Part) -> Question -> Question map_part path fn question = { question | parts = List.map (\p -> if p.path==path then fn p else p) question.parts } show_question : Model -> Model show_question model = case model.exam of Nothing -> model Just exam -> case JD.decodeValue (JD.field "currentQuestionNumber" JD.int) exam.object of Ok n -> let nexam = { exam | currentQuestion = n } in {model | exam = Just nexam} Err _ -> model update_question_list = map_exam (\e -> {e | questions = get_questions e.object }) type alias MessageHandler m = String -> Value -> m -> m handle_message : Dict String (Value -> m -> m) -> MessageHandler m handle_message message_handlers kind value model = let handler = Dict.get kind message_handlers |> Maybe.withDefault (\_ -> identity) in handler value model handle_exam_message : MessageHandler Model handle_exam_message = handle_message <| Dict.fromList [ ("showQuestion", \_ -> show_question) , ("updateQuestionList", \_ -> update_question_list) , ("question", \value model -> let mexam : Maybe Exam mexam = model.exam q = Debug.log "question message" minfo minfo = value |> JD.decodeValue (JD.map3 QuestionMessage (JD.field "questionNumber" JD.int) (JD.field "type" JD.string) (JD.field "arg" JD.value) ) |> Result.toMaybe mquestion : Maybe Question mquestion = Maybe.map2 get_question mexam (Maybe.map .questionNumber minfo) |> Maybe.andThen identity in case (minfo, mexam) of (Just info, Just exam) -> map_question info.questionNumber (handle_question_message info.type_ info.msg) model _ -> model ) ] handle_question_message : MessageHandler Question handle_question_message = handle_message <| Dict.fromList [ ("isDirty", \v q -> v |> JD.decodeValue (JD.bool) |> Result.map (\dirty -> {q | isDirty = dirty}) |> Result.withDefault q ) , ("showScore", \_ q -> {q | score = JD.decodeValue (JD.field "score" JD.float) q.object |> Result.withDefault q.score }) , ("part", \value question -> let minfo = value |> JD.decodeValue (JD.map3 PartMessage (JD.field "partPath" JD.string) (JD.field "type" JD.string) (JD.field "arg" JD.value) ) |> Result.toMaybe q = Debug.log "part message" minfo mpart = Maybe.map (.partPath >> get_part question) minfo in case (minfo, mpart) of (Just info, Just part) -> map_part info.partPath (handle_part_message info.type_ info.msg) question _ -> question ) ] handle_part_message : MessageHandler Part handle_part_message = handle_message <| Dict.fromList [ ("showScore", \_ p -> let score = decode_default (JD.field "score" JD.float) 0 p.object marks = decode_default (JD.field "marks" JD.float) 0 p.object marking_feedback = decode_default (JD.field "markingFeedback" (JD.list decode_feedback_message)) [] p.object in {p | score = score, marks = marks, marking_feedback = marking_feedback }) ] subscriptions model = Sub.batch [ receiveMessage ReceiveMessage ] get_exam_name : Exam -> String get_exam_name = .object >> JD.decodeValue (JD.at ["settings","name"] JD.string) >> Result.withDefault "Unnamed exam" get_questions : JE.Value -> List Question get_questions = JD.decodeValue (JD.at ["questionList"] (JD.list decode_question)) >> Result.withDefault [] get_question : Exam -> Int -> Maybe Question get_question exam n = listGet n exam.questions get_part : Question -> String -> Maybe Part get_part question path = question.parts |> List.filter (\p -> p.path == path) |> List.head decode_question : JD.Decoder Question decode_question = JD.succeed Question |> JDP.hardcoded False |> JDP.hardcoded 0 |> JDP.custom JD.value |> required "name" JD.string |> requiredAt ["display", "allParts"] (JD.list decode_part) |> required "number" JD.int |> JDP.optionalAt ["display", "statement"] JD.value (JE.string "") decode_part : JD.Decoder Part decode_part = JD.succeed Part |> JDP.custom JD.value |> required "name" JD.string |> required "type" decode_part_type |> required "path" JD.string |> required "score" JD.float |> required "marks" JD.float |> JDP.optionalAt ["display", "prompt"] JD.value (JE.string "") |> JDP.optional "markingFeedback" (JD.list decode_feedback_message) [] |> JDP.optional "gaps" (JD.map Gaps <| JD.list (JD.lazy (\_ -> decode_part))) (Gaps []) part_types : Dict String PartType part_types = Dict.fromList [ ("information", InformationOnlyPart) , ("numberentry", NumberEntryPart) , ("jme", MathematicalExpressionPart) , ("patternmatch", PatternMatchPart) ] decode_part_type : JD.Decoder PartType decode_part_type = JD.string |> JD.map (\t -> Dict.get t part_types |> Maybe.withDefault (CustomPart t)) decode_feedback_message = JD.succeed FeedbackMessage |> required "op" JD.string |> JDP.optional "credit" (JD.maybe JD.float) Nothing |> required "message" JD.value |> JDP.optional "reason" (JD.maybe JD.string) Nothing |> JDP.optional "credit_change" (JD.maybe JD.string) Nothing external_html : JE.Value -> Html Msg external_html html = H.node "elm-html" [ HA.property "html" html ] [] format_score : Float -> Float -> String format_score score marks = (String.fromFloat score)++"/"++(String.fromFloat marks) view model = case model.exam of Nothing -> view_loading Just exam -> case model.view_mode of ViewWholeExam -> view_exam model exam ViewQuestion n -> case get_question exam n of Just question -> view_question question Nothing -> H.text "That question doesn't exist" ViewPart n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of Just (question, part) -> view_part question part Nothing -> H.text "That part doesn't exist" ViewPartAnswer n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of Just (question, part) -> view_part_answer_input question part Nothing -> H.text <| "That part (q"++(String.fromInt n)++(path)++") doesn't exist" view_loading = H.text "It's loading" view_exam : Model -> Exam -> Html Msg view_exam model exam = let exam_name = get_exam_name exam questions = exam.questions in H.div [] [ H.h1 [] [H.text <| exam_name] {- , H.ul [] (List.map (\msg -> H.li [] [H.text msg]) (List.take 5 model.messages)) -} , H.ul [] (List.map (\q -> H.li [] [view_question q]) questions) ] view_question question = H.div [] [ H.h2 [] [H.text question.name, H.text <| if question.isDirty then " (dirty)" else " (clean)"] , H.p [] [H.text "Score: ", H.text <| String.fromFloat question.score] , external_html question.statement , H.ul [] (List.map (\p -> H.li [] [view_part question p]) question.parts) ] view_part : Question -> Part -> Html Msg view_part question part = let gaps = case part.gaps of Gaps g -> g in H.div [] [ H.h3 [] [H.text part.name] , H.p [] [H.text <| Debug.toString part.type_] , H.p [] [H.text <| "Score: " ++ (format_score part.score part.marks)] , external_html part.prompt , view_part_answer_input question part , H.ul [] (List.map (\f -> view_feedback_message f) part.marking_feedback) ] view_part_answer_input question part = let msgs = { store_answer = pair "answer" >> List.singleton >> SendPartMessage question part "part_answer" , submit_answer = SendPartMessage question part "submit" [] } in case part.type_ of GapFillPart -> H.text "" InformationOnlyPart -> H.text "" _ -> H.form [ HE.onSubmit msgs.submit_answer , HA.attribute "part" "answer-input-form" , HE.on "focusout" (JD.succeed msgs.submit_answer) ] [ case part.type_ of NumberEntryPart -> number_entry_input msgs MathematicalExpressionPart -> mathematical_expression_input msgs _ -> H.text "" ] type alias AnswerMessages = { store_answer : JE.Value -> Msg , submit_answer : Msg } type alias AnswerInput = AnswerMessages -> Html Msg number_entry_input : AnswerInput number_entry_input msgs = H.input [ HE.onInput ( JE.string >> msgs.store_answer ) ] [] mathematical_expression_input : AnswerInput mathematical_expression_input msgs = H.input [ HE.onInput ( JE.string >> msgs.store_answer ) ] [] view_feedback_message f = let feedback_icon = case f.credit_change of Just "positive" -> H.text "+" Just "negative" -> H.text "-" _ -> H.text "" in H.li [HA.class "feedback-message"] [ feedback_icon , external_html f.message ]