diff --git a/.typos.toml b/.typos.toml index 44a547f652..1f4be05722 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,7 +1,7 @@ [files] extend-exclude = [ "lib/WeBWorK/Localize/*", - "courses.dist/modelCourse/html/achievements/*.png", + "htdocs/images/achievements/*.png", ] [default] diff --git a/courses.dist/modelCourse/templates/achievements/back_for_more.at b/assets/achievementEvaluators/back_for_more.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/back_for_more.at rename to assets/achievementEvaluators/back_for_more.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_eight.at b/assets/achievementEvaluators/challenge_eight.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_eight.at rename to assets/achievementEvaluators/challenge_eight.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_five.at b/assets/achievementEvaluators/challenge_five.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_five.at rename to assets/achievementEvaluators/challenge_five.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_four.at b/assets/achievementEvaluators/challenge_four.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_four.at rename to assets/achievementEvaluators/challenge_four.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_nine.at b/assets/achievementEvaluators/challenge_nine.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_nine.at rename to assets/achievementEvaluators/challenge_nine.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_one.at b/assets/achievementEvaluators/challenge_one.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_one.at rename to assets/achievementEvaluators/challenge_one.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_seven.at b/assets/achievementEvaluators/challenge_seven.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_seven.at rename to assets/achievementEvaluators/challenge_seven.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_six.at b/assets/achievementEvaluators/challenge_six.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_six.at rename to assets/achievementEvaluators/challenge_six.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_ten.at b/assets/achievementEvaluators/challenge_ten.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_ten.at rename to assets/achievementEvaluators/challenge_ten.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_three.at b/assets/achievementEvaluators/challenge_three.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_three.at rename to assets/achievementEvaluators/challenge_three.at diff --git a/courses.dist/modelCourse/templates/achievements/challenge_two.at b/assets/achievementEvaluators/challenge_two.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenge_two.at rename to assets/achievementEvaluators/challenge_two.at diff --git a/courses.dist/modelCourse/templates/achievements/challenger.at b/assets/achievementEvaluators/challenger.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/challenger.at rename to assets/achievementEvaluators/challenger.at diff --git a/courses.dist/modelCourse/templates/achievements/chipping_away.at b/assets/achievementEvaluators/chipping_away.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/chipping_away.at rename to assets/achievementEvaluators/chipping_away.at diff --git a/courses.dist/modelCourse/templates/achievements/complete_n_problems.at b/assets/achievementEvaluators/complete_n_problems.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/complete_n_problems.at rename to assets/achievementEvaluators/complete_n_problems.at diff --git a/courses.dist/modelCourse/templates/achievements/complete_n_sets.at b/assets/achievementEvaluators/complete_n_sets.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/complete_n_sets.at rename to assets/achievementEvaluators/complete_n_sets.at diff --git a/courses.dist/modelCourse/templates/achievements/crack_o_dawn.at b/assets/achievementEvaluators/crack_o_dawn.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/crack_o_dawn.at rename to assets/achievementEvaluators/crack_o_dawn.at diff --git a/courses.dist/modelCourse/templates/achievements/default_achievements.axp b/assets/achievementEvaluators/default_achievements.axp similarity index 100% rename from courses.dist/modelCourse/templates/achievements/default_achievements.axp rename to assets/achievementEvaluators/default_achievements.axp diff --git a/courses.dist/modelCourse/templates/achievements/early_bird.at b/assets/achievementEvaluators/early_bird.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/early_bird.at rename to assets/achievementEvaluators/early_bird.at diff --git a/courses.dist/modelCourse/templates/achievements/earn_n_achievements.at b/assets/achievementEvaluators/earn_n_achievements.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/earn_n_achievements.at rename to assets/achievementEvaluators/earn_n_achievements.at diff --git a/courses.dist/modelCourse/templates/achievements/example_complete_n_specific_problems.at b/assets/achievementEvaluators/example_complete_n_specific_problems.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/example_complete_n_specific_problems.at rename to assets/achievementEvaluators/example_complete_n_specific_problems.at diff --git a/courses.dist/modelCourse/templates/achievements/example_complete_n_specific_sets.at b/assets/achievementEvaluators/example_complete_n_specific_sets.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/example_complete_n_specific_sets.at rename to assets/achievementEvaluators/example_complete_n_specific_sets.at diff --git a/courses.dist/modelCourse/templates/achievements/example_complete_specific_problem.at b/assets/achievementEvaluators/example_complete_specific_problem.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/example_complete_specific_problem.at rename to assets/achievementEvaluators/example_complete_specific_problem.at diff --git a/courses.dist/modelCourse/templates/achievements/example_complete_specific_set.at b/assets/achievementEvaluators/example_complete_specific_set.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/example_complete_specific_set.at rename to assets/achievementEvaluators/example_complete_specific_set.at diff --git a/courses.dist/modelCourse/templates/achievements/extensions.at b/assets/achievementEvaluators/extensions.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/extensions.at rename to assets/achievementEvaluators/extensions.at diff --git a/courses.dist/modelCourse/templates/achievements/extensions.axp b/assets/achievementEvaluators/extensions.axp similarity index 100% rename from courses.dist/modelCourse/templates/achievements/extensions.axp rename to assets/achievementEvaluators/extensions.axp diff --git a/courses.dist/modelCourse/templates/achievements/hows_your_finger.at b/assets/achievementEvaluators/hows_your_finger.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/hows_your_finger.at rename to assets/achievementEvaluators/hows_your_finger.at diff --git a/courses.dist/modelCourse/templates/achievements/last_minute.at b/assets/achievementEvaluators/last_minute.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/last_minute.at rename to assets/achievementEvaluators/last_minute.at diff --git a/courses.dist/modelCourse/templates/achievements/level_eight.at b/assets/achievementEvaluators/level_eight.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_eight.at rename to assets/achievementEvaluators/level_eight.at diff --git a/courses.dist/modelCourse/templates/achievements/level_five.at b/assets/achievementEvaluators/level_five.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_five.at rename to assets/achievementEvaluators/level_five.at diff --git a/courses.dist/modelCourse/templates/achievements/level_four.at b/assets/achievementEvaluators/level_four.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_four.at rename to assets/achievementEvaluators/level_four.at diff --git a/courses.dist/modelCourse/templates/achievements/level_nine.at b/assets/achievementEvaluators/level_nine.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_nine.at rename to assets/achievementEvaluators/level_nine.at diff --git a/courses.dist/modelCourse/templates/achievements/level_one.at b/assets/achievementEvaluators/level_one.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_one.at rename to assets/achievementEvaluators/level_one.at diff --git a/courses.dist/modelCourse/templates/achievements/level_seven.at b/assets/achievementEvaluators/level_seven.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_seven.at rename to assets/achievementEvaluators/level_seven.at diff --git a/courses.dist/modelCourse/templates/achievements/level_six.at b/assets/achievementEvaluators/level_six.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_six.at rename to assets/achievementEvaluators/level_six.at diff --git a/courses.dist/modelCourse/templates/achievements/level_ten.at b/assets/achievementEvaluators/level_ten.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_ten.at rename to assets/achievementEvaluators/level_ten.at diff --git a/courses.dist/modelCourse/templates/achievements/level_three.at b/assets/achievementEvaluators/level_three.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_three.at rename to assets/achievementEvaluators/level_three.at diff --git a/courses.dist/modelCourse/templates/achievements/level_two.at b/assets/achievementEvaluators/level_two.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/level_two.at rename to assets/achievementEvaluators/level_two.at diff --git a/courses.dist/modelCourse/templates/achievements/n_correct_in_a_row.at b/assets/achievementEvaluators/n_correct_in_a_row.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/n_correct_in_a_row.at rename to assets/achievementEvaluators/n_correct_in_a_row.at diff --git a/courses.dist/modelCourse/templates/achievements/night_owl.at b/assets/achievementEvaluators/night_owl.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/night_owl.at rename to assets/achievementEvaluators/night_owl.at diff --git a/courses.dist/modelCourse/templates/achievements/on_one_hand.at b/assets/achievementEvaluators/on_one_hand.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/on_one_hand.at rename to assets/achievementEvaluators/on_one_hand.at diff --git a/courses.dist/modelCourse/templates/achievements/on_the_hour.at b/assets/achievementEvaluators/on_the_hour.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/on_the_hour.at rename to assets/achievementEvaluators/on_the_hour.at diff --git a/courses.dist/modelCourse/templates/achievements/one_click.at b/assets/achievementEvaluators/one_click.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/one_click.at rename to assets/achievementEvaluators/one_click.at diff --git a/courses.dist/modelCourse/templates/achievements/persistence.at b/assets/achievementEvaluators/persistence.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/persistence.at rename to assets/achievementEvaluators/persistence.at diff --git a/courses.dist/modelCourse/templates/achievements/really_early_bird.at b/assets/achievementEvaluators/really_early_bird.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/really_early_bird.at rename to assets/achievementEvaluators/really_early_bird.at diff --git a/courses.dist/modelCourse/templates/achievements/seeing_green.at b/assets/achievementEvaluators/seeing_green.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/seeing_green.at rename to assets/achievementEvaluators/seeing_green.at diff --git a/courses.dist/modelCourse/templates/achievements/speed_mather.at b/assets/achievementEvaluators/speed_mather.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/speed_mather.at rename to assets/achievementEvaluators/speed_mather.at diff --git a/courses.dist/modelCourse/templates/achievements/still_not_right.at b/assets/achievementEvaluators/still_not_right.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/still_not_right.at rename to assets/achievementEvaluators/still_not_right.at diff --git a/courses.dist/modelCourse/templates/achievements/super_persistence.at b/assets/achievementEvaluators/super_persistence.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/super_persistence.at rename to assets/achievementEvaluators/super_persistence.at diff --git a/courses.dist/modelCourse/templates/achievements/super_speed_math.at b/assets/achievementEvaluators/super_speed_math.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/super_speed_math.at rename to assets/achievementEvaluators/super_speed_math.at diff --git a/courses.dist/modelCourse/templates/achievements/third_time.at b/assets/achievementEvaluators/third_time.at similarity index 100% rename from courses.dist/modelCourse/templates/achievements/third_time.at rename to assets/achievementEvaluators/third_time.at diff --git a/conf/defaults.config b/conf/defaults.config index d28b39e71f..46788a851e 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -283,12 +283,15 @@ $webworkDirs{conf} = "$webworkDirs{root}/conf"; # Location of add-on configuration files. $webworkDirs{addOnConf} = "$webworkDirs{conf}/addon"; -# Location of assets (tex, pg, themes) +# Location of assets (tex, pg, themes, achievement evaluators) $webworkDirs{assets} = "$webworkDirs{root}/assets"; # Location of the distribution hardcopy themes. $webworkDirs{hardcopyThemes} = "$webworkDirs{assets}/hardcopyThemes"; +# Location of system achievement evaluators. +$webworkDirs{achievementEvaluators} = "$webworkDirs{assets}/achievementEvaluators"; + # Location of course directories. $webworkDirs{courses} = "$webwork_courses_dir" || "$webworkDirs{root}/courses"; @@ -327,6 +330,10 @@ $webworkURLs{htdocs_temp} = "$webworkURLs{htdocs}/tmp"; # Location of theme templates. $webworkDirs{themes} = "$webworkDirs{htdocs}/themes"; +# Location of system achievement badge icons. +$webworkDirs{achievementIcons} = "$webworkDirs{htdocs}/images/achievements"; +$webworkURLs{achievementIcons} = "$webworkURLs{htdocs}/images/achievements"; + # Location of localization directory. $webworkDirs{localize} = "$webworkDirs{root}/lib/WeBWorK/Localize"; diff --git a/courses.dist/modelCourse/html/achievements/README b/courses.dist/modelCourse/html/achievements/README new file mode 100644 index 0000000000..1afe5d8c51 --- /dev/null +++ b/courses.dist/modelCourse/html/achievements/README @@ -0,0 +1 @@ +Place course specific achievement image icons/overrides here. diff --git a/courses.dist/modelCourse/html/achievements/back_for_more.png b/courses.dist/modelCourse/html/achievements/back_for_more.png deleted file mode 100644 index cd63b56f2b..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/back_for_more.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/defaulticon.png b/courses.dist/modelCourse/html/achievements/defaulticon.png deleted file mode 100644 index 2f660e72f7..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/defaulticon.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_eight.png b/courses.dist/modelCourse/html/achievements/level_eight.png deleted file mode 100644 index c5e793ffca..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_eight.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_five.png b/courses.dist/modelCourse/html/achievements/level_five.png deleted file mode 100644 index 598adc678d..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_five.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_four.png b/courses.dist/modelCourse/html/achievements/level_four.png deleted file mode 100644 index e027fabb23..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_four.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_one.png b/courses.dist/modelCourse/html/achievements/level_one.png deleted file mode 100644 index 552010f472..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_one.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_seven.png b/courses.dist/modelCourse/html/achievements/level_seven.png deleted file mode 100644 index 3f9b1d9cd2..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_seven.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/level_two.png b/courses.dist/modelCourse/html/achievements/level_two.png deleted file mode 100644 index b86afcc186..0000000000 Binary files a/courses.dist/modelCourse/html/achievements/level_two.png and /dev/null differ diff --git a/courses.dist/modelCourse/html/achievements/2nd_order_ode.png b/htdocs/images/achievements/2nd_order_ode.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/2nd_order_ode.png rename to htdocs/images/achievements/2nd_order_ode.png diff --git a/courses.dist/modelCourse/html/achievements/Fractal-Cornucopia.svg b/htdocs/images/achievements/Fractal-Cornucopia.svg similarity index 100% rename from courses.dist/modelCourse/html/achievements/Fractal-Cornucopia.svg rename to htdocs/images/achievements/Fractal-Cornucopia.svg diff --git a/courses.dist/modelCourse/html/achievements/add_anything.png b/htdocs/images/achievements/add_anything.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/add_anything.png rename to htdocs/images/achievements/add_anything.png diff --git a/htdocs/images/achievements/back_for_more.png b/htdocs/images/achievements/back_for_more.png new file mode 100644 index 0000000000..6620def16a Binary files /dev/null and b/htdocs/images/achievements/back_for_more.png differ diff --git a/courses.dist/modelCourse/html/achievements/bug_sqush.png b/htdocs/images/achievements/bug_sqush.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/bug_sqush.png rename to htdocs/images/achievements/bug_sqush.png diff --git a/courses.dist/modelCourse/html/achievements/by_definition.png b/htdocs/images/achievements/by_definition.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/by_definition.png rename to htdocs/images/achievements/by_definition.png diff --git a/courses.dist/modelCourse/html/achievements/calendar.png b/htdocs/images/achievements/calendar.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/calendar.png rename to htdocs/images/achievements/calendar.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_eight.png b/htdocs/images/achievements/challenge_eight.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_eight.png rename to htdocs/images/achievements/challenge_eight.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_five.png b/htdocs/images/achievements/challenge_five.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_five.png rename to htdocs/images/achievements/challenge_five.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_four.png b/htdocs/images/achievements/challenge_four.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_four.png rename to htdocs/images/achievements/challenge_four.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_nine.png b/htdocs/images/achievements/challenge_nine.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_nine.png rename to htdocs/images/achievements/challenge_nine.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_one.png b/htdocs/images/achievements/challenge_one.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_one.png rename to htdocs/images/achievements/challenge_one.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_seven.png b/htdocs/images/achievements/challenge_seven.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_seven.png rename to htdocs/images/achievements/challenge_seven.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_six.png b/htdocs/images/achievements/challenge_six.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_six.png rename to htdocs/images/achievements/challenge_six.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_ten.png b/htdocs/images/achievements/challenge_ten.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_ten.png rename to htdocs/images/achievements/challenge_ten.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_three.png b/htdocs/images/achievements/challenge_three.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_three.png rename to htdocs/images/achievements/challenge_three.png diff --git a/courses.dist/modelCourse/html/achievements/challenge_two.png b/htdocs/images/achievements/challenge_two.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenge_two.png rename to htdocs/images/achievements/challenge_two.png diff --git a/courses.dist/modelCourse/html/achievements/challenger.png b/htdocs/images/achievements/challenger.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/challenger.png rename to htdocs/images/achievements/challenger.png diff --git a/courses.dist/modelCourse/html/achievements/change_of_variables.png b/htdocs/images/achievements/change_of_variables.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/change_of_variables.png rename to htdocs/images/achievements/change_of_variables.png diff --git a/courses.dist/modelCourse/html/achievements/chipping_away.png b/htdocs/images/achievements/chipping_away.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/chipping_away.png rename to htdocs/images/achievements/chipping_away.png diff --git a/courses.dist/modelCourse/html/achievements/complete_100_problems.png b/htdocs/images/achievements/complete_100_problems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_100_problems.png rename to htdocs/images/achievements/complete_100_problems.png diff --git a/courses.dist/modelCourse/html/achievements/complete_10_problems.png b/htdocs/images/achievements/complete_10_problems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_10_problems.png rename to htdocs/images/achievements/complete_10_problems.png diff --git a/courses.dist/modelCourse/html/achievements/complete_150_problems.png b/htdocs/images/achievements/complete_150_problems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_150_problems.png rename to htdocs/images/achievements/complete_150_problems.png diff --git a/courses.dist/modelCourse/html/achievements/complete_25_problems.png b/htdocs/images/achievements/complete_25_problems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_25_problems.png rename to htdocs/images/achievements/complete_25_problems.png diff --git a/courses.dist/modelCourse/html/achievements/complete_50_problems.png b/htdocs/images/achievements/complete_50_problems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_50_problems.png rename to htdocs/images/achievements/complete_50_problems.png diff --git a/courses.dist/modelCourse/html/achievements/complete_all_sets.png b/htdocs/images/achievements/complete_all_sets.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_all_sets.png rename to htdocs/images/achievements/complete_all_sets.png diff --git a/courses.dist/modelCourse/html/achievements/complete_five_sets.png b/htdocs/images/achievements/complete_five_sets.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_five_sets.png rename to htdocs/images/achievements/complete_five_sets.png diff --git a/courses.dist/modelCourse/html/achievements/complete_one_problem.png b/htdocs/images/achievements/complete_one_problem.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_one_problem.png rename to htdocs/images/achievements/complete_one_problem.png diff --git a/courses.dist/modelCourse/html/achievements/complete_one_set.png b/htdocs/images/achievements/complete_one_set.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_one_set.png rename to htdocs/images/achievements/complete_one_set.png diff --git a/courses.dist/modelCourse/html/achievements/complete_ten_sets.png b/htdocs/images/achievements/complete_ten_sets.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_ten_sets.png rename to htdocs/images/achievements/complete_ten_sets.png diff --git a/courses.dist/modelCourse/html/achievements/complete_thirty_sets.png b/htdocs/images/achievements/complete_thirty_sets.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_thirty_sets.png rename to htdocs/images/achievements/complete_thirty_sets.png diff --git a/courses.dist/modelCourse/html/achievements/complete_twenty_sets.png b/htdocs/images/achievements/complete_twenty_sets.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/complete_twenty_sets.png rename to htdocs/images/achievements/complete_twenty_sets.png diff --git a/courses.dist/modelCourse/html/achievements/crack_o_dawn.png b/htdocs/images/achievements/crack_o_dawn.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/crack_o_dawn.png rename to htdocs/images/achievements/crack_o_dawn.png diff --git a/courses.dist/modelCourse/html/achievements/curvature.png b/htdocs/images/achievements/curvature.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/curvature.png rename to htdocs/images/achievements/curvature.png diff --git a/courses.dist/modelCourse/html/achievements/curvy_geometry.png b/htdocs/images/achievements/curvy_geometry.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/curvy_geometry.png rename to htdocs/images/achievements/curvy_geometry.png diff --git a/htdocs/images/achievements/defaulticon.png b/htdocs/images/achievements/defaulticon.png new file mode 100644 index 0000000000..add5258aa5 Binary files /dev/null and b/htdocs/images/achievements/defaulticon.png differ diff --git a/courses.dist/modelCourse/html/achievements/derivative_master.png b/htdocs/images/achievements/derivative_master.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/derivative_master.png rename to htdocs/images/achievements/derivative_master.png diff --git a/courses.dist/modelCourse/html/achievements/doing_it_backwards.png b/htdocs/images/achievements/doing_it_backwards.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/doing_it_backwards.png rename to htdocs/images/achievements/doing_it_backwards.png diff --git a/courses.dist/modelCourse/html/achievements/double_integral.png b/htdocs/images/achievements/double_integral.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/double_integral.png rename to htdocs/images/achievements/double_integral.png diff --git a/courses.dist/modelCourse/html/achievements/early_bird.png b/htdocs/images/achievements/early_bird.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/early_bird.png rename to htdocs/images/achievements/early_bird.png diff --git a/courses.dist/modelCourse/html/achievements/earn_10_achievements.png b/htdocs/images/achievements/earn_10_achievements.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/earn_10_achievements.png rename to htdocs/images/achievements/earn_10_achievements.png diff --git a/courses.dist/modelCourse/html/achievements/earn_20_achievements.png b/htdocs/images/achievements/earn_20_achievements.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/earn_20_achievements.png rename to htdocs/images/achievements/earn_20_achievements.png diff --git a/courses.dist/modelCourse/html/achievements/earn_30_achievements.png b/htdocs/images/achievements/earn_30_achievements.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/earn_30_achievements.png rename to htdocs/images/achievements/earn_30_achievements.png diff --git a/courses.dist/modelCourse/html/achievements/earn_40_achievements.png b/htdocs/images/achievements/earn_40_achievements.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/earn_40_achievements.png rename to htdocs/images/achievements/earn_40_achievements.png diff --git a/courses.dist/modelCourse/html/achievements/earn_50_achievements.png b/htdocs/images/achievements/earn_50_achievements.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/earn_50_achievements.png rename to htdocs/images/achievements/earn_50_achievements.png diff --git a/courses.dist/modelCourse/html/achievements/eigenvector.png b/htdocs/images/achievements/eigenvector.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/eigenvector.png rename to htdocs/images/achievements/eigenvector.png diff --git a/courses.dist/modelCourse/html/achievements/first_order_ode.png b/htdocs/images/achievements/first_order_ode.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/first_order_ode.png rename to htdocs/images/achievements/first_order_ode.png diff --git a/courses.dist/modelCourse/html/achievements/heart_area.png b/htdocs/images/achievements/heart_area.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/heart_area.png rename to htdocs/images/achievements/heart_area.png diff --git a/courses.dist/modelCourse/html/achievements/hows_your_finger.png b/htdocs/images/achievements/hows_your_finger.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/hows_your_finger.png rename to htdocs/images/achievements/hows_your_finger.png diff --git a/courses.dist/modelCourse/html/achievements/integral_master.png b/htdocs/images/achievements/integral_master.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/integral_master.png rename to htdocs/images/achievements/integral_master.png diff --git a/courses.dist/modelCourse/html/achievements/its_all_relative.png b/htdocs/images/achievements/its_all_relative.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/its_all_relative.png rename to htdocs/images/achievements/its_all_relative.png diff --git a/courses.dist/modelCourse/html/achievements/la_revolucion.png b/htdocs/images/achievements/la_revolucion.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/la_revolucion.png rename to htdocs/images/achievements/la_revolucion.png diff --git a/courses.dist/modelCourse/html/achievements/lagrange_multipliers.png b/htdocs/images/achievements/lagrange_multipliers.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/lagrange_multipliers.png rename to htdocs/images/achievements/lagrange_multipliers.png diff --git a/courses.dist/modelCourse/html/achievements/laplace.png b/htdocs/images/achievements/laplace.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/laplace.png rename to htdocs/images/achievements/laplace.png diff --git a/courses.dist/modelCourse/html/achievements/last_minute.png b/htdocs/images/achievements/last_minute.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/last_minute.png rename to htdocs/images/achievements/last_minute.png diff --git a/htdocs/images/achievements/level_eight.png b/htdocs/images/achievements/level_eight.png new file mode 100644 index 0000000000..72053bb476 Binary files /dev/null and b/htdocs/images/achievements/level_eight.png differ diff --git a/htdocs/images/achievements/level_five.png b/htdocs/images/achievements/level_five.png new file mode 100644 index 0000000000..cfe4c03f88 Binary files /dev/null and b/htdocs/images/achievements/level_five.png differ diff --git a/htdocs/images/achievements/level_four.png b/htdocs/images/achievements/level_four.png new file mode 100644 index 0000000000..211f7312dc Binary files /dev/null and b/htdocs/images/achievements/level_four.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_nine.png b/htdocs/images/achievements/level_nine.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/level_nine.png rename to htdocs/images/achievements/level_nine.png diff --git a/htdocs/images/achievements/level_one.png b/htdocs/images/achievements/level_one.png new file mode 100644 index 0000000000..2e8f6ad83f Binary files /dev/null and b/htdocs/images/achievements/level_one.png differ diff --git a/htdocs/images/achievements/level_seven.png b/htdocs/images/achievements/level_seven.png new file mode 100644 index 0000000000..b64a3ca7ad Binary files /dev/null and b/htdocs/images/achievements/level_seven.png differ diff --git a/courses.dist/modelCourse/html/achievements/level_six.png b/htdocs/images/achievements/level_six.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/level_six.png rename to htdocs/images/achievements/level_six.png diff --git a/courses.dist/modelCourse/html/achievements/level_ten.png b/htdocs/images/achievements/level_ten.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/level_ten.png rename to htdocs/images/achievements/level_ten.png diff --git a/courses.dist/modelCourse/html/achievements/level_three.png b/htdocs/images/achievements/level_three.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/level_three.png rename to htdocs/images/achievements/level_three.png diff --git a/htdocs/images/achievements/level_two.png b/htdocs/images/achievements/level_two.png new file mode 100644 index 0000000000..90030bc517 Binary files /dev/null and b/htdocs/images/achievements/level_two.png differ diff --git a/courses.dist/modelCourse/html/achievements/line_integrals.png b/htdocs/images/achievements/line_integrals.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/line_integrals.png rename to htdocs/images/achievements/line_integrals.png diff --git a/courses.dist/modelCourse/html/achievements/lines_and_planes.png b/htdocs/images/achievements/lines_and_planes.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/lines_and_planes.png rename to htdocs/images/achievements/lines_and_planes.png diff --git a/courses.dist/modelCourse/html/achievements/many_fractions.png b/htdocs/images/achievements/many_fractions.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/many_fractions.png rename to htdocs/images/achievements/many_fractions.png diff --git a/courses.dist/modelCourse/html/achievements/night_owl.png b/htdocs/images/achievements/night_owl.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/night_owl.png rename to htdocs/images/achievements/night_owl.png diff --git a/courses.dist/modelCourse/html/achievements/ode_systems.png b/htdocs/images/achievements/ode_systems.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/ode_systems.png rename to htdocs/images/achievements/ode_systems.png diff --git a/courses.dist/modelCourse/html/achievements/on_fire.png b/htdocs/images/achievements/on_fire.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/on_fire.png rename to htdocs/images/achievements/on_fire.png diff --git a/courses.dist/modelCourse/html/achievements/on_one_hand.png b/htdocs/images/achievements/on_one_hand.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/on_one_hand.png rename to htdocs/images/achievements/on_one_hand.png diff --git a/courses.dist/modelCourse/html/achievements/on_the_hour.png b/htdocs/images/achievements/on_the_hour.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/on_the_hour.png rename to htdocs/images/achievements/on_the_hour.png diff --git a/courses.dist/modelCourse/html/achievements/one_click.png b/htdocs/images/achievements/one_click.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/one_click.png rename to htdocs/images/achievements/one_click.png diff --git a/courses.dist/modelCourse/html/achievements/optimizer_prime.png b/htdocs/images/achievements/optimizer_prime.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/optimizer_prime.png rename to htdocs/images/achievements/optimizer_prime.png diff --git a/courses.dist/modelCourse/html/achievements/partial_derivatives.png b/htdocs/images/achievements/partial_derivatives.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/partial_derivatives.png rename to htdocs/images/achievements/partial_derivatives.png diff --git a/courses.dist/modelCourse/html/achievements/pattern_recognition.png b/htdocs/images/achievements/pattern_recognition.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/pattern_recognition.png rename to htdocs/images/achievements/pattern_recognition.png diff --git a/courses.dist/modelCourse/html/achievements/persistence.png b/htdocs/images/achievements/persistence.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/persistence.png rename to htdocs/images/achievements/persistence.png diff --git a/courses.dist/modelCourse/html/achievements/put_me_in_coach.png b/htdocs/images/achievements/put_me_in_coach.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/put_me_in_coach.png rename to htdocs/images/achievements/put_me_in_coach.png diff --git a/courses.dist/modelCourse/html/achievements/reaching_a_limit.png b/htdocs/images/achievements/reaching_a_limit.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/reaching_a_limit.png rename to htdocs/images/achievements/reaching_a_limit.png diff --git a/courses.dist/modelCourse/html/achievements/really_early_bird.png b/htdocs/images/achievements/really_early_bird.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/really_early_bird.png rename to htdocs/images/achievements/really_early_bird.png diff --git a/courses.dist/modelCourse/html/achievements/revolucion_redux.png b/htdocs/images/achievements/revolucion_redux.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/revolucion_redux.png rename to htdocs/images/achievements/revolucion_redux.png diff --git a/courses.dist/modelCourse/html/achievements/roman_numeral_I.png b/htdocs/images/achievements/roman_numeral_I.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/roman_numeral_I.png rename to htdocs/images/achievements/roman_numeral_I.png diff --git a/courses.dist/modelCourse/html/achievements/roman_numeral_II.png b/htdocs/images/achievements/roman_numeral_II.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/roman_numeral_II.png rename to htdocs/images/achievements/roman_numeral_II.png diff --git a/courses.dist/modelCourse/html/achievements/seeing_green.png b/htdocs/images/achievements/seeing_green.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/seeing_green.png rename to htdocs/images/achievements/seeing_green.png diff --git a/courses.dist/modelCourse/html/achievements/speed_mather.png b/htdocs/images/achievements/speed_mather.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/speed_mather.png rename to htdocs/images/achievements/speed_mather.png diff --git a/courses.dist/modelCourse/html/achievements/step_by_step_1.png b/htdocs/images/achievements/step_by_step_1.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/step_by_step_1.png rename to htdocs/images/achievements/step_by_step_1.png diff --git a/courses.dist/modelCourse/html/achievements/step_by_step_10.png b/htdocs/images/achievements/step_by_step_10.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/step_by_step_10.png rename to htdocs/images/achievements/step_by_step_10.png diff --git a/courses.dist/modelCourse/html/achievements/step_by_step_20.png b/htdocs/images/achievements/step_by_step_20.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/step_by_step_20.png rename to htdocs/images/achievements/step_by_step_20.png diff --git a/courses.dist/modelCourse/html/achievements/step_by_step_30.png b/htdocs/images/achievements/step_by_step_30.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/step_by_step_30.png rename to htdocs/images/achievements/step_by_step_30.png diff --git a/courses.dist/modelCourse/html/achievements/step_by_step_5.png b/htdocs/images/achievements/step_by_step_5.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/step_by_step_5.png rename to htdocs/images/achievements/step_by_step_5.png diff --git a/courses.dist/modelCourse/html/achievements/still_not_right.png b/htdocs/images/achievements/still_not_right.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/still_not_right.png rename to htdocs/images/achievements/still_not_right.png diff --git a/courses.dist/modelCourse/html/achievements/super_persistence.png b/htdocs/images/achievements/super_persistence.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/super_persistence.png rename to htdocs/images/achievements/super_persistence.png diff --git a/courses.dist/modelCourse/html/achievements/super_speed_math.png b/htdocs/images/achievements/super_speed_math.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/super_speed_math.png rename to htdocs/images/achievements/super_speed_math.png diff --git a/courses.dist/modelCourse/html/achievements/tangent_plane.png b/htdocs/images/achievements/tangent_plane.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/tangent_plane.png rename to htdocs/images/achievements/tangent_plane.png diff --git a/courses.dist/modelCourse/html/achievements/taylor_hero.png b/htdocs/images/achievements/taylor_hero.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/taylor_hero.png rename to htdocs/images/achievements/taylor_hero.png diff --git a/courses.dist/modelCourse/html/achievements/tea_time.png b/htdocs/images/achievements/tea_time.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/tea_time.png rename to htdocs/images/achievements/tea_time.png diff --git a/courses.dist/modelCourse/html/achievements/the_biologist.png b/htdocs/images/achievements/the_biologist.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/the_biologist.png rename to htdocs/images/achievements/the_biologist.png diff --git a/courses.dist/modelCourse/html/achievements/the_fundamentals.png b/htdocs/images/achievements/the_fundamentals.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/the_fundamentals.png rename to htdocs/images/achievements/the_fundamentals.png diff --git a/courses.dist/modelCourse/html/achievements/the_lhopital.png b/htdocs/images/achievements/the_lhopital.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/the_lhopital.png rename to htdocs/images/achievements/the_lhopital.png diff --git a/courses.dist/modelCourse/html/achievements/third_time.png b/htdocs/images/achievements/third_time.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/third_time.png rename to htdocs/images/achievements/third_time.png diff --git a/courses.dist/modelCourse/html/achievements/three_in_a_row.png b/htdocs/images/achievements/three_in_a_row.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/three_in_a_row.png rename to htdocs/images/achievements/three_in_a_row.png diff --git a/courses.dist/modelCourse/html/achievements/to_infinity.png b/htdocs/images/achievements/to_infinity.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/to_infinity.png rename to htdocs/images/achievements/to_infinity.png diff --git a/courses.dist/modelCourse/html/achievements/trig_ninja.png b/htdocs/images/achievements/trig_ninja.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/trig_ninja.png rename to htdocs/images/achievements/trig_ninja.png diff --git a/courses.dist/modelCourse/html/achievements/triple_integral.png b/htdocs/images/achievements/triple_integral.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/triple_integral.png rename to htdocs/images/achievements/triple_integral.png diff --git a/courses.dist/modelCourse/html/achievements/vector_fields.png b/htdocs/images/achievements/vector_fields.png similarity index 100% rename from courses.dist/modelCourse/html/achievements/vector_fields.png rename to htdocs/images/achievements/vector_fields.png diff --git a/htdocs/js/AchievementEditor/achievementeditor.js b/htdocs/js/AchievementEditor/achievementeditor.js new file mode 100644 index 0000000000..70ef31da3a --- /dev/null +++ b/htdocs/js/AchievementEditor/achievementeditor.js @@ -0,0 +1,87 @@ +(() => { + // Action form validation. + // Store event listeners so they can be removed. + const event_listeners = {}; + + const show_errors = (ids, elements) => { + for (const id of ids) elements.push(document.getElementById(id)); + for (const element of elements) { + if (element?.id.endsWith('_err_msg')) { + element.classList.remove('d-none'); + } else if (element) { + element.classList.add('is-invalid'); + if (!(element.id in event_listeners)) { + event_listeners[element.id] = hide_errors([], elements); + element.addEventListener('change', event_listeners[element.id]); + } + } + } + }; + + const hide_errors = (ids, elements) => { + return () => { + for (const id of ids) elements.push(document.getElementById(id)); + for (const element of elements) { + if (element?.id.endsWith('_err_msg')) { + element.classList.add('d-none'); + } else if (element) { + element.classList.remove('is-invalid'); + if (element.id in event_listeners) { + element.removeEventListener('change', event_listeners[element.id]); + delete event_listeners[element.id]; + } + } + } + }; + }; + + document.getElementById('editor')?.addEventListener('submit', (e) => { + const action = document.getElementById('current_action')?.value || ''; + if (action === 'save_as') { + const target_file_input = document.getElementById('action.save_as.target_file_id'); + const filename = (target_file_input?.value || '') + (target_file_input?.value.endsWith('.at') ? '' : '.at'); + if (filename.trim() === '' || filename.trim() == '.at') { + e.preventDefault(); + e.stopPropagation(); + show_errors(['blank_filename_err_msg'], [target_file_input]); + } else if (filename == target_file_input.dataset.originalName) { + e.preventDefault(); + e.stopPropagation(); + show_errors(['change_filename_err_msg'], [target_file_input]); + } else if (filename.includes('/')) { + e.preventDefault(); + e.stopPropagation(); + show_errors(['invalid_filename_err_msg'], [target_file_input]); + } else { + const selectedOption = + document.querySelector('input[name="action.save_as.saveMode"]:checked')?.value || ''; + if (selectedOption === 'use_in_new') { + const newFilename = document.getElementById('action.save_as.new_id'); + if (newFilename?.value.trim() === '') { + e.preventDefault(); + e.stopPropagation(); + show_errors(['blank_id_err_msg'], [newFilename]); + } + } + } + } else if (action === 'revert') { + const delete_confirm = document.getElementById('delete_select'); + if (delete_confirm.value != 'yes') { + e.preventDefault(); + e.stopPropagation(); + show_errors(['delete_confirm_err_msg'], [delete_confirm]); + } + } + }); + + // Remove all error messages when changing tabs. + for (const tab of document.querySelectorAll('a[data-bs-toggle="tab"]')) { + tab.addEventListener('shown.bs.tab', () => { + if (Object.keys(event_listeners) != 0) + hide_errors( + [], + document.getElementById('editor')?.querySelectorAll('div[id$=_err_msg], .is-invalid') + )(); + }); + } +})(); diff --git a/htdocs/js/AchievementList/achievementlist.js b/htdocs/js/AchievementList/achievementlist.js index 08406848b1..ae747bbc96 100644 --- a/htdocs/js/AchievementList/achievementlist.js +++ b/htdocs/js/AchievementList/achievementlist.js @@ -108,6 +108,38 @@ e.stopPropagation(); show_errors(['delete_confirm_err_msg'], [delete_confirm]); } + } else if (action === 'save_edit') { + const invalid_inputs = Array.from( + document.querySelectorAll('input[name$=".test"], input[name$=".icon"]') + ).filter((input) => input.value.includes('/')); + if (invalid_inputs.length) { + e.preventDefault(); + e.stopPropagation(); + const err_msg = document.getElementById('save_edit_filename_err_msg'); + err_msg?.classList.remove('d-none'); + for (const input of invalid_inputs) { + input.classList.add('is-invalid'); + if (!(input.id in event_listeners)) { + event_listeners[input.id] = () => { + // Only clear this field's own invalid marking, and only once it is valid. + if (input.value.includes('/')) return; + input.classList.remove('is-invalid'); + input.removeEventListener('change', event_listeners[input.id]); + delete event_listeners[input.id]; + + // Hide the shared error message once no fields remain invalid. + if ( + !document.querySelector( + 'input[name$=".test"].is-invalid, input[name$=".icon"].is-invalid' + ) + ) { + err_msg?.classList.add('d-none'); + } + }; + input.addEventListener('change', event_listeners[input.id]); + } + } + } } }); diff --git a/htdocs/js/AchievementNotificationEditor/achievementnotificationeditor.js b/htdocs/js/AchievementNotificationEditor/achievementnotificationeditor.js new file mode 100644 index 0000000000..5174a519e2 --- /dev/null +++ b/htdocs/js/AchievementNotificationEditor/achievementnotificationeditor.js @@ -0,0 +1,69 @@ +(() => { + // Action form validation. + // Store event listeners so they can be removed. + const event_listeners = {}; + + const show_errors = (ids, elements) => { + for (const id of ids) elements.push(document.getElementById(id)); + for (const element of elements) { + if (element?.id.endsWith('_err_msg')) { + element.classList.remove('d-none'); + } else if (element) { + element.classList.add('is-invalid'); + if (!(element.id in event_listeners)) { + event_listeners[element.id] = hide_errors([], elements); + element.addEventListener('change', event_listeners[element.id]); + } + } + } + }; + + const hide_errors = (ids, elements) => { + return () => { + for (const id of ids) elements.push(document.getElementById(id)); + for (const element of elements) { + if (element?.id.endsWith('_err_msg')) { + element.classList.add('d-none'); + } else if (element) { + element.classList.remove('is-invalid'); + if (element.id in event_listeners) { + element.removeEventListener('change', event_listeners[element.id]); + delete event_listeners[element.id]; + } + } + } + }; + }; + + document.getElementById('editor')?.addEventListener('submit', (e) => { + const action = document.getElementById('current_action')?.value || ''; + if (action === 'save_as') { + const target_file_input = document.getElementById('action.save_as.target_file_id'); + const filename = target_file_input?.value || ''; + if (filename.trim() === '') { + e.preventDefault(); + e.stopPropagation(); + show_errors(['blank_filename_err_msg'], [target_file_input]); + } else if (filename == target_file_input.dataset.originalName) { + e.preventDefault(); + e.stopPropagation(); + show_errors(['change_filename_err_msg'], [target_file_input]); + } else if (filename.includes('/')) { + e.preventDefault(); + e.stopPropagation(); + show_errors(['invalid_filename_err_msg'], [target_file_input]); + } + } + }); + + // Remove all error messages when changing tabs. + for (const tab of document.querySelectorAll('a[data-bs-toggle="tab"]')) { + tab.addEventListener('shown.bs.tab', () => { + if (Object.keys(event_listeners).length) + hide_errors( + [], + document.getElementById('editor')?.querySelectorAll('div[id$=_err_msg], .is-invalid') + )(); + }); + } +})(); diff --git a/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm b/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm index c2d45f6618..338b88f2cb 100644 --- a/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm +++ b/lib/Mojolicious/WeBWorK/Tasks/AchievementNotification.pm @@ -52,6 +52,10 @@ sub send_achievement_notification ($job, $ce, $db, $mail_data) { die "User $mail_data->{recipient} does not have an email address -- skipping\n" unless ($user_record->email_address =~ /\S/); + my $email_template = $mail_data->{achievement}{email_template}; + die "Achievement notification template file \"$email_template\" contains a slash and cannot be used.\n" + if $email_template =~ /\//; + my $compartment = WeBWorK::WWSafe->new; $compartment->share_from('main', [qw(%Encode:: %Mojo::Base:: %Mojo::Exception:: %Mojo::Template:: %WeBWorK::SafeTemplate::)]); @@ -74,9 +78,7 @@ sub send_achievement_notification ($job, $ce, $db, $mail_data) { pointsEarned => $mail_data->{pointsEarned} }; - our $template = - Mojo::File->new("$ce->{courseDirs}{achievement_notifications}/$mail_data->{achievement}{email_template}") - ->slurp; + our $template = Mojo::File->new("$ce->{courseDirs}{achievement_notifications}/$email_template")->slurp; $compartment->share(qw($template $template_vars)); my $body = $compartment->reval( diff --git a/lib/WeBWorK/AchievementEvaluator.pm b/lib/WeBWorK/AchievementEvaluator.pm index bf4d0cf9de..0389cf32ce 100644 --- a/lib/WeBWorK/AchievementEvaluator.pm +++ b/lib/WeBWorK/AchievementEvaluator.pm @@ -195,18 +195,31 @@ sub checkForAchievements ($problem_in, $c, %options) { $counter = $userAchievement->counter; $maxCounter = $achievement->max_counter; + # Determine location of achievement evaluator. + my $sourceFilePath; + my $sourceFile = $achievement->test; + if (!$sourceFile) { + warn qq{No achievement evaluator provided for "$achievement_id".}; + next; + } elsif ($sourceFile =~ /\//) { + warn qq{Achievement evaluator "$sourceFile" for "$achievement_id" contains a slash.}; + next; + } elsif (-f "$ce->{courseDirs}{achievements}/$sourceFile") { + $sourceFilePath = "$ce->{courseDirs}{achievements}/$sourceFile"; + } elsif (-f "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile") { + $sourceFilePath = "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile"; + } else { + warn qq{Could not find achievement evaluator file "$sourceFile" for "$achievement_id".}; + next; + } + # Check the achievement using Safe. - my $sourceFilePath = $ce->{courseDirs}{achievements} . '/' . $achievement->test; - if (-e $sourceFilePath) { + { local $/ = undef; open(my $SOURCE, '<', $sourceFilePath); $source = <$SOURCE>; close($SOURCE); - } else { - warn('Couldnt find achievement evaluator $sourceFilePath'); - next; } - my $earned = $compartment->reval($preamble . "\n" . $source); warn "There were errors in achievement $achievement_id\n" . $@ if $@; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm index 149282e78e..fc6e5cd7f9 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementEditor.pm @@ -11,12 +11,13 @@ use HTML::Entities; use File::Copy; use WeBWorK::Utils qw(fix_newlines not_blank x); -use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); +use WeBWorK::Utils::Files qw(readFile); -use constant ACTION_FORMS => [qw(save save_as)]; +use constant ACTION_FORMS => [qw(save save_as revert)]; use constant ACTION_FORM_TITLES => { save => x('Save'), save_as => x('Save As'), + revert => x('Revert') }; use constant DEFAULT_ICON => 'defaulticon.png'; @@ -31,23 +32,36 @@ sub pre_header_initialize ($c) { $c->stash->{actionFormTitles} = ACTION_FORM_TITLES(); $c->stash->{achievementContents} = ''; - # Check permissions - return unless ($authz->hasPermissions($user, 'edit_achievements')); + return unless $authz->hasPermissions($user, 'edit_achievements'); - # Get the achievement my $Achievement = $c->db->getAchievement($c->stash('achievementID')); + unless ($Achievement) { + $c->addbadmessage($c->maketext('Achievement "[_1]" not found!', $c->stash('achievementID'))); + return; + } - if (!$Achievement) { - $c->addbadmessage($c->maketext("Achievement [_1] not found!", $c->stash('achievementID'))); + my $sourceFile = $Achievement->test; + if ($sourceFile =~ /\//) { + $c->addbadmessage($c->maketext( + 'Achievement evaluator "[_1]" file contains a invalid character "/", and cannot be edited.', + $sourceFile + )); + return; + } + if (-f "$ce->{courseDirs}{achievements}/$sourceFile") { + $c->{sourceFilePath} = "$ce->{courseDirs}{achievements}/$sourceFile"; + } elsif (-f "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile") { + $c->{sourceFilePath} = "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile"; + $c->{sourceFileIsProtected} = 1; + } else { + $c->addbadmessage($c->maketext('The achievement evaluator "[_1]" cannot be found.', $sourceFile)); return; } - $c->{achievement} = $Achievement; - $c->{sourceFilePath} = $ce->{courseDirs}{achievements} . '/' . $Achievement->test; + $c->{achievement} = $Achievement; + $c->{sourceFile} = $sourceFile; my $actionID = $c->param('action'); - - # Perform a save or save_as action if ($actionID) { unless (grep { $_ eq $actionID } @{ ACTION_FORMS() }) { die "Action $actionID not found"; @@ -65,23 +79,10 @@ sub initialize ($c) { my $user = $c->param('user'); my $sourceFilePath = $c->{sourceFilePath}; - return unless ($authz->hasPermissions($user, 'edit_achievements')); - - $c->addmessage($c->authen->flash('status_message') || ''); # Record status messages carried over from a redirect - - # Check source file path - if (not(-e $sourceFilePath)) { - $c->addbadmessage('The file "' . $c->shortPath($sourceFilePath) . '" cannot be found.'); - return; - } + return unless $authz->hasPermissions($user, 'edit_achievements') && $c->{achievement} && $sourceFilePath; # Find the text for the achievement. - unless ($c->stash->{achievementContents} =~ /\S/) { - unless (path_is_subdir($sourceFilePath, $c->ce->{courseDirs}{achievements}, 1)) { - $c->addbadmessage('Path is Unsafe!'); - return; - } - + unless (not_blank($c->stash->{achievementContents})) { eval { $c->stash->{achievementContents} = readFile($sourceFilePath) }; $c->stash->{achievementContents} = $@ if $@; } @@ -89,187 +90,130 @@ sub initialize ($c) { return; } -# Convert long paths to [ACHEVDIR] +# Append [ACHEVDIR] to filename. sub shortPath ($c, $file) { - my $ache = $c->ce->{courseDirs}{achievements}; - $file =~ s|^$ache|[ACHEVDIR]|; - return $file; + return "[ACHEVDIR]/$file"; } -sub getRelativeSourceFilePath ($c, $sourceFilePath) { - my $achievementsDir = $c->ce->{courseDirs}{achievements}; - $sourceFilePath =~ s|^${achievementsDir}/*||; # remove templates path and any slashes that follow - return $sourceFilePath; -} - -# saveFileChanges does most of the work. It is a separate method so that it can -# be called from either pre_header_initialize or initialize, depending on -# whether a redirect is needed or not. -sub saveFileChanges ($c, $outputFilePath, $achievementContents = undef) { +# saveFileChanges does the work of saving the file for both the save_handler +# and the save_as_handler. Be sure that $outputFile is validated before calling +# this method (defined, not blank, and does not contain a slash). +sub saveFileChanges ($c, $outputFile) { my $ce = $c->ce; - if (defined($achievementContents) and ref($achievementContents)) { - $achievementContents = ${$achievementContents}; - } elsif (!not_blank($achievementContents)) { # if the AchievementContents is undefined or empty - $achievementContents = $c->stash->{achievementContents}; - } - - unless (not_blank($outputFilePath)) { - $c->addbadmessage($c->maketext('You must specify an file name in order to save a new file.')); - return ''; - } - my $do_not_save = 0; # flag to prevent saving of file - my $editErrors = ''; - - # write changes to the appropriate files - # FIXME make sure that the permissions are set correctly!!! - # Make sure that the warning is being transmitted properly. - - my $writeFileErrors; - if (not_blank($outputFilePath)) { # save file + # This shouldn't be needed, but one last check for safety. + return 0 if !$outputFile || $outputFile =~ /\//; - # make sure any missing directories are created - surePathToFile($ce->{courseDirs}{achievements}, $outputFilePath); - die 'outputFilePath is unsafe!' - unless path_is_subdir($outputFilePath, $ce->{courseDirs}{achievements}, 1); - - eval { - open my $OUTPUTFILE, '>', $outputFilePath or die "Failed to open $outputFilePath"; - print $OUTPUTFILE $achievementContents; - close $OUTPUTFILE; - }; - - $writeFileErrors = $@ if $@; - } - - # Catch errors in saving files, - $c->{saveError} = $do_not_save; # Don't do redirects if the file was not saved. - # Don't unlink files or send success messages + my $outputFilePath = "$ce->{courseDirs}{achievements}/$outputFile"; + eval { + open my $OUTPUTFILE, '>', $outputFilePath or die "Failed to open $outputFilePath"; + print $OUTPUTFILE $c->stash->{achievementContents}; + close $OUTPUTFILE; + }; + my $writeFileErrors = $@; if ($writeFileErrors) { - # Get the current directory from the outputFilePath - $outputFilePath =~ m|^(/.*?/)[^/]+$|; - my $currentDirectory = $1; - my $errorMessage; - # Check why we failed to give better error messages - if (not -w $ce->{courseDirs}{achievements}) { + # Check why we failed to give better error messages. + if (not -d $ce->{courseDirs}{achievements}) { + $errorMessage = $c->maketext('Course achievements directory does not exist. File not saved.'); + } elsif (not -w $ce->{courseDirs}{achievements}) { $errorMessage = $c->maketext( 'Write permissions have not been enabled in the templates directory. No changes can be made.'); - } elsif (not -w $currentDirectory) { - $errorMessage = $c->maketext( - 'Write permissions have not been enabled in "[_1]". ' - . 'Changes must be saved to a different directory for viewing.', - $c->shortPath($currentDirectory) - ); - } elsif (-e $outputFilePath and not -w $outputFilePath) { + } elsif (-f $outputFilePath and not -w $outputFilePath) { $errorMessage = $c->maketext( 'Write permissions have not been enabled for "[_1]". ' . 'Changes must be saved to another file for viewing.', - $c->shortPath($outputFilePath) + $c->shortPath($outputFile) ); } else { $errorMessage = - $c->maketext('Unable to write to "[_1]": [_2]', $c->shortPath($outputFilePath), $writeFileErrors); + $c->maketext('Unable to write to "[_1]": [_2]', $c->shortPath($outputFile), $writeFileErrors); } - - $c->{failure} = 1; $c->addbadmessage($errorMessage); + } else { + $c->addgoodmessage($c->maketext('Saved to file "[_1]".', $c->shortPath($outputFile))); } - if (!$writeFileErrors && !$do_not_save && defined $outputFilePath && !$c->{failure}) { - $c->addgoodmessage($c->maketext('Saved to file "[_1]"', $c->shortPath($outputFilePath))); - } - - return; + return $writeFileErrors ? 0 : 1; } +# This is only called after $c->{sourceFile} has been validated and no additional +# validation is needed. sub save_handler ($c) { - my $courseName = $c->stash('courseID'); - my $achievementName = $c->stash('achievementID'); + my $ce = $c->ce; - # Grab the achievementContents from the form in order to save it to the source path $c->stash->{achievementContents} = fix_newlines($c->param('achievementContents')); - - # Construct the output file path - $c->saveFileChanges($c->{sourceFilePath}); - + # Update source file if file is successfully saved. + if ($c->saveFileChanges($c->{sourceFile})) { + $c->{sourceFilePath} = "$ce->{courseDirs}{achievements}/$c->{sourceFile}"; + $c->{sourceFileIsProtected} = 0; + } return; } sub save_as_handler ($c) { my $db = $c->db; - $c->{status_message} = $c->c; ## DPVC -- remove bogus old messages + my $ce = $c->ce; + my $courseName = $c->stash('courseID'); my $achievementName = $c->stash('achievementID'); my $effectiveUserName = $c->param('effectiveUser'); - my $do_not_save = 0; my $saveMode = $c->param('action.save_as.saveMode') || 'no_save_mode_selected'; my $new_file_name = $c->param('action.save_as.target_file') || ''; - my $sourceFilePath = $c->param('action.save_as.source_file') || ''; my $targetAchievementID = $c->param('action.save_as.id') || ''; - $c->{sourceFilePath} = $sourceFilePath; # store for use in saveFileChanges - $new_file_name =~ s/^\s*//; #remove initial and final white space + $c->stash->{achievementContents} = fix_newlines($c->param('achievementContents')); + + $new_file_name =~ s/^\s*//; # Remove initial and final white space. $new_file_name =~ s/\s*$//; - if ($new_file_name !~ /\S/) { # need a non-blank file name - # setting $c->{failure} stops saving and any redirects - $do_not_save = 1; + if ($new_file_name !~ /\S/) { $c->addbadmessage($c->maketext('Please specify a file to save to.')); + return; + } elsif ($new_file_name =~ /\//) { + $c->addbadmessage($c->maketext('Achievement files names cannot contain a slash.')); + return; } - # Grab the achievementContents from the form in order to save it to a new permanent file - $c->stash->{achievementContents} = fix_newlines($c->param('achievementContents')); - warn 'achievement contents is empty' unless $c->stash->{achievementContents}; - - # Rescue the user in case they forgot to end the file name with .at - $new_file_name =~ s/\.at$//; # remove it if it is there - $new_file_name .= '.at'; # put it there + # Rescue the user in case they forgot to end the file name with .at. + $new_file_name =~ s/\.at$//; + $new_file_name .= '.at'; - # Construct the output file path - my $outputFilePath = $c->ce->{courseDirs}{achievements} . '/' . $new_file_name; - if (defined $outputFilePath and -e $outputFilePath) { - # setting $do_not_save stops saving and any redirects - $do_not_save = 1; + my $outputFilePath = "$ce->{courseDirs}{achievements}/$new_file_name"; + if (-f $outputFilePath) { $c->addbadmessage($c->maketext( 'File "[_1]" exists. File not saved. No changes have been made.', - $c->shortPath($outputFilePath) + $c->shortPath($new_file_name) )); + return; } elsif ($saveMode eq 'use_in_new' && !$targetAchievementID) { $c->addbadmessage( $c->maketext('No new Achievement ID specified. No new achievement created. File not saved.')); - $do_not_save = 1; - + return; } elsif ($saveMode eq 'use_in_new' && $db->existsAchievement($targetAchievementID)) { $c->addbadmessage($c->maketext('Achievement ID exists! No new achievement created. File not saved.')); - $do_not_save = 1; - } else { - $c->{editFilePath} = $outputFilePath; - $c->{inputFilePath} = ''; + return; + } + return unless $c->saveFileChanges($new_file_name); + unless ($saveMode eq 'dont_use') { + $c->{sourceFile} = $new_file_name; + $c->{sourceFilePath} = $outputFilePath; } - - return '' if $do_not_save; - - #Save changes - $c->saveFileChanges($outputFilePath); if ($saveMode eq 'use_in_current' and -r $outputFilePath) { - # Modify evaluator path in current achievement - my $achievement = $c->db->getAchievement($achievementName); + my $achievement = $c->{achievement}; $achievement->test($new_file_name); if ($c->db->putAchievement($achievement)) { $c->addgoodmessage($c->maketext( 'The evaluator for [_1] has been renamed to "[_2]".', $achievementName, - $c->shortPath($outputFilePath) + $c->shortPath($new_file_name) )); } else { $c->addbadmessage( $c->maketext('Unable to change the evaluator for set [_1]. Unknown error.', $achievementName)); } - } elsif ($saveMode eq 'use_in_new') { - # Create a new achievement to use the evaluator in my $achievement = $c->db->newAchievement(); $achievement->achievement_id($targetAchievementID); $achievement->test($new_file_name); @@ -278,27 +222,61 @@ sub save_as_handler ($c) { $c->db->addAchievement($achievement); $c->addgoodmessage($c->maketext( 'Achievement [_1] created with evaluator "[_2]".', $targetAchievementID, - $c->shortPath($outputFilePath) + $c->shortPath($new_file_name) )); - + $c->{achievement} = $achievement; } elsif ($saveMode eq 'dont_use') { - # Don't change any achievements - just report - $c->addgoodmessage($c->maketext('A new file has been created at "[_1]"', $c->shortPath($outputFilePath))); + # Don't change any achievements - just report. + $c->addgoodmessage($c->maketext( + 'A new file has been created at "[_1]". Editing original achievement evaluator "[_2]".', + $c->shortPath($new_file_name), + $c->{sourceFile} + )); + # FIXME: Currently editor cannot edit a file not associated with an achievement. + # Update saved data so the editor correctly shows/edits the original achievement evaluator. + $c->stash->{achievementContents} = ''; } else { $c->addbadmessage($c->maketext(q{Don't recognize saveMode: |[_1]|. Unknown error.}, $saveMode)); } - # Set up redirect - # The redirect gives the server time to detect that the new file exists. - $c->authen->flash(status_message => $c->{status_message}->join('')); - $c->reply_with_redirect($c->systemLink( - $c->url_for( - 'instructor_achievement_editor', - achievementID => $saveMode eq 'use_in_new' ? $targetAchievementID : $achievementName - ), - params => { sourceFilePath => $c->getRelativeSourceFilePath($outputFilePath) } + return; +} + +sub revert_handler ($c) { + my $ce = $c->ce; + my $sourceFile = $c->{sourceFile}; + my $confirmDelete = $c->param('action.delete.confirm') // ''; + my $deletePath = "$ce->{courseDirs}{achievements}/$sourceFile"; + my $systemPath = "$ce->{webworkDirs}{achievementEvaluators}/$sourceFile"; + + # Use form contents in case there is a validation error. + $c->stash->{achievementContents} = fix_newlines($c->param('achievementContents')); + + unless ($confirmDelete eq 'yes') { + $c->addbadmessage($c->maketext('Missing delete confirmation of course override. Not deleting.')); + return; + } + unless (-f $systemPath) { + $c->addbadmessage( + $c->maketext('Achievement evaluator "[_1]" is not a course override. Not deleting.', $sourceFile)); + return; + } + unless (-f $deletePath) { + $c->addbadmessage($c->maketext( + 'Achievement evaluator course override "[_1]" does not exist. Not deleting.', + $c->shortPath($sourceFile) + )); + return; + } + unlink($deletePath); + $c->addgoodmessage($c->maketext( + 'Course override "[_1]" deleted. Reverted to using system achievement.', + $c->shortPath($sourceFile) )); + $c->{sourceFilePath} = $systemPath; + $c->{sourceFileIsProtected} = 1; + $c->stash->{achievementContents} = ''; # Force loading original system achievement contents. return; } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm index b8c38350d4..c78a3b6675 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementList.pm @@ -56,6 +56,13 @@ use constant FORM_TITLES => { cancel_export => x('Cancel Export') }; +# These fields store the filename of an achievement asset, which cannot contain a slash. +use constant FILENAME_FIELDS => { + test => 'achievement evaluator', + icon => 'icon', + email_template => 'email template' +}; + sub initialize ($c) { my $db = $c->db; my $ce = $c->ce; @@ -399,18 +406,29 @@ sub import_handler ($c) { my $ce = $c->ce; my $db = $c->db; - my $fileName = $c->param('action.import.source'); - my $assign = $c->param('action.import.assign'); - my @users = $db->listUsers; - my %allAchievementIDs = map { $_ => 1 } @{ $c->{allAchievementIDs} }; - my %visibleAchievementIDs = map { $_ => 1 } @{ $c->{visibleAchievementIDs} }; - my $filePath = $ce->{courseDirs}{achievements} . '/' . $fileName; + my $fileName = $c->param('action.import.source') // ''; + + return (0, $c->maketext('File "[_1]" contains a slash and is invalid.', $fileName)) if $fileName =~ /\//; + + my $filePath; + if (-f "$ce->{courseDirs}{achievements}/$fileName") { + $filePath = "$ce->{courseDirs}{achievements}/$fileName"; + } elsif (-f "$ce->{webworkDirs}{achievementEvaluators}/$fileName") { + $filePath = "$ce->{webworkDirs}{achievementEvaluators}/$fileName"; + } else { + return (0, $c->maketext('File "[_1]" does not exist.', $fileName)); + } my @userAchievementRecordsToAdd; # Open file name my $fh = Mojo::File->new($filePath)->open('<:encoding(UTF-8)') - or return (0, $c->maketext("Failed to open [_1]", $filePath)); + or return (0, $c->maketext('Failed to open "[_1]".', $filePath)); + + my @users = $db->listUsers; + my $assign = $c->param('action.import.assign'); + my %allAchievementIDs = map { $_ => 1 } @{ $c->{allAchievementIDs} }; + my %visibleAchievementIDs = map { $_ => 1 } @{ $c->{visibleAchievementIDs} }; # Read in lines from file my $count = 0; @@ -436,9 +454,9 @@ sub import_handler ($c) { $achievement->description($$data[5]); $achievement->points($$data[6]); $achievement->max_counter($$data[7]); - $achievement->test($$data[8]); - $achievement->icon($$data[9]); - $achievement->email_template($$data[10] // ''); + $achievement->test($$data[8] =~ /\// ? '' : $$data[8]); # Don't allow / in paths. + $achievement->icon($$data[9] =~ /\// ? '' : $$data[9]); + $achievement->email_template(!$$data[10] || $$data[10] =~ /\// ? '' : $$data[10]); $achievement->enabled($assign eq "all" ? 1 : 0); @@ -566,21 +584,33 @@ sub save_edit_handler ($c) { for my $achievementID (@selectedAchievementIDs) { my $Achievement = $db->getAchievement($achievementID); + unless ($Achievement) { + $c->addbadmessage($c->maketext('No record for achievement "[_1]" found. Skipping.', $achievementID)); + next; + } - # FIXME: we may not want to die on bad achievements, they're not as bad as bad users - die "record for achievement $achievementID not found" unless $Achievement; - - # Update fields + # Update fields. for my $field ($Achievement->NONKEYFIELDS()) { my $param = "achievement.${achievementID}.${field}"; if ($field eq 'assignment_type') { my @types = $c->param($param); $Achievement->assignment_type(join(',', @types)); - } else { - - if (defined $c->param($param)) { - $Achievement->$field($c->param($param)); + } elsif (defined $c->param($param)) { + my $value = $c->param($param); + + # Filename fields test, icon, and email_template cannot contain a slash. + if (FILENAME_FIELDS->{$field} && $value =~ /\//) { + # Skip filename error message for previous saved values. + $c->addbadmessage($c->maketext( + 'Filename "[_1]" for "[_2]" in achievement "[_3]" contains a slash, "/", and is invalid. ' + . 'Not updating.', + $value, FILENAME_FIELDS->{$field}, + $achievementID + )) + unless $value eq $Achievement->$field; + } else { + $Achievement->$field($value); } } } @@ -595,7 +625,18 @@ sub save_edit_handler ($c) { # Get list of files that can be imported. sub getAxpList ($c) { - return @{ Mojo::File->new($c->ce->{courseDirs}{achievements})->list->grep(qr/.*\.axp/)->map('basename') }; + my %fileList; + + # Find all system .axp files. + for (@{ Mojo::File->new($c->ce->{webworkDirs}{achievementEvaluators})->list->grep(qr/.*\.axp/)->map('basename') }) { + $fileList{$_} = 1; + } + # Find all course .axp files, overriding any system files. + for (@{ Mojo::File->new($c->ce->{courseDirs}{achievements})->list->grep(qr/.*\.axp/)->map('basename') }) { + $fileList{$_} = 1; + } + + return (sort keys %fileList); } 1; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm b/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm index fa012ee23e..7c47ecfadb 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/AchievementNotificationEditor.pm @@ -8,7 +8,7 @@ WeBWorK::ContentGenerator::Instructor::AchievementNotificationEditor - edit the =cut use WeBWorK::Utils qw(fix_newlines not_blank x); -use WeBWorK::Utils::Files qw(surePathToFile readFile path_is_subdir); +use WeBWorK::Utils::Files qw(readFile); use constant ACTION_FORMS => [qw(save save_as existing disable)]; use constant ACTION_FORM_TITLES => { @@ -19,32 +19,33 @@ use constant ACTION_FORM_TITLES => { }; sub pre_header_initialize ($c) { - my $courseID = $c->stash('courseID'); - my $achievementID = $c->stash('achievementID'); - # Make sure that are defined for the templates. $c->stash->{formsToShow} = ACTION_FORMS(); $c->stash->{actionFormTitles} = ACTION_FORM_TITLES(); $c->stash->{achievementNotification} = ''; - # Check permissions - return unless ($c->authz->hasPermissions($c->param('user'), 'edit_achievements')); + return unless $c->authz->hasPermissions($c->param('user'), 'edit_achievements'); - # Get the achievement - $c->{achievement} = $c->db->getAchievement($achievementID); + my $achievement = $c->db->getAchievement($c->stash('achievementID')); + unless ($achievement) { + $c->addbadmessage($c->maketext('Achievement "[_1]" not found!', $c->stash('achievementID'))); + return; + } - if (!$c->{achievement}) { - $c->addbadmessage($c->maketext('Achievement [_1] not found!', $achievementID)); + my $sourceFile = $achievement->email_template || 'default.txt.epl'; + if ($sourceFile =~ /\//) { + $c->addbadmessage($c->maketext( + 'Achievement notification template "[_1]" filename contains a slash, "/", and cannot be edited.', + $sourceFile + )); return; } - $c->{sourceFilePath} = - $c->ce->{courseDirs}{achievement_notifications} . '/' - . ($c->{achievement}->email_template || 'default.txt.epl'); + $c->{achievement} = $achievement; + $c->{sourceFile} = $sourceFile; + $c->{sourceFilePath} = $c->ce->{courseDirs}{achievement_notifications} . '/' . $sourceFile; my $actionID = $c->param('action'); - - # Perform a save or save_as action if ($actionID) { die "Action $actionID not found" unless (grep { $_ eq $actionID } @{ ACTION_FORMS() }); @@ -60,197 +61,163 @@ sub initialize ($c) { my $user = $c->param('user'); my $sourceFilePath = $c->{sourceFilePath}; - return unless ($authz->hasPermissions($user, 'edit_achievements')); + return unless $authz->hasPermissions($user, 'edit_achievements') && $c->{achievement} && $sourceFilePath; - $c->addmessage($c->authen->flash('status_message') || ''); # Record status messages carried over from a redirect - - # Check source file path - if (!-e $sourceFilePath) { - $c->addbadmessage($c->maketext('The file [_1] cannot be found.', $sourceFilePath)); + # Check source file path. + unless (-f $sourceFilePath) { + $c->addbadmessage($c->maketext( + 'The achievement notification template file "[_1]" cannot be found.', + $c->shortPath($c->{sourceFile}) + )); return; } # Find the text for the achievement. - unless ($c->stash('achievementNotification') =~ /\S/) { - unless (path_is_subdir($sourceFilePath, $c->ce->{courseDirs}{achievement_notifications}, 1)) { - $c->addbadmessage('Path is Unsafe!'); - return; - } - - eval { $c->stash->{achievementNotification} = readFile($sourceFilePath); }; + unless (not_blank($c->stash->{achievementNotification})) { + eval { $c->stash->{achievementNotification} = readFile($sourceFilePath) }; $c->stash->{achievementNotification} = $@ if $@; } return; } -# Convert long paths to [ACHEVNOTIFYDIR] +# Append [ACHEVNOTIFYDIR] to filename. sub shortPath ($c, $file) { - my $achievementsDir = $c->ce->{courseDirs}{achievement_notifications}; - return $file =~ s|^$achievementsDir|[ACHEVNOTIFYDIR]|r; -} - -sub getRelativeSourceFilePath ($c, $sourceFilePath) { - my $achievementsDir = $c->ce->{courseDirs}{achievement_notifications}; - return $sourceFilePath =~ s|^$achievementsDir/*||r; # remove templates path and any slashes that follow + return "[ACHEVNOTIFYDIR]/$file"; } -# saveFileChanges does most of the work. It is a separate method so that it can -# be called from either pre_header_initialize or initialize, depending on -# whether a redirect is needed or not. -sub saveFileChanges ($c, $outputFilePath) { +# saveFileChanges does the work of saving the file for both the save_handler +# and the save_as_handler. Be sure that $outputFile is validated before calling +# this method (defined, not blank, and does not contain a slash). +sub saveFileChanges ($c, $outputFile) { my $ce = $c->ce; - unless (not_blank($outputFilePath)) { - $c->addbadmessage($c->maketext('You must specify an file name in order to save a new file.')); - return; - } + # This shouldn't be needed, but one last check for safety. + return 0 if !$outputFile || $outputFile =~ /\//; - # make sure any missing directories are created - surePathToFile($ce->{courseDirs}{achievement_notifications}, $outputFilePath); - die 'outputFilePath is unsafe!' - unless path_is_subdir($outputFilePath, $ce->{courseDirs}{achievement_notifications}, 1); + my $outputFilePath = "$ce->{courseDirs}{achievement_notifications}/$outputFile"; eval { - # Write changes to the file. - open my $OUTPUTFILE, '>', $outputFilePath - or die "Failed to open $outputFilePath"; - print $OUTPUTFILE $c->stash('achievementNotification'); + open my $OUTPUTFILE, '>', $outputFilePath or die "Failed to open $outputFilePath"; + print $OUTPUTFILE $c->stash->{achievementNotification}; close $OUTPUTFILE; }; - my $writeFileErrors = $@; - # Catch errors in saving files, + my $writeFileErrors = $@; if ($writeFileErrors) { - # Get the current directory from the outputFilePath - $outputFilePath =~ m|^(/.*?/)[^/]+$|; - my $currentDirectory = $1; - my $errorMessage; - - # Check why we failed to give better error messages - if (!-w $ce->{courseDirs}{achievement_notifications}) { + # Check why we failed to give better error messages. + if (not -d $ce->{courseDirs}{achievement_notifications}) { + $errorMessage = $c->maketext('Course achievement notifications directory does not exist. File not saved.'); + } elsif (not -w $ce->{courseDirs}{achievement_notifications}) { $errorMessage = $c->maketext( 'Write permissions have not been enabled in the templates directory. No changes can be made.'); - } elsif (!-w $currentDirectory) { - $errorMessage = $c->maketext( - 'Write permissions have not been enabled in "[_1]". ' - . 'Changes must be saved to a different directory for viewing.', - $c->shortPath($currentDirectory) - ); - } elsif (-e $outputFilePath && !-w $outputFilePath) { + } elsif (-f $outputFilePath and not -w $outputFilePath) { $errorMessage = $c->maketext( 'Write permissions have not been enabled for "[_1]". ' . 'Changes must be saved to another file for viewing.', - $c->shortPath($outputFilePath) + $c->shortPath($outputFile) ); } else { $errorMessage = - $c->maketext('Unable to write to "[_1]": [_2]', $c->shortPath($outputFilePath), $writeFileErrors); + $c->maketext('Unable to write to "[_1]": [_2]', $c->shortPath($outputFile), $writeFileErrors); } - $c->addbadmessage($errorMessage); } else { - $c->addgoodmessage($c->maketext('Saved to file "[_1]"', $c->shortPath($outputFilePath))); + $c->addgoodmessage($c->maketext('Saved to file "[_1]".', $c->shortPath($outputFile))); } - return; + return $writeFileErrors ? 0 : 1; } +# This is only called after $c->{sourceFile} has been validated and no additional +# validation is needed. sub save_handler ($c) { - # Grab the achievementNotification from the form in order to save it to the source path $c->stash->{achievementNotification} = fix_newlines($c->param('achievementNotification')); - - # Construct the output file path - $c->saveFileChanges($c->{sourceFilePath}); + $c->saveFileChanges($c->{sourceFile}); return; } sub save_as_handler ($c) { - $c->{status_message} = $c->c; - my $courseName = $c->stash('courseID'); - my $achievementName = $c->stash('achievementID'); - my $effectiveUserName = $c->param('effectiveUser'); + my $ce = $c->ce; + my $achievementName = $c->stash('achievementID'); - my $new_file_name = $c->param('action.save_as.target_file') || ''; - my $targetAchievementID = $c->param('action.save_as.id') || ''; + my $new_file_name = $c->param('action.save_as.target_file') || ''; - $new_file_name =~ s/^\s*|\s*$//g; # remove initial and final white space - if ($new_file_name !~ /\S/) { # need a non-blank file name + $c->stash->{achievementNotification} = fix_newlines($c->param('achievementNotification')); + + $new_file_name =~ s/^\s*//; # Remove initial and final white space. + $new_file_name =~ s/\s*$//; + if ($new_file_name !~ /\S/) { $c->addbadmessage($c->maketext('Please specify a file to save to.')); return; + } elsif ($new_file_name =~ /\//) { + $c->addbadmessage($c->maketext('Achievement notification template filenames cannot contain a slash, "/".')); + return; } - # Grab the achievementNotification from the form in order to save it to a new permanent file - $c->stash->{achievementNotification} = fix_newlines($c->param('achievementNotification')); - $c->addbadmessage($c->maketext('Achievement notification contents is empty.')) - unless $c->stash->{achievementNotification}; - - # Rescue the user in case they forgot to end the file name with .txt.epl + # Rescue the user in case they forgot to end the filename with .txt.epl. $new_file_name =~ s/(\.html|\.txt)?(\.epl?)?$/.txt.epl/; - # Construct the output file path. - my $outputFilePath = $c->ce->{courseDirs}{achievement_notifications} . '/' . $new_file_name; - if (defined $outputFilePath && -e $outputFilePath) { + my $outputFilePath = "$ce->{courseDirs}{achievement_notifications}/$new_file_name"; + if (-e $outputFilePath) { $c->addbadmessage($c->maketext( 'File "[_1]" exists. File not saved. No changes have been made.', - $c->shortPath($outputFilePath) + $c->shortPath($new_file_name) )); return; - } else { - $c->{editFilePath} = $outputFilePath; - $c->{inputFilePath} = ''; } - $c->saveFileChanges($outputFilePath); + return unless $c->saveFileChanges($new_file_name); + + $c->{sourceFile} = $new_file_name; + $c->{sourceFilePath} = $outputFilePath; - # Modify achievement notification template path for the current achievement $c->{achievement}->email_template($new_file_name); if ($c->db->putAchievement($c->{achievement})) { $c->addgoodmessage($c->maketext( - 'The achievement notification template for [_1] has been renamed to "[_2]".', $achievementName, - $c->shortPath($outputFilePath) + 'The achievement notification template for "[_1]" has been renamed to "[_2]".', $achievementName, + $c->shortPath($new_file_name) )); } else { $c->addbadmessage($c->maketext( - 'Unable to change the achievement notification template for achievement [_1]. Unknown error.', + 'Unable to change the achievement notification template for achievement "[_1]". Unknown error.', $achievementName )); } - # A redirect is needed to ensure that all data and parameters for page display are updated correctly. - # FIXME: This could be done without a redirect if the data and parameters were updated here instead. - $c->authen->flash(status_message => $c->{status_message}->join('')); - $c->reply_with_redirect($c->systemLink( - $c->url_for('instructor_achievement_notification', achievementID => $achievementName))); return; } # use an existing template file sub existing_handler ($c) { + my $ce = $c->ce; my $achievementID = $c->stash('achievementID'); + my $sourceFile = $c->param('action.existing.target_file') || ''; - # Get the desired file name from form data. - my $sourceFile = $c->param('action.existing.target_file') || ''; + if ($sourceFile =~ /\//) { + $c->addbadmessage($c->maketext('Achievement notification template filenames cannot contain a slash, "/".')); + return; + } - if (-e $c->ce->{courseDirs}{achievement_notifications} . "/$sourceFile") { + if (-f "$ce->{courseDirs}{achievement_notifications}/$sourceFile") { # If it exists, update the achievement to use the existing email template. $c->{achievement}->email_template($sourceFile); if ($c->db->putAchievement($c->{achievement})) { + $c->{sourceFile} = $sourceFile; + $c->{sourceFilePath} = "$ce->{courseDirs}{achievement_notifications}/$sourceFile"; + $c->stash->{achievementNotification} = ''; # Force loading the newly selected template's contents. $c->addgoodmessage($c->maketext( - 'The notification for [_1] has been changed to "[_2]".', $achievementID, $sourceFile)); + 'The notification for "[_1]" has been changed to "[_2]".', + $achievementID, $sourceFile + )); } else { $c->addbadmessage($c->maketext( - 'Unable to change the notification for [_1]. Unknown error.', $achievementID)); + 'Unable to change the notification for "[_1]". Unknown error.', $achievementID)); } } else { - $c->addbadmessage($c->maketext('The file "[_1]" cannot be found.', $sourceFile)); + $c->addbadmessage($c->maketext('The file "[_1]" cannot be found.', $c->shortPath($sourceFile))); } - # A redirect is needed to ensure that all data and parameters for page display are updated correctly. - # FIXME: This could be done without a redirect if the data and parameters were updated here instead. - $c->authen->flash(status_message => $c->{status_message}->join('')); - $c->reply_with_redirect($c->systemLink( - $c->url_for('instructor_achievement_notification', achievementID => $achievementID))); return; } @@ -259,7 +226,7 @@ sub disable_handler ($c) { if ($c->db->putAchievement($c->{achievement})) { $c->addgoodmessage($c->maketext( - 'The achievement notification template for achievement [_1] has been disabled.', + 'The achievement notification template for "[_1]" has been disabled.', $c->stash('achievementID') )); @@ -268,7 +235,7 @@ sub disable_handler ($c) { $c->reply_with_redirect($c->systemLink($c->url_for('instructor_achievement_list'))); } else { $c->addbadmessage($c->maketext( - 'Unable to disable the achievement notification template for achievement [_1]. Unknown error.', + 'Unable to disable the achievement notification template for "[_1]". Unknown error.', $c->stash('achievementID') )); } diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index 391930c0af..7734c60562 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -32,6 +32,8 @@ our @EXPORT_OK = qw( generateURLs formatEmailSubject getAssetURL + getAchievementIconURL + isAchievementAssetOverridden points_stepsize round_nearest_stepsize x @@ -535,6 +537,28 @@ sub getAssetURL ($ce, $file, $isThemeFile = 0) { return "$ce->{webworkURLs}{htdocs}/$file"; } +sub getAchievementIconURL ($ce, $icon) { + return "$ce->{webworkURLs}{achievementIcons}/defaulticon.png" if !$icon || $icon =~ /\//; + + if (-f "$ce->{courseDirs}{achievements_html}/$icon") { + return "$ce->{courseURLs}{achievements}/$icon"; + } + if (-f "$ce->{webworkDirs}{achievementIcons}/$icon") { + return "$ce->{webworkURLs}{achievementIcons}/$icon"; + } + return "$ce->{webworkURLs}{achievementIcons}/defaulticon.png"; +} + +sub isAchievementAssetOverridden ($ce, $type, $asset) { + return 0 if !$asset || $asset =~ /\//; + return ($type eq 'icon' + && -f "$ce->{courseDirs}{achievements_html}/$asset" + && -f "$ce->{webworkDirs}{achievementIcons}/$asset") + || ($type eq 'test' + && -f "$ce->{courseDirs}{achievements}/$asset" + && -f "$ce->{webworkDirs}{achievementEvaluators}/$asset"); +} + sub points_stepsize ($points) { my $stepsize; if ($points == 1) { @@ -787,6 +811,24 @@ Returns the URL for the asset specified in C<$file>. If C<$isThemeFile> is true, then the asset will be assumed to be located in a theme directory. The parameter C<$ce> must be a valid C object. +=head2 getAchievementIconURL + +Usage: C + +Returns the URL path for an achievement icon for a given course environment. +This checks if the icon provided is a valid icon file (defined and doesn't contain +a slash), then checks if the icon file exists in the course templates directory, +and finally if the icon file exists in the WeBWorK images directory. It returns the +URL to the first valid file found, or the URL to the system default icon otherwise. + +=head2 isAchievementAssetOverridden + +Usage: C + +This tests if an achievement icon, C<$type='icon'>, or an achievement evaluator, +C<$type='test'>, file name C<$asset> is a system file being overridden by a course +file of the same name in the given course environment. + =head2 points_stepsize Usage: C diff --git a/templates/AchievementEvaluator/cheevoMessage.html.ep b/templates/AchievementEvaluator/cheevoMessage.html.ep index 5f795567a0..15f34882d1 100644 --- a/templates/AchievementEvaluator/cheevoMessage.html.ep +++ b/templates/AchievementEvaluator/cheevoMessage.html.ep @@ -1,10 +1,10 @@ +% use WeBWorK::Utils qw(getAchievementIconURL); +%