diff --git a/app.py b/app.py index 7ed863310b9..80720480b5c 100644 --- a/app.py +++ b/app.py @@ -85,6 +85,26 @@ # Use 5 minutes as a reasonable default for all files we load elsewise. app.config['SEND_FILE_MAX_AGE_DEFAULT'] = datetime.timedelta(minutes=5) +# We're adding a new URL rule for getting the static files from the server. If we don't do this +# when using a subdomain getting them will fail. +for rule in app.url_map.iter_rules('static'): + app.url_map._rules.remove(rule) +app.url_map._rules_by_endpoint['static'] = [] +app.view_functions["static"] = None +app.static_folder = 'static' +app.add_url_rule('/', + endpoint='static', + subdomain="", + view_func=app.send_static_file) + + +@app.url_value_preprocessor +def before_route(endpoint, values): + if values is not None and endpoint == 'static': + values.pop('language', None) + if values is not None: + setup_language(language=values.get('language', None)) + def get_locale(): return session.get("lang", request.accept_languages.best_match(ALL_LANGUAGES.keys(), 'en')) @@ -435,7 +455,7 @@ def before_request_https(): app.config.update( SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SAMESITE='Lax', + SESSION_COOKIE_SAMESITE='Strict', ) # Set security attributes for cookies in a central place - but not when @@ -458,13 +478,18 @@ def before_request_https(): @app.before_request -def setup_language(): +def setup_language(language=None): # Determine the user's requested language code. # # If not in the request parameters, use the browser's accept-languages # header to do language negotiation. Can be changed in the session by # POSTing to `/change_language`, and be overwritten by remember_current_user(). - if lang_from_request := request.args.get('language', None): + if language: + session['lang'] = get_correct_lang_key(language) + elif request.url_rule is not None and request.url_rule.subdomain not in ['', '']: + lang_from_subdomain = get_correct_lang_key(request.url_rule.subdomain) + session['lang'] = lang_from_subdomain + elif lang_from_request := request.args.get('language', None): session['lang'] = lang_from_request if 'lang' not in session: session['lang'] = request.accept_languages.best_match( @@ -478,7 +503,6 @@ def setup_language(): if request.args.get('keyword_language', None): session['keyword_lang'] = request.args.get('keyword_language', None) g.keyword_lang = session['keyword_lang'] - # Set the page direction -> automatically set it to "left-to-right" # Switch to "right-to-left" if one of the language is rtl according to Locale (from Babel) settings. # This is the only place to expand / shrink the list of RTL languages -> @@ -492,6 +516,23 @@ def setup_language(): if g.lang not in ALL_LANGUAGES.keys(): return make_response(gettext("request_invalid"), 404) +# When languages come from the subdomain, they do in lowercase, +# but the keys are partially in uppercase sometimes + + +def get_correct_lang_key(language: str): + index = language.find('_') + if index != -1: + if language in ["zh_hans", "zh_hant"]: + first_part = language[0:index] + second_part = language[index + 1].upper() + third_part = language[index + 2:] + return f'{first_part}_{second_part}{third_part}' + else: + return f'{language[0:index]}{language[index:].upper()}' + else: + return language + if utils.is_heroku() and not os.getenv('HEROKU_RELEASE_CREATED_AT'): logger.warning( @@ -532,7 +573,7 @@ def add_hx_detection(): @app.after_request -def set_security_headers(response): +def set_security_headers(response: Response): security_headers = { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'X-XSS-Protection': '1; mode=block', @@ -540,6 +581,7 @@ def set_security_headers(response): # Not X-Frame-Options on purpose -- we are being embedded by Online Masters # and that's okay. response.headers.update(security_headers) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response @@ -557,20 +599,22 @@ def teardown_request_finish_logging(exc): @app.route('/session_test', methods=['GET']) -def echo_session_vars_test(): +def echo_session_vars_test(language="en"): if not utils.is_testing_request(request): return make_response(gettext("request_invalid"), 400) return make_response({'session': dict(session)}) +@app.route('/session_main', methods=['GET'], subdomain="") @app.route('/session_main', methods=['GET']) -def echo_session_vars_main(): +def echo_session_vars_main(language="en"): if not utils.is_testing_request(request): return make_response(gettext("request_invalid"), 400) return make_response({'session': dict(session), 'proxy_enabled': bool(os.getenv('PROXY_TO_TEST_HOST'))}) +@app.route('/parse', methods=['POST'], subdomain="") @app.route('/parse', methods=['POST']) @querylog.timed_as('parse_handler') def parse(): @@ -711,6 +755,7 @@ def parse(): return make_response(response, 200) +@app.route('/parse-by-id', methods=['POST'], subdomain="") @app.route('/parse-by-id', methods=['POST']) @requires_login def parse_by_id(user): @@ -736,6 +781,7 @@ def parse_by_id(user): return make_response(gettext("request_invalid"), 400) +@app.route('/parse_tutorial', methods=['POST'], subdomain="") @app.route('/parse_tutorial', methods=['POST']) @requires_login def parse_tutorial(user): @@ -751,8 +797,9 @@ def parse_tutorial(user): return make_response(gettext("request_invalid"), 400) +@app.route("/generate_machine_files", methods=['POST'], subdomain="") @app.route("/generate_machine_files", methods=['POST']) -def prepare_files(): +def prepare_files(language="en"): body = request.json # Prepare the file -> return the "secret" filename as response transpiled_code = hedy.transpile(body.get("code"), body.get("level"), body.get("lang")) @@ -797,8 +844,9 @@ def int_with_error(value, error_message): return make_response({'filename': filename}, 200) +@app.route("/download_machine_files/", methods=['GET'], subdomain="") @app.route("/download_machine_files/", methods=['GET']) -def download_machine_file(filename, extension="zip"): +def download_machine_file(filename, extension="zip", language="en"): # https://stackoverflow.com/questions/24612366/delete-an-uploaded-file-after-downloading-it-from-flask # Once the file is downloaded -> remove it @@ -818,8 +866,9 @@ def remove_file(response): MICROBIT_FEATURE = False +@app.route('/generate_microbit_files', methods=['POST'], subdomain="") @app.route('/generate_microbit_files', methods=['POST']) -def generate_microbit_file(): +def generate_microbit_file(language="en"): if MICROBIT_FEATURE: # Extract variables from request body body = request.json @@ -866,8 +915,9 @@ def save_transpiled_code_for_microbit(transpiled_python_code): file.write(processed_code) +@app.route('/download_microbit_files/', methods=['GET'], subdomain="") @app.route('/download_microbit_files/', methods=['GET']) -def convert_to_hex_and_download(): +def convert_to_hex_and_download(language="en"): if MICROBIT_FEATURE: flash_micro_bit() current_directory = os.path.dirname(os.path.abspath(__file__)) @@ -920,8 +970,9 @@ def hedy_error_to_response(ex): } +@app.route('/report_error', methods=['POST'], subdomain="") @app.route('/report_error', methods=['POST']) -def report_error(): +def report_error(language="en"): post_body = request.json parse_logger.log({ @@ -938,8 +989,9 @@ def report_error(): return 'logged' +@app.route('/client_exception', methods=['POST'], subdomain="") @app.route('/client_exception', methods=['POST']) -def report_client_exception(): +def report_client_exception(language="en"): post_body = request.json querylog.log_value( @@ -955,8 +1007,9 @@ def report_client_exception(): return 'logged', 500 +@app.route('/version', methods=['GET'], subdomain="") @app.route('/version', methods=['GET']) -def version_page(): +def version_page(language="en"): """ Generate a page with some diagnostic information and a useful GitHub URL on upcoming changes. @@ -977,8 +1030,9 @@ def version_page(): commit=commit) +@app.route('/commands/', subdomain="") @app.route('/commands/') -def all_commands(id): +def all_commands(id, language="en"): program = DATABASE.program_by_id(id) code = program.get('code') level = program.get('level') @@ -988,9 +1042,10 @@ def all_commands(id): commands=hedy.all_commands(code, level, lang)) +@app.route('/programs', methods=['GET'], subdomain="") @app.route('/programs', methods=['GET']) @requires_login_redirect -def programs_page(user): +def programs_page(user, language="en"): username = user['username'] if not username: # redirect users to /login if they are not logged in @@ -1099,8 +1154,9 @@ def programs_page(user): user_program_count=len(programs)) +@app.route('/logs/query', methods=['POST'], subdomain="") @app.route('/logs/query', methods=['POST']) -def query_logs(): +def query_logs(language="en"): user = current_user() if not is_admin(user) and not is_teacher(user): return utils.error_page(error=401, ui_message=gettext('unauthorized')) @@ -1125,8 +1181,9 @@ def query_logs(): return make_response(response, 200) +@app.route('/logs/results', methods=['GET'], subdomain="") @app.route('/logs/results', methods=['GET']) -def get_log_results(): +def get_log_results(language="en"): query_execution_id = request.args.get( 'query_execution_id', default=None, type=str) next_token = request.args.get('next_token', default=None, type=str) @@ -1141,8 +1198,9 @@ def get_log_results(): return make_response(response, 200) +@app.route('/tutorial', methods=['GET'], subdomain="") @app.route('/tutorial', methods=['GET']) -def tutorial_index(): +def tutorial_index(language="en"): if not current_user()['username']: return redirect('/login') level = 1 @@ -1184,9 +1242,10 @@ def tutorial_index(): )) +@app.route('/teacher-tutorial', methods=['GET'], subdomain="") @app.route('/teacher-tutorial', methods=['GET']) @requires_teacher -def teacher_tutorial(user): +def teacher_tutorial(user, language="en"): teacher_classes = DATABASE.get_teacher_classes(user['username'], True) adventures = [] for adventure in DATABASE.get_teacher_adventures(user['username']): @@ -1213,9 +1272,11 @@ def teacher_tutorial(user): # routing to index.html +@app.route('/hour-of-code/', methods=['GET'], subdomain="") @app.route('/hour-of-code/', methods=['GET']) +@app.route('/hour-of-code', methods=['GET'], defaults={'level': 1}, subdomain="") @app.route('/hour-of-code', methods=['GET'], defaults={'level': 1}) -def hour_of_code(level, program_id=None): +def hour_of_code(level, program_id=None, language="en"): try: level = int(level) if level < 1 or level > hedy.HEDY_MAX_LEVEL: @@ -1388,13 +1449,19 @@ def hour_of_code(level, program_id=None): # routing to index.html +@app.route('/ontrack', methods=['GET'], defaults={'level': '1', 'program_id': None}, subdomain="") @app.route('/ontrack', methods=['GET'], defaults={'level': '1', 'program_id': None}) +@app.route('/onlinemasters', methods=['GET'], defaults={'level': '1', 'program_id': None}, subdomain="") @app.route('/onlinemasters', methods=['GET'], defaults={'level': '1', 'program_id': None}) +@app.route('/onlinemasters/', methods=['GET'], defaults={'program_id': None}, subdomain="") @app.route('/onlinemasters/', methods=['GET'], defaults={'program_id': None}) +@app.route('/hedy', methods=['GET'], defaults={'program_id': None, 'level': '1'}, subdomain="") @app.route('/hedy', methods=['GET'], defaults={'program_id': None, 'level': '1'}) +@app.route('/hedy/', methods=['GET'], defaults={'program_id': None}, subdomain="") @app.route('/hedy/', methods=['GET'], defaults={'program_id': None}) +@app.route('/hedy//', methods=['GET'], subdomain="") @app.route('/hedy//', methods=['GET']) -def index(level, program_id): +def index(level, program_id, language='en'): try: level = int(level) if level < 1 or level > hedy.HEDY_MAX_LEVEL: @@ -1622,10 +1689,13 @@ def index(level, program_id): )) +@app.route('/tryit', methods=['GET'], defaults={'program_id': None, 'level': '1'}, subdomain="") @app.route('/tryit', methods=['GET'], defaults={'program_id': None, 'level': '1'}) +@app.route('/tryit/', methods=['GET'], defaults={'program_id': None}, subdomain="") @app.route('/tryit/', methods=['GET'], defaults={'program_id': None}) +@app.route('/tryit//', methods=['GET'], subdomain="") @app.route('/tryit//', methods=['GET']) -def tryit(level, program_id): +def tryit(level, program_id, language='en'): try: level = int(level) if level < 1 or level > hedy.HEDY_MAX_LEVEL: @@ -1853,8 +1923,9 @@ def tryit(level, program_id): )) +@app.route('/hedy', methods=['GET'], subdomain="") @app.route('/hedy', methods=['GET']) -def index_level(): +def index_level(language="en"): if current_user()['username']: highest_quiz = get_highest_quiz_level(current_user()['username']) # This function returns the character '-' in case there are no finished quizes @@ -1869,9 +1940,10 @@ def index_level(): return index(1, None) +@app.route('/hedy//view', methods=['GET'], subdomain="") @app.route('/hedy//view', methods=['GET']) @requires_login -def view_program(user, id): +def view_program(user, id, language="en"): result = DATABASE.program_by_id(id) if not result or not current_user_allowed_to_see_program(result): @@ -1971,8 +2043,9 @@ def view_program(user, id): **arguments_dict) +@app.route('/render_code//', methods=['GET'], subdomain="") @app.route('/render_code//', methods=['GET']) -def render_code_in_editor(level): +def render_code_in_editor(level, language="en"): code = request.args['code'] try: @@ -2014,10 +2087,13 @@ def render_code_in_editor(level): )) +@app.route('/adventure/', methods=['GET'], defaults={'level': 1, 'mode': 'full'}, subdomain="") @app.route('/adventure/', methods=['GET'], defaults={'level': 1, 'mode': 'full'}) +@app.route('/adventure//', methods=['GET'], defaults={'mode': 'full'}, subdomain="") @app.route('/adventure//', methods=['GET'], defaults={'mode': 'full'}) +@app.route('/adventure///', methods=['GET'], subdomain="") @app.route('/adventure///', methods=['GET']) -def get_specific_adventure(name, level, mode): +def get_specific_adventure(name, level, mode, language="en"): try: level = int(level) except BaseException: @@ -2096,8 +2172,9 @@ def get_specific_adventure(name, level, mode): )) +@app.route('/embedded/', methods=['GET'], subdomain="") @app.route('/embedded/', methods=['GET']) -def get_embedded_code_editor(level): +def get_embedded_code_editor(level, language="en"): forget_current_user() # Start with an empty program @@ -2149,9 +2226,11 @@ def get_embedded_code_editor(level): )) +@app.route('/cheatsheet/', methods=['GET'], defaults={'level': 1}, subdomain="") @app.route('/cheatsheet/', methods=['GET'], defaults={'level': 1}) +@app.route('/cheatsheet/', methods=['GET'], subdomain="") @app.route('/cheatsheet/', methods=['GET']) -def get_cheatsheet_page(level): +def get_cheatsheet_page(level, language="en"): try: level = int(level) if level < 1 or level > hedy.HEDY_MAX_LEVEL: @@ -2164,8 +2243,9 @@ def get_cheatsheet_page(level): return render_template("printable/cheatsheet.html", commands=commands, level=level) +@app.route('/certificate/', methods=['GET'], subdomain="") @app.route('/certificate/', methods=['GET']) -def get_certificate_page(username): +def get_certificate_page(username, language="en"): if not current_user()['username']: return utils.error_page(error=401, ui_message=gettext('unauthorized')) username = username.lower() @@ -2220,22 +2300,25 @@ def internal_error(exception): return utils.error_page(error=500, exception=exception) +@app.route('/signup', methods=['GET'], subdomain="") @app.route('/signup', methods=['GET']) -def signup_page(): +def signup_page(language="en"): if current_user()['username']: return redirect('/my-profile') return render_template('signup.html', page_title=gettext('title_signup'), current_page='login') +@app.route('/login', methods=['GET'], subdomain="") @app.route('/login', methods=['GET']) -def login_page(): +def login_page(language="en"): if current_user()['username']: return redirect('/my-profile') return render_template('login.html', page_title=gettext('title_login'), current_page='login') +@app.route('/recover', methods=['GET'], subdomain="") @app.route('/recover', methods=['GET']) -def recover_page(): +def recover_page(language="en"): if current_user()['username']: return redirect('/my-profile') return render_template( @@ -2244,8 +2327,9 @@ def recover_page(): current_page='login') +@app.route('/reset', methods=['GET'], subdomain="") @app.route('/reset', methods=['GET']) -def reset_page(): +def reset_page(language="en"): # If there is a user logged in -> don't allow password reset if current_user()['username']: return redirect('/my-profile') @@ -2265,9 +2349,10 @@ def reset_page(): current_page='login') +@app.route('/my-profile', methods=['GET'], subdomain="") @app.route('/my-profile', methods=['GET']) @requires_login_redirect -def profile_page(user): +def profile_page(user, language="en"): profile = DATABASE.user_by_username(user['username']) programs = DATABASE.filtered_programs_for_user(user['username'], public=True) public_profile_settings = DATABASE.get_public_profile_settings(current_user()['username']) @@ -2304,19 +2389,23 @@ def profile_page(user): )) +@app.route('/research/', methods=['GET'], subdomain="") @app.route('/research/', methods=['GET']) -def get_research(filename): +def get_research(filename, language="en"): return send_from_directory('content/research/', filename) +@app.route('/favicon.ico', subdomain="") @app.route('/favicon.ico') -def favicon(): +def favicon(language="en"): abort(404) +@app.route('/', subdomain="") @app.route('/') +@app.route('/index.html', subdomain="") @app.route('/index.html') -def main_page(): +def main_page(language="en"): sections = hedyweb.PageTranslations('start').get_page_translations(g.lang)['home-sections'] sections = sections[:] @@ -2356,13 +2445,15 @@ def main_page(): current_page='start', content=content, user=user) +@app.route('/subscribe', subdomain="") @app.route('/subscribe') -def subscribe(): +def subscribe(language="en"): return render_template('subscribe.html', current_page='subscribe') +@app.route('/learn-more', subdomain="") @app.route('/learn-more') -def learn_more(): +def learn_more(language="en"): learn_more_translations = hedyweb.PageTranslations('learn-more').get_page_translations(g.lang) return render_template( 'learn-more.html', @@ -2372,34 +2463,39 @@ def learn_more(): content=learn_more_translations) +@app.route('/join', subdomain="") @app.route('/join') -def join(): +def join(language="en"): join_translations = hedyweb.PageTranslations('join').get_page_translations(g.lang) return render_template('join.html', page_title=gettext('title_learn-more'), current_page='join', content=join_translations) +@app.route('/kerndoelen', subdomain="") @app.route('/kerndoelen') -def poster(): +def poster(language="en"): return send_from_directory('content/', 'kerndoelenposter.pdf') +@app.route('/start', subdomain="") @app.route('/start') -def start(): +def start(language="en"): start_translations = hedyweb.PageTranslations('start').get_page_translations(g.lang) return render_template('start.html', page_title=gettext('title_learn-more'), current_page='start', content=start_translations) +@app.route('/privacy', subdomain="") @app.route('/privacy') -def privacy(): +def privacy(language="en"): privacy_translations = hedyweb.PageTranslations('privacy').get_page_translations(g.lang) return render_template('privacy.html', page_title=gettext('title_privacy'), content=privacy_translations) +@app.route('/explore', methods=['GET'], subdomain="") @app.route('/explore', methods=['GET']) -def explore(): +def explore(language="en"): if not current_user()['username']: return redirect('/login') @@ -2487,8 +2583,9 @@ def pre_process_explore_program(program): return program +@app.route('/change_language', methods=['POST'], subdomain="") @app.route('/change_language', methods=['POST']) -def change_language(): +def change_language(language="en"): body = request.json session['lang'] = body.get('lang') # Remove 'keyword_lang' from session, it will automatically be renegotiated from 'lang' @@ -2498,9 +2595,11 @@ def change_language(): return jsonify({'success': 204}) +@app.route('/slides', methods=['GET'], defaults={'level': '1'}, subdomain="") @app.route('/slides', methods=['GET'], defaults={'level': '1'}) +@app.route('/slides/', methods=['GET'], subdomain="") @app.route('/slides/', methods=['GET']) -def get_slides(level): +def get_slides(level, language="en"): # In case of a "forced keyword language" -> load that one, otherwise: load # the one stored in the g object @@ -2518,8 +2617,9 @@ def get_slides(level): return render_template('slides.html', level=level, slides=slides) +@app.route('/translate_keywords', methods=['POST'], subdomain="") @app.route('/translate_keywords', methods=['POST']) -def translate_keywords(): +def translate_keywords(language="en"): body = request.json try: translated_code = hedy_translation.translate_keywords(body.get('code'), body.get( @@ -2535,8 +2635,9 @@ def translate_keywords(): # TODO TB: Think about changing this to sending all steps to the front-end at once +@app.route('/get_tutorial_step//', methods=['GET'], subdomain="") @app.route('/get_tutorial_step//', methods=['GET']) -def get_tutorial_translation(level, step): +def get_tutorial_translation(level, step, language="en"): # Keep this structure temporary until we decide on a nice code / parse structure if step == "code_snippet": code = hedy_content.deep_translate_keywords(gettext('tutorial_code_snippet'), g.keyword_lang) @@ -2553,8 +2654,9 @@ def get_tutorial_translation(level, step): return make_response((data), 200) +@app.route('/store_parsons_order', methods=['POST'], subdomain="") @app.route('/store_parsons_order', methods=['POST']) -def store_parsons_order(): +def store_parsons_order(language="en"): body = request.json # Validations if not isinstance(body, dict): @@ -2786,13 +2888,15 @@ def get_user_messages(): app.add_template_global(utils.prepare_content_for_ckeditor, name="prepare_content_for_ckeditor") +@app.route('/translating', subdomain="") @app.route('/translating') -def translating_page(): +def translating_page(language="en"): return render_template('translating.html') +@app.route('/update_yaml', methods=['POST'], subdomain="") @app.route('/update_yaml', methods=['POST']) -def update_yaml(): +def update_yaml(language="en"): filename = path.join('coursedata', request.form['file']) # The file MUST point to something inside our 'coursedata' directory filepath = path.abspath(filename) @@ -2812,8 +2916,9 @@ def update_yaml(): headers={'Content-disposition': 'attachment; filename=' + request.form['file'].replace('/', '-')}) +@app.route('/user/', subdomain="") @app.route('/user/') -def public_user_page(username): +def public_user_page(username, language="en"): if not current_user()['username']: return utils.error_page(error=401, ui_message=gettext('unauthorized')) username = username.lower() @@ -2909,8 +3014,9 @@ def valid_invite_code(code): return code in valid_codes +@app.route('/invite/', methods=['GET'], subdomain="") @app.route('/invite/', methods=['GET']) -def teacher_invitation(code): +def teacher_invitation(code, language="en"): user = current_user() if not valid_invite_code(code): @@ -3095,7 +3201,11 @@ def on_offline_mode(): on_offline_mode() on_server_start() - + app.config.update( + SERVER_NAME=config['domain_name'], + SESSION_COOKIE_DOMAIN=config['domain_name'], + SESSION_COOKIE_SAMESITE="Strict" + ) # Set some default environment variables for development mode env_defaults = dict( BASE_URL=f"http://localhost:{config['port']}/", diff --git a/config.py b/config.py index 86d48e214e3..944496b7bca 100644 --- a/config.py +++ b/config.py @@ -2,11 +2,16 @@ import socket app_name = os.getenv('HEROKU_APP_NAME', socket.gethostname()) -dyno = os.getenv('DYNO') +dyno = os.getenv('DYNO') # if this env variable is set, it means we are in a Heroku athena_query = os.getenv('AWS_ATHENA_PREPARE_STATEMENT') config = { - 'port': os.getenv('PORT') or 8080, + 'port': os.getenv('PORT', 8080), + # I can't reference a previous field, so copying and pasting here + 'domain_name': ( + f"{os.getenv('DOMAIN_NAME')}" if dyno # if we are in localhost no need to add port + else f"{os.getenv('DOMAIN_NAME', 'localhost')}:{os.getenv('PORT', 8080)}" + ), 'session': { 'cookie_name': 'hedy', # in minutes diff --git a/dodo.py b/dodo.py index 74df9508793..a75912576ba 100644 --- a/dodo.py +++ b/dodo.py @@ -25,7 +25,7 @@ from glob import glob import sys import platform - +from config import config as CONFIG from doit.tools import LongRunning if os.getenv('GITHUB_ACTION') and platform.system() == 'Windows': @@ -354,7 +354,7 @@ def task_devserver(): LongRunning([python3, 'app.py'], shell=False, env=dict( os.environ, # These are required to make some local features work. - BASE_URL="http://localhost:8080/", + BASE_URL=f"http://{CONFIG['domain_name']}", ADMIN_USER="admin",)) ], verbosity=2, # show everything live diff --git a/static/js/app-main.ts b/static/js/app-main.ts deleted file mode 100644 index 74052e1131f..00000000000 --- a/static/js/app-main.ts +++ /dev/null @@ -1,1912 +0,0 @@ -import { ClientMessages } from './client-messages'; -import { modal, error, success, tryCatchPopup } from './modal'; -import JSZip from "jszip"; -import * as Tone from 'tone' -import { Tabs } from './tabs'; -import { MessageKey } from './message-translations'; -import { turtle_prefix, pressed_prefix, normal_prefix, music_prefix } from './pythonPrefixes' -import { Adventure, isServerSaveInfo, ServerSaveInfo } from './types'; -import { startIntroTutorial } from './tutorials/tutorial'; -import { get_parsons_code, initializeParsons, loadParsonsExercise } from './parsons'; -import { checkNow, onElementBecomesVisible } from './browser-helpers/on-element-becomes-visible'; -import { - incrementDebugLine, - initializeDebugger, - load_variables, - startDebug, - toggleVariableView -} from './debugging'; -import { localDelete, localLoad, localSave } from './local'; -import { initializeLoginLinks } from './auth'; -import { postJson, postNoResponse } from './comm'; -import { LocalSaveWarning } from './local-save-warning'; -import { HedyEditor, EditorType } from './editor'; -import { stopDebug } from "./debugging"; -import { HedyCodeMirrorEditorCreator } from './cm-editor'; -import { initializeTranslation } from './lezer-parsers/tokens'; -import { initializeActivity } from './user-activity'; -export let theGlobalDebugger: any; -export let theGlobalEditor: HedyEditor; -export let theModalEditor: HedyEditor; -export let theGlobalSourcemap: { [x: string]: any; }; -export const theLocalSaveWarning = new LocalSaveWarning(); -const editorCreator: HedyCodeMirrorEditorCreator = new HedyCodeMirrorEditorCreator(); -let last_code: string; - -/** - * Represents whether there's an open 'ask' prompt - */ -let askPromptOpen = false; -/** - * Represents whether there's an open 'sleeping' prompt - */ -let sleepRunning = false; - -// Many bits of code all over this file need this information globally. -// Not great but it'll do for now until we refactor this file some more. -let theAdventures: Record = {}; -export let theLevel: number = 0; -export let theLanguage: string = ''; -export let theKeywordLanguage: string = 'en'; -let theStaticRoot: string = ''; -let currentTab: string; -let theUserIsLoggedIn: boolean; -//create a synth and connect it to the main output (your speakers) -//const synth = new Tone.Synth().toDestination(); - -const synth = new Tone.PolySynth(Tone.Synth).toDestination(); - -const slides_template = ` - - - - - - - - Slides Level - 1 - - - - -
-
- {replace} -
-
- - - - - -`; - - -export interface InitializeAppOptions { - readonly level: number; - readonly keywordLanguage: string; - /** - * The URL root where static content is hosted - */ - readonly staticRoot?: string; -} - -/** - * Initialize "global" parts of the main app - */ -export function initializeApp(options: InitializeAppOptions) { - theLevel = options.level; - theKeywordLanguage = options.keywordLanguage; - theStaticRoot = options.staticRoot ?? ''; - // When we are in Alpha or in dev the static root already points to an internal directory - theStaticRoot = theStaticRoot === '/' ? '' : theStaticRoot; - initializeCopyToClipboard(); - - // Close the dropdown menu if the user clicks outside of it - $(document).on("click", function(event){ - // The following is not needed anymore, but it saves the next for loop if the click is not for dropdown. - if (!$(event.target).closest(".dropdown").length) { - $('.dropdown_menu').slideUp("medium"); - $('.cheatsheet_menu').slideUp("medium"); - return; - } - - const allDropdowns = $('.dropdown_menu') - for (const dd of allDropdowns) { - // find the closest dropdown button (element) that initiated the event - const c = $(dd).closest('.dropdown')[0] - // if the click event target is not within or close to the container, slide up the dropdown menu - if (!$(event.target).closest(c).length) { - $(dd).slideUp('fast'); - } - } - }); - - $('#search_language').on('keyup', function() { - let search_query = ($('#search_language').val() as string).toLowerCase(); - $('.language').each(function(){ - let languageName = $(this).html().toLowerCase(); - let englishName = $(this).attr('data-english'); - if (englishName !== undefined && (languageName.includes(search_query) || englishName.toLowerCase().includes(search_query))) { - $(this).show(); - } else { - $(this).hide(); - $('#add_language_btn').show(); - } - }); - }); - - // All input elements with data-autosubmit="true" automatically submit their enclosing form - $('*[data-autosubmit="true"]').on('change', (ev) => { - $(ev.target).closest('form').trigger('submit'); - }); - - initializeLoginLinks(); - - initializeActivity(); -} - -export interface InitializeCodePageOptions { - readonly page: 'code'; - readonly level: number; - readonly lang: string; - readonly adventures: Adventure[]; - readonly start_tutorial?: boolean; - readonly initial_tab: string; - readonly current_user_name?: string; - readonly suppress_save_and_load_for_slides?: boolean; - readonly enforce_developers_mode?: boolean; -} - -/** - * Initialize the actual code page - */ -export function initializeCodePage(options: InitializeCodePageOptions) { - theUserIsLoggedIn = !!options.current_user_name; - if (theUserIsLoggedIn) { - theLocalSaveWarning.setLoggedIn(); - } - - theAdventures = Object.fromEntries((options.adventures ?? []).map(a => [a.short_name, a])); - - // theLevel will already have been set during initializeApp - if (theLevel != options.level) { - throw new Error(`initializeApp set level to ${JSON.stringify(theLevel)} but initializeCodePage sets it to ${JSON.stringify(options.level)}`); - } - theLanguage = options.lang; - - // *** EDITOR SETUP *** - const $editor = $('#editor'); - if ($editor.length) { - const dir = $('body').attr('dir'); - theGlobalEditor = editorCreator.initializeEditorWithGutter($editor, EditorType.MAIN, dir); - initializeTranslation({keywordLanguage: theKeywordLanguage, level: theLevel}); - attachMainEditorEvents(theGlobalEditor); - initializeDebugger({ - editor: theGlobalEditor, - level: theLevel, - language: theLanguage, - keywordLanguage: theKeywordLanguage, - }); - } - - const anchor = window.location.hash.substring(1); - - const validAnchor = [...Object.keys(theAdventures), 'parsons', 'quiz'].includes(anchor) ? anchor : undefined; - - const tabs = new Tabs({ - // If we're opening an adventure from the beginning (either through a link to /hedy/adventures or through a saved program for an adventure), we click on the relevant tab. - // We click on `level` to load a program associated with level, if any. - initialTab: validAnchor ?? options.initial_tab, - }); - - tabs.on('beforeSwitch', () => { - // If there are unsaved changes, we warn the user before changing tabs. - saveIfNecessary(); - }); - - tabs.on('afterSwitch', (ev) => { - currentTab = ev.newTab; - const adventure = theAdventures[currentTab]; - - if (!options.suppress_save_and_load_for_slides) { - // Load initial code from local storage, if available - const programFromLs = localLoad(currentTabLsKey()); - // if we are in raw (used in slides) we don't want to load from local storage, we always want to show startcode - if (programFromLs && adventure) { - adventure.editor_contents = programFromLs.code; - adventure.save_name = programFromLs.saveName; - adventure.save_info = 'local-storage'; - } - } - reconfigurePageBasedOnTab(options.enforce_developers_mode); - checkNow(); - theLocalSaveWarning.switchTab(); - }); - - initializeSpeech(); - - if (options.start_tutorial) { - startIntroTutorial(); - } - - // Share/hand in modals - $('#share_program_button').on('click', () => $('#share_modal').show()); - $('#hand_in_button').on('click', () => $('#hand_in_modal').show()); - initializeShareProgramButtons(); - initializeHandInButton(); - - if (options.suppress_save_and_load_for_slides) { - disableAutomaticSaving(); - } - - // Save if user navigates away - window.addEventListener('beforeunload', () => saveIfNecessary(), { capture: true }); - - // Save if program name is changed - $('#program_name').on('blur', () => saveIfNecessary()); -} - -function attachMainEditorEvents(editor: HedyEditor) { - - editor.on('change', () => { - theLocalSaveWarning.setProgramLength(theGlobalEditor.contents.split('\n').length); - }); - - // If prompt is shown and user enters text in the editor, hide the prompt. - editor.on('change', function() { - if (askPromptOpen) { - stopit(); - theGlobalEditor.focus(); // Make sure the editor has focus, so we can continue typing - } - if ($('#ask_modal').is(':visible')) $('#inline_modal').hide(); - askPromptOpen = false; - $('#runit').css('background-color', ''); - theGlobalEditor.clearErrors(); - theGlobalEditor.clearIncorrectLines(); - //removing the debugging state when loading in the editor - stopDebug(); - }); - - editor.on('click', (event: MouseEvent) => { - editor.skipFaultyHandler(event); - }); - - // *** KEYBOARD SHORTCUTS *** - let altPressed: boolean | undefined; - // alt is 18, enter is 13 - window.addEventListener ('keydown', function (ev) { - const keyCode = ev.keyCode; - if (keyCode === 18) { - altPressed = true; - return; - } - if (keyCode === 13 && altPressed) { - if (!theLevel || !theLanguage) { - throw new Error('Oh no'); - } - runit (theLevel, theLanguage, false, "", "run",function () { - $ ('#output').focus (); - }); - } - // We don't use jquery because it doesn't return true for this equality check. - if (keyCode === 37 && document.activeElement === document.getElementById ('output')) { - theGlobalEditor.focus(); - theGlobalEditor.moveCursorToEndOfFile(); - } - }); - window.addEventListener ('keyup', function (ev) { - triggerAutomaticSave(); - const keyCode = ev.keyCode; - if (keyCode === 18) { - altPressed = false; - return; - } - }); -} - -export interface InitializeViewProgramPageOptions { - readonly page: 'view-program'; - readonly level: number; - readonly lang: string; - readonly code: string; -} - -export function initializeViewProgramPage(options: InitializeViewProgramPageOptions) { - theLevel = options.level; - theLanguage = options.lang; - - // We need to enable the main editor for the program page as well - const dir = $('body').attr('dir'); - theGlobalEditor = editorCreator.initializeEditorWithGutter($('#editor'), EditorType.MAIN, dir); - initializeTranslation({ - keywordLanguage: options.lang, - level: options.level - }); - attachMainEditorEvents(theGlobalEditor); - theGlobalEditor.contents = options.code; - initializeDebugger({ - editor: theGlobalEditor, - level: theLevel, - language: theLanguage, - keywordLanguage: theKeywordLanguage, - }); -} - -export function initializeHighlightedCodeBlocks(where: Element, initializeAll?: boolean) { - const dir = $("body").attr("dir"); - initializeParsons(); - if (theLevel) { - initializeTranslation({ - keywordLanguage: theKeywordLanguage, - level: theLevel - }) - } - // Any code blocks we find inside 'turn-pre-into-ace' get turned into - // read-only editors (for syntax highlighting) - for (const container of $(where).find('.turn-pre-into-ace').get()) { - for (const preview of $(container).find('pre').get()) { - $(preview) - .addClass('relative text-lg rounded overflow-x-hidden') - // We set the language of the editor to the current keyword_language -> needed when copying to main editor - .attr('lang', theKeywordLanguage); - // If the request comes from HTMX initialize all directly - if (initializeAll) { - convertPreviewToEditor(preview, container, dir) - } else { - // Only turn into an editor if the editor scrolls into view - // Otherwise, the teacher manual Frequent Mistakes page is SUPER SLOW to load. - onElementBecomesVisible(preview, () => { - convertPreviewToEditor(preview, container, dir) - }); - } - } - } -} - -function convertPreviewToEditor(preview: HTMLPreElement, container: HTMLElement, dir?: string) { - const codeNode = preview.querySelector('code') - let code: string; - // In case it has a child node - if (codeNode) { - codeNode.hidden = true - code = codeNode.innerText - } else { - code = preview.textContent || ""; - preview.textContent = ""; - } - - // Create this example editor - const exampleEditor = editorCreator.initializeReadOnlyEditor(preview, dir); - // Strip trailing newline, it renders better - exampleEditor.contents = code; - exampleEditor.contents = exampleEditor.contents.trimEnd(); - // And add an overlay button to the editor if requested via a show-copy-button class, either - // on the
 itself OR on the element that has the '.turn-pre-into-ace' class.
-  if ($(preview).hasClass('show-copy-button') || $(container).hasClass('show-copy-button')) {
-    const buttonContainer = $('
').addClass('absolute ltr:right-0 rtl:left-0 top-0 mx-1 mt-1').appendTo(preview); - let symbol = "⇥"; - if (dir === "rtl") { - symbol = "⇤"; - } - const adventure = container.getAttribute('data-tabtarget') - $('