From aa66695bc3526d6991381dec0ab9bfa1e56fbd35 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Wed, 6 Mar 2024 14:27:13 +0000 Subject: [PATCH] User online status caching (#94) * WIP on cron + Redis caching of connected users per locale * Language support tweaks * WIP on more logical locale support; fav users filtered by locale * WIP on Redis caching of presence using sets; moved type definitions * WIP on Redis caching of user presence data * WIP on online user caching * WIP but code is now consistent; needs test * Cleanup of unused variables in firebase.py * Added user parameter to render_template() calls; code formatting in cache.py * Bug fixes in Redis online status handling code * Added online user update job to cron.yaml * Added TTL caching of online users * Add attribution & copyright for Norwegian word frequency data * Reduced in-memory caching of online users to 30 seconds * Skip user online query if robots only * Removed unused class --- cron.yaml | 7 ++ src/api.py | 107 +++++++++++-------- src/auth.py | 15 ++- src/basics.py | 11 +- src/billing.py | 3 +- src/cache.py | 156 +++++++++++++++++++++------ src/config.py | 68 ++++++++++-- src/firebase.py | 158 +++++++++++++++++++--------- src/languages.py | 72 +++++++------ src/main.py | 5 +- src/skraflstats.py | 3 +- src/skrafluser.py | 21 ++-- src/web.py | 11 +- static/assets/messages.json | 12 +-- static/src/mithril.d.ts | 1 + static/src/page.ts | 11 +- templates/container-base-explo.html | 2 +- utils/dawgbuilder.py | 9 +- 18 files changed, 461 insertions(+), 211 deletions(-) diff --git a/cron.yaml b/cron.yaml index 47d52428..bef658df 100644 --- a/cron.yaml +++ b/cron.yaml @@ -13,3 +13,10 @@ cron: job_retry_limit: 3 min_backoff_seconds: 30.0 max_doublings: 3 +- description: "Online users" + url: /connect/update + schedule: every 1 minutes + retry_parameters: + job_retry_limit: 3 + min_backoff_seconds: 10.0 + max_doublings: 3 diff --git a/src/api.py b/src/api.py index d8fec04c..4d14ee17 100644 --- a/src/api.py +++ b/src/api.py @@ -16,6 +16,7 @@ """ from __future__ import annotations +import functools from typing import ( Optional, @@ -38,7 +39,6 @@ import re import logging import threading -import random from datetime import datetime, timedelta import base64 import io @@ -58,12 +58,12 @@ running_local, PROJECT_ID, DEFAULT_LOCALE, + ResponseType, ) from basics import ( is_mobile_client, jsonify, auth_required, - ResponseType, RequestData, current_user, current_user_id, @@ -78,7 +78,7 @@ current_alphabet, current_language, to_supported_locale, - SUPPORTED_LOCALES, + RECOGNIZED_LOCALES, ) from dawgdictionary import Wordbase from skraflmechanics import ( @@ -241,6 +241,9 @@ class ChatHistoryDict(TypedDict): disabled: bool # Chat disabled? +ChatHistoryList = List[ChatHistoryDict] + + class MoveNotifyDict(TypedDict): """A notification sent via Firebase to clients when a move has been processed""" @@ -325,7 +328,7 @@ class RevenueCatEvent(TypedDict, total=False): "NICK_TOO_LONG": "Ní mór d'ainm cleite a bheith níos lú ná {MAX_NICKNAME_LENGTH} carachtair", "EMAIL_NO_AT": "Caithfidh seoladh ríomhphoist comhartha @ a áireamh", "LOCALE_UNKNOWN": "Locale anaithnid", - } + }, } PUSH_MESSAGES: Mapping[str, Mapping[str, str]] = { @@ -334,28 +337,28 @@ class RevenueCatEvent(TypedDict, total=False): "en": "Your turn in Explo 💥", "pl": "Twoja kolej w Explo 💥", "nb": "Din tur i Explo 💥", - "ga": "Do sheal i Explo 💥" + "ga": "Do sheal i Explo 💥", }, "body": { "is": "{player} hefur leikið í viðureign ykkar.", "en": "{player} made a move in your game.", "pl": "{player} wykonał ruch w Twojej grze.", "nb": "{player} har gjort et trekk i spillet ditt.", - "ga": "Rinne {player} gluaiseacht i do chluiche." + "ga": "Rinne {player} gluaiseacht i do chluiche.", }, "chall_title": { "is": "Þú fékkst áskorun í Explo 💥", "en": "You've been challenged in Explo 💥", "pl": "Zostałeś wyzwany w Explo 💥", "nb": "Du har blitt utfordret i Explo 💥", - "ga": "Tá dúshlán curtha ort i Explo 💥" + "ga": "Tá dúshlán curtha ort i Explo 💥", }, "chall_body": { "is": "{player} hefur skorað á þig í viðureign!", "en": "{player} has challenged you to a game!", "pl": "{player} wyzwał cię na pojedynek!", "nb": "{player} har utfordret deg til en kamp!", - "ga": "Tá {player} tar éis dúshlán a thabhairt duit i gcluiche!" + "ga": "Tá {player} tar éis dúshlán a thabhairt duit i gcluiche!", }, } @@ -485,7 +488,7 @@ def validate(self) -> Dict[str, str]: errors["nickname"] = self.error_msg("NICK_NOT_ALPHANUMERIC") if self.email and "@" not in self.email: errors["email"] = self.error_msg("EMAIL_NO_AT") - if self.locale not in SUPPORTED_LOCALES: + if self.locale not in RECOGNIZED_LOCALES: errors["locale"] = self.error_msg("LOCALE_UNKNOWN") return errors @@ -773,6 +776,13 @@ def fetch_users( return {uid: user for uid, user in zip(uids, user_objects)} +# Kludge to create reasonably type-safe functions for each type of +# dictionary that contains some kind of user id and has a 'live' property +set_online_status_for_users = functools.partial(firebase.set_online_status, "userid") +set_online_status_for_games = functools.partial(firebase.set_online_status, "oppid") +set_online_status_for_chats = functools.partial(firebase.set_online_status, "user") + + def _userlist(query: str, spec: str) -> UserList: """Return a list of users matching the filter criteria""" @@ -825,21 +835,22 @@ def elo_str(elo: Union[None, int, str]) -> str: # Note that we only consider online users in the same locale # as the requesting user - online = firebase.online_users(locale) + online = firebase.online_status(locale) # Set of users blocked by the current user blocked: Set[str] = cuser.blocked() if cuser else set() - if query == "live": - # Return a sample (no larger than MAX_ONLINE items) of online (live) users + func_online_status: Optional[firebase.OnlineStatusFunc] = None - iter_online: Iterable[str] - if len(online) > MAX_ONLINE: - iter_online = random.sample(list(online), MAX_ONLINE) - else: - iter_online = online + if query == "live": + # Return a sample (no larger than MAX_ONLINE items) + # of online (live) users. Note that these are always + # grouped by locale, so all returned users will be in + # the same locale as the current user. + iter_online = online.random_sample(MAX_ONLINE) ousers = User.load_multi(iter_online) + for lu in ousers: if ( lu @@ -870,17 +881,19 @@ def elo_str(elo: Union[None, int, str]) -> str: ) elif query == "fav": - # Return favorites of the current user - # Note: this is currently not locale-constrained, - # which may well turn out to be a bug + # Return favorites of the current user, filtered by + # the user's current locale if cuid is not None: i = set(FavoriteModel.list_favorites(cuid)) # Do a multi-get of the entire favorites list fusers = User.load_multi(i) + # Look up users' online status later + func_online_status = online.users_online for fu in fusers: if ( fu and fu.is_displayable() + and fu.locale == locale and (favid := fu.id()) and favid not in blocked ): @@ -897,7 +910,7 @@ def elo_str(elo: Union[None, int, str]) -> str: chall=chall, fairplay=fu.fairplay(), newbag=True, - live=favid in online, + live=False, # Will be filled in later ready=fu.is_ready(), ready_timed=fu.is_ready_timed(), image=fu.image(), @@ -913,6 +926,8 @@ def elo_str(elo: Union[None, int, str]) -> str: cuser.human_elo(), max_len=40, locale=locale ) ausers = User.load_multi(ui) + # Look up users' online status later + func_online_status = online.users_online for au in ausers: if ( au @@ -933,7 +948,7 @@ def elo_str(elo: Union[None, int, str]) -> str: fav=cuser.has_favorite(uid), chall=chall, fairplay=au.fairplay(), - live=uid in online, + live=False, # Will be filled in later newbag=True, ready=au.is_ready(), ready_timed=au.is_ready_timed(), @@ -945,11 +960,7 @@ def elo_str(elo: Union[None, int, str]) -> str: # Display users who are online and ready for a timed game. # Note that the online list is already filtered by locale, # so the result is also filtered by locale. - if len(online) > MAX_ONLINE: - iter_online = random.sample(list(online), MAX_ONLINE) - else: - iter_online = online - + iter_online = online.random_sample(MAX_ONLINE) online_users = User.load_multi(iter_online) for user in online_users: @@ -998,6 +1009,7 @@ def elo_str(elo: Union[None, int, str]) -> str: # Store the result in the cache with a lifetime of 2 minutes memcache.set(cache_range, si, time=2 * 60, namespace="userlist") + func_online_status = online.users_online for ud in si: if not (uid := ud.get("id")) or uid == cuid or uid in blocked: continue @@ -1012,7 +1024,7 @@ def elo_str(elo: Union[None, int, str]) -> str: human_elo=elo_str(ud["human_elo"] or str(User.DEFAULT_ELO)), fav=False if cuser is None else cuser.has_favorite(uid), chall=chall, - live=uid in online, + live=False, # Will be filled in later fairplay=User.fairplay_from_prefs(ud["prefs"]), newbag=True, ready=ud["ready"] or False, @@ -1039,6 +1051,10 @@ def elo_str(elo: Union[None, int, str]) -> str: current_alphabet().sortkey_nocase(x["nick"]), ) ) + # Assign the online status of the users in the list, + # if this assignment was postponed + if func_online_status is not None: + set_online_status_for_users(result, func_online_status) return result @@ -1050,9 +1066,8 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList: now = datetime.utcnow() cuser = current_user() - online = firebase.online_users( - cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE - ) + locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE + online = firebase.online_status(locale) u: Optional[User] = None # Place zombie games (recently finished games that this player @@ -1093,7 +1108,7 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList: "manual": manual, }, timed=timed, - live=opp in online, + live=False, # Will be filled in later image=u.image(), fav=False if cuser is None else cuser.has_favorite(opp), tile_count=100, # All tiles (100%) accounted for @@ -1173,7 +1188,7 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList: }, timed=timed, tile_count=int(g["tile_count"] * 100 / tileset.num_tiles()), - live=opp in online, + live=False, image="" if u is None else u.image(), fav=False if cuser is None else cuser.has_favorite(opp), robot_level=robot_level, @@ -1181,6 +1196,8 @@ def _gamelist(cuid: str, include_zombies: bool = True) -> GameList: human_elo=0 if u is None else u.human_elo(), ) ) + # Set the live status of the opponents in the list + set_online_status_for_games(result, online.users_online) return result @@ -1283,8 +1300,9 @@ def _recentlist(cuid: Optional[str], versus: Optional[str], max_len: int) -> Rec rlist = GameModel.list_finished_games(cuid, versus=versus, max_len=max_len) # Multi-fetch the opponents in the list into a dictionary opponents = fetch_users(rlist, lambda g: g["opp"]) + locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE - online = firebase.online_users(cuser.locale if cuser else DEFAULT_LOCALE) + online = firebase.online_status(locale) u: Optional[User] = None @@ -1341,13 +1359,14 @@ def _recentlist(cuid: Optional[str], versus: Optional[str], max_len: int) -> Rec "duration": Game.get_duration_from_prefs(prefs), "manual": Game.manual_wordcheck_from_prefs(prefs), }, - live=False if opp is None else opp in online, + live=False, # Will be filled in later image="" if u is None else u.image(), elo=0 if u is None else u.elo(), human_elo=0 if u is None else u.human_elo(), fav=False if cuser is None or opp is None else cuser.has_favorite(opp), ) ) + set_online_status_for_games(result, online.users_online) return result @@ -1383,9 +1402,8 @@ def opp_ready(c: ChallengeTuple): return _opponent_waiting(cuid, c.opp, key=c.key) blocked = cuser.blocked() - online = firebase.online_users( - cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE - ) + locale = cuser.locale if cuser and cuser.locale else DEFAULT_LOCALE + online = firebase.online_status(locale) # List received challenges received = list(ChallengeModel.list_received(cuid, max_len=20)) # List issued challenges @@ -1415,7 +1433,7 @@ def opp_ready(c: ChallengeTuple): prefs=c.prefs, ts=Alphabet.format_timestamp_short(c.ts), opp_ready=False, - live=oppid in online, + live=False, # Will be filled in later image=u.image(), fav=cuser.has_favorite(oppid), elo=u.elo(), @@ -1444,13 +1462,15 @@ def opp_ready(c: ChallengeTuple): prefs=c.prefs, ts=Alphabet.format_timestamp_short(c.ts), opp_ready=opp_ready(c), - live=oppid in online, + live=False, # Will be filled in later image=u.image(), fav=cuser.has_favorite(oppid), elo=u.elo(), human_elo=u.human_elo(), ) ) + # Set the live status of the opponents in the list + set_online_status_for_users(result, online.users_online) return result @@ -2261,14 +2281,14 @@ def chathistory() -> ResponseType: # By default, return a history of 20 conversations count = rq.get_int("count", 20) - online = firebase.online_users(user.locale or DEFAULT_LOCALE) + online = firebase.online_status(user.locale or DEFAULT_LOCALE) # We don't return chat conversations with users # that this user has blocked blocked = user.blocked() uc = UserCache() # The chat history is ordered in reverse timestamp # order, i.e. the newest entry comes first - history: List[ChatHistoryDict] = [ + history: ChatHistoryList = [ ChatHistoryDict( user=(uid := cm["user"]), name=uc.full_name(uid), @@ -2277,13 +2297,14 @@ def chathistory() -> ResponseType: last_msg=cm["last_msg"], ts=Alphabet.format_timestamp(cm["ts"]), unread=cm["unread"], - live=uid in online, + live=False, # Will be filled in later fav=user.has_favorite(uid), disabled=uc.chat_disabled(uid), ) for cm in ChatModel.chat_history(user_id, blocked_users=blocked, maxlen=count) ] + set_online_status_for_chats(history, online.users_online) return jsonify(ok=True, history=history) diff --git a/src/auth.py b/src/auth.py index 4c11a79f..ea458d77 100644 --- a/src/auth.py +++ b/src/auth.py @@ -203,9 +203,9 @@ def oauth2callback(request: Request) -> ResponseType: idinfo["client_type"] = client_type except (KeyError, ValueError) as e: - # Invalid token + # Invalid token (most likely expired, which is an expected condition) # 401 - Unauthorized - logging.error(f"Invalid Google token: {e}", exc_info=True) + logging.info(f"Invalid Google token: {e}") return jsonify({"status": "invalid", "msg": str(e)}), 401 except GoogleAuthError as e: @@ -375,8 +375,17 @@ def oauth_apple(request: Request) -> ResponseType: audience=APPLE_CLIENT_ID, options={"require": ["iss", "sub", "email"]}, ) + except jwt.exceptions.ExpiredSignatureError as e: + # Expired token (which is an expected condition): + # return 401 - Unauthorized + logging.info(f"Expired Apple token: {e}") + return ( + jsonify({"status": "invalid", "msg": "Invalid token", "error": str(e)}), + 401, + ) except Exception as e: - # Invalid token: return 401 - Unauthorized + # Invalid token (something is probably wrong): + # return 401 - Unauthorized logging.error(f"Invalid Apple token: {e}", exc_info=True) return ( jsonify({"status": "invalid", "msg": "Invalid token", "error": str(e)}), diff --git a/src/basics.py b/src/basics.py index c0cb79e1..557fcb82 100644 --- a/src/basics.py +++ b/src/basics.py @@ -28,7 +28,6 @@ Any, TypeVar, Callable, - Tuple, cast, overload, ) @@ -46,20 +45,16 @@ session, ) from flask.wrappers import Request, Response -from werkzeug.wrappers import Response as WerkzeugResponse from authlib.integrations.flask_client import OAuth # type: ignore -from config import OAUTH_CONF_URL +from config import OAUTH_CONF_URL, RouteType, ResponseType from languages import set_locale from skrafluser import User from skrafldb import Client -# Type definitions + +# Generic placeholder type T = TypeVar("T") -ResponseType = Union[ - str, bytes, Response, WerkzeugResponse, Tuple[str, int], Tuple[Response, int] -] -RouteType = Callable[..., ResponseType] class UserIdDict(TypedDict): diff --git a/src/billing.py b/src/billing.py index f74f7f58..a78d187c 100755 --- a/src/billing.py +++ b/src/billing.py @@ -34,7 +34,8 @@ import requests import firebase -from basics import jsonify, ResponseType +from config import ResponseType +from basics import jsonify from skrafluser import User diff --git a/src/cache.py b/src/cache.py index 27625e75..2d20fd8f 100644 --- a/src/cache.py +++ b/src/cache.py @@ -2,7 +2,7 @@ Cache - Redis cache wrapper for the Netskrafl application - Copyright (C) 2023 Miðeind ehf. + Copyright (C) 2024 Miðeind ehf. Author: Vilhjálmur Þorsteinsson This module wraps Redis caching in a thin wrapper object, @@ -23,14 +23,16 @@ from typing import Dict, Any, Callable, List, Mapping, Optional, Tuple, Union from types import ModuleType +from collections.abc import Collection import os -import redis import json import importlib import logging from datetime import datetime +import redis + # A cache of imported modules, used to create fresh instances # when de-serializing JSON objects @@ -42,6 +44,7 @@ SerializerFunc = Callable[..., Any] SerializerFuncTuple = Tuple[SerializerFunc, SerializerFunc] + def _serialize_dt(dt: datetime) -> DateTimeTuple: return (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) @@ -51,18 +54,22 @@ def _deserialize_dt(args: DateTimeTuple) -> datetime: _serializers: Mapping[Tuple[str, str], SerializerFuncTuple] = { - ("datetime", "datetime"): - (_serialize_dt, _deserialize_dt,), + ("datetime", "datetime"): ( + _serialize_dt, + _deserialize_dt, + ), # Apparently we sometimes get this derived class from the Google # datastore instead of datetime.datetime, so we need an entry for # it. Replacing it with plain datetime.datetime is fine, btw. - ("proto.datetime_helpers", "DatetimeWithNanoseconds"): - (_serialize_dt, _deserialize_dt,), + ("proto.datetime_helpers", "DatetimeWithNanoseconds"): ( + _serialize_dt, + _deserialize_dt, + ), } def serialize(obj: Any) -> Dict[str, Any]: - """ Return a JSON-serializable representation of an object """ + """Return a JSON-serializable representation of an object""" cls = obj.__class__ cls_name = cls.__name__ module_name = cls.__module__ @@ -90,14 +97,14 @@ def serialize(obj: Any) -> Dict[str, Any]: def _dumps(obj: Any) -> str: - """ Returns the given object in JSON format, using the custom serializer - for composite objects """ + """Returns the given object in JSON format, using the custom serializer + for composite objects""" return json.dumps(obj, default=serialize, ensure_ascii=False, separators=(",", ":")) def _loads(j: Optional[str]) -> Any: - """ Return an instance of a serializable class, - initialized from a JSON string """ + """Return an instance of a serializable class, + initialized from a JSON string""" if j is None: return None d: Union[int, str, List[Any], Dict[str, Any]] = json.loads(j) @@ -132,9 +139,8 @@ def _loads(j: Optional[str]) -> Any: class RedisWrapper: - - """ Wrapper class around the Redis client, - making it appear as a simplified memcache instance """ + """Wrapper class around the Redis client, + making it appear as a simplified memcache instance""" def __init__( self, redis_host: Optional[str] = None, redis_port: Optional[int] = None @@ -148,25 +154,29 @@ def __init__( ) def get_redis_client(self) -> redis.Redis[bytes]: - """ Return the underlying Redis client instance """ + """Return the underlying Redis client instance""" return self._client def _call_with_retry( self, func: Callable[..., Any], errval: Any, *args: Any, **kwargs: Any ) -> Any: - """ Call a client function, attempting one retry - upon a connection error """ + """Call a client function, attempting one retry + upon a connection error""" attempts = 0 while attempts < 2: try: ret = func(*args, **kwargs) # No error: return return ret - except redis.exceptions.ConnectionError: + except ( + redis.exceptions.ConnectionError, + redis.exceptions.TimeoutError, + redis.exceptions.TryAgainError, + ) as e: if attempts == 0: - logging.warning("Retrying Redis call after connection error") + logging.warning(f"Retrying Redis call after {repr(e)}") else: - logging.error("Redis connection error persisted after retrying") + logging.error(f"Redis error {repr(e)} persisted after retrying") attempts += 1 return errval @@ -177,9 +187,9 @@ def add( time: Optional[int] = None, namespace: Optional[str] = None, ) -> Any: - """ Add a value to the cache, under the given key - and within the given namespace, with an optional - expiry time in seconds """ + """Add a value to the cache, under the given key + and within the given namespace, with an optional + expiry time in seconds""" if namespace: # Redis doesn't have namespaces, so we prepend the namespace id to the key key = namespace + "|" + key @@ -187,37 +197,115 @@ def add( self._client.set, None, key, _dumps(value), ex=time ) - def set( + set = add # Alias for add() + + def mset( self, - key: str, - value: Any, + mapping: Mapping[str, str], time: Optional[int] = None, namespace: Optional[str] = None, ) -> Any: - """ Set a value in the cache, under the given key - and within the given namespace, with an optional - expiry time in seconds. This is an alias for self.add(). """ - return self.add(key, value, time, namespace) + """Add multiple key-value pairs to the cache, within the given namespace, + with an optional expiry time in seconds""" + keyfunc: Callable[[str], str] + if namespace: + # Redis doesn't have namespaces, so we prepend the namespace id to the key + keyfunc = lambda k: namespace + "|" + k + else: + keyfunc = lambda k: k + mapping = {keyfunc(k): _dumps(v) for k, v in mapping.items()} + return self._call_with_retry(self._client.mset, None, mapping, ex=time) def get(self, key: str, namespace: Optional[str] = None) -> Any: - """ Fetch a value from the cache, under the given key and within - the given namespace. Returns None if the key is not found. """ + """Fetch a value from the cache, under the given key and within + the given namespace. Returns None if the key is not found.""" if namespace: # Redis doesn't have namespaces, so we prepend the namespace id to the key key = namespace + "|" + key return _loads(self._call_with_retry(self._client.get, None, key)) + def mget(self, keys: List[str], namespace: Optional[str] = None) -> Any: + """Fetch multiple values from the cache, within the given namespace. + Returns a list of values, with None for keys that are not found.""" + if namespace: + # Redis doesn't have namespaces, so we prepend the namespace id to the key + keys = [namespace + "|" + k for k in keys] + return [_loads(v) for v in self._call_with_retry(self._client.mget, [], keys)] + def delete(self, key: str, namespace: Optional[str] = None) -> Any: - """ Delete a value from the cache """ + """Delete a value from the cache""" if namespace: # Redis doesn't have namespaces, so we prepend the namespace id to the key key = namespace + "|" + key return self._call_with_retry(self._client.delete, False, key) def flush(self) -> None: - """ Flush all keys from the current cache """ + """Flush all keys from the current cache""" return self._call_with_retry(self._client.flushdb, None) + def init_set( + self, + key: str, + elements: Collection[str], + *, + time: Optional[int] = None, + namespace: Optional[str] = None, + ) -> bool: + """Initialize a fresh set with the given elements, optionally + with an expiry time in seconds""" + if namespace: + # Redis doesn't have namespaces, so we prepend the namespace id to the key + key = namespace + "|" + key + try: + # Start a pipeline (transaction is implicit with MULTI/EXEC) + pipe = self._client.pipeline() + # Delete the set (if it exists) + pipe.delete(key) + # Add users to the set + if elements: + pipe.sadd(key, *elements) + # Set an expiration time of 2 minutes (120 seconds) on the set + if time: + pipe.expire(key, time) + # Execute the pipeline (transaction) + return self._call_with_retry(pipe.execute, None) != None + except redis.exceptions.RedisError as e: + logging.error(f"Redis error in init_set(): {repr(e)}") + return False + + def query_set( + self, + key: str, + elements: List[str], + *, + namespace: Optional[str] = None, + ) -> List[bool]: + """Check for multiple elements in a set using the SMISMEMBER + command, returning a list of booleans""" + if not elements: + return [] + if namespace: + # Redis doesn't have namespaces, so we prepend the namespace id to the key + key = namespace + "|" + key + result: Optional[List[int]] = self._call_with_retry( + self._client.smismember, None, key, elements # type: ignore + ) + if result is None: + # The key is not found: no elements are present in the set + return [False] * len(elements) + return [bool(r) for r in result] + + def random_sample_from_set( + self, key: str, count: int, *, namespace: Optional[str] = None + ) -> List[str]: + """Return a random sample of elements from the set""" + if namespace: + # Redis doesn't have namespaces, so we prepend the namespace id to the key + key = namespace + "|" + key + result = self._call_with_retry(self._client.srandmember, [], key, count) + # The returned list contains bytes, which we need to convert to strings + return [str(u, "utf-8") for u in result] + # Create a global singleton wrapper instance with default parameters, # emulating a part of the memcache API. diff --git a/src/config.py b/src/config.py index f80fbb57..771e9712 100644 --- a/src/config.py +++ b/src/config.py @@ -17,14 +17,33 @@ from __future__ import annotations -from typing import Dict, Literal, Mapping, NotRequired, Optional, TypedDict -from datetime import timedelta +from typing import ( + Any, + Dict, + Literal, + Mapping, + NotRequired, + Optional, + TypedDict, + Union, + Tuple, + Callable, +) +from datetime import datetime, timedelta import os +from werkzeug.wrappers import Response as WerkzeugResponse +from flask.wrappers import Response from flask import json -class FlaskConfig(TypedDict): +# Universal type definitions +ResponseType = Union[ + str, bytes, Response, WerkzeugResponse, Tuple[str, int], Tuple[Response, int] +] +RouteType = Callable[..., ResponseType] + +class FlaskConfig(TypedDict): """The Flask configuration dictionary""" DEBUG: bool @@ -66,11 +85,15 @@ class FlaskConfig(TypedDict): CONSTRAIN_COOKIE_DOMAIN = False # Obtain the domain to use for HTTP session cookies -COOKIE_DOMAIN: Optional[str] = { - "netskrafl": ".netskrafl.is", - "explo-dev": ".explo-dev.appspot.com", - "explo-live": ".explo-live.appspot.com", -}.get(PROJECT_ID, ".netskrafl.is") if CONSTRAIN_COOKIE_DOMAIN else None +COOKIE_DOMAIN: Optional[str] = ( + { + "netskrafl": ".netskrafl.is", + "explo-dev": ".explo-dev.appspot.com", + "explo-live": ".explo-live.appspot.com", + }.get(PROJECT_ID, ".netskrafl.is") + if CONSTRAIN_COOKIE_DOMAIN + else None +) # Open the correct client_secret file for the project (Explo/Netskrafl) CLIENT_SECRET_FILE = { @@ -150,3 +173,32 @@ class FlaskConfig(TypedDict): # How many games a player plays as a provisional player # before becoming an established one ESTABLISHED_MARK: int = 10 + + +class CacheEntryDict(TypedDict): + value: Any + time: datetime + + +def ttl_cache(seconds: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """A simple time-to-live (TTL) caching decorator""" + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + cache: Dict[Tuple[Any, ...], CacheEntryDict] = {} + delta = timedelta(seconds=seconds) + + def wrapped(*args: Any, **kwargs: Any) -> Any: + current_time = datetime.utcnow() + # Check if the value is in the cache and if it has not expired + key = (*args, *kwargs.items()) + val = cache.get(key) + if val is not None and current_time - val["time"] < delta: + return val["value"] + # Call the function and store the result in the cache with the current time + result = func(*args, **kwargs) + cache[key] = {"value": result, "time": current_time} + return result + + return wrapped + + return decorator diff --git a/src/firebase.py b/src/firebase.py index f40c2bf3..10a17d73 100644 --- a/src/firebase.py +++ b/src/firebase.py @@ -2,7 +2,7 @@ Firebase wrapper for Netskrafl - Copyright (C) 2023 Miðeind ehf. + Copyright (C) 2024 Miðeind ehf. Original author: Vilhjálmur Þorsteinsson The Creative Commons Attribution-NonCommercial 4.0 @@ -19,12 +19,14 @@ from typing import ( Any, Callable, + Iterable, Mapping, NotRequired, Optional, List, + Required, + Sequence, TypedDict, - Union, Set, Dict, cast, @@ -33,21 +35,24 @@ import threading import logging from datetime import datetime, timedelta +from flask import Blueprint, request from firebase_admin import App, initialize_app, auth, messaging, db # type: ignore from firebase_admin.exceptions import FirebaseError # type: ignore from firebase_admin.messaging import UnregisteredError # type: ignore -from config import PROJECT_ID, FIREBASE_DB_URL +from config import PROJECT_ID, FIREBASE_DB_URL, running_local, ResponseType, ttl_cache +from languages import SUPPORTED_LOCALES from cache import memcache +OnlineStatusFunc = Callable[[Iterable[str]], Iterable[bool]] + PushMessageCallable = Callable[[str], str] PushDataDict = Mapping[str, Any] class PushMessageDict(TypedDict): - """A message to be sent to a device via a push notification""" title: PushMessageCallable @@ -55,18 +60,21 @@ class PushMessageDict(TypedDict): image: NotRequired[PushMessageCallable] # Image URL -_LIFETIME_MEMORY_CACHE = 1 # Minutes -_LIFETIME_REDIS_CACHE = 5 # Minutes +# Expiration of user online status, in seconds +_CONNECTED_EXPIRY = 2 * 60 # 2 minutes # We don't send push notification messages to sessions # that are older than the following constant indicates _PUSH_NOTIFICATION_CUTOFF = 14 # Days -_USERLIST_LOCK = threading.Lock() - _firebase_app: Optional[App] = None _firebase_app_lock = threading.Lock() +# Create a blueprint for the connect module, which is used to +# update the Redis cache from Firebase presence information +# using a cron job that calls /connect/update +connect_blueprint = connect = Blueprint("connect", __name__, url_prefix="/connect") + def init_firebase_app(): """Initialize a global Firebase app instance""" @@ -163,6 +171,9 @@ def check_presence(user_id: str, locale: str) -> bool: def get_connected_users(locale: str) -> Set[str]: """Return a set of all presently connected users""" + assert ( + len(locale) == 5 and "_" in locale + ), "Locale string is expected to have format 'xx_XX'" try: path = f"/connection/{locale}" ref = cast(Any, db).reference(path, app=_firebase_app) @@ -197,59 +208,73 @@ def create_custom_token(uid: str, valid_minutes: int = 60) -> str: assert False, "Unexpected fall out of loop in firebase.create_custom_token()" -_online_cache: Dict[str, Set[str]] = dict() -_online_ts: Dict[str, datetime] = dict() -_online_counter: int = 0 +class OnlineStatus: + """This class implements a wrapper for queries about the + online status of users by locale. The wrapper talks to the + Redis cache, which is updated from Firebase by a cron job.""" + def __init__(self, locale: str) -> None: + # Assign the Redis key used for the set of online users + # for this locale + self._key = "live:" + locale -def online_users(locale: str) -> Set[str]: - """Obtain a set of online users, by their user ids""" + def users_online(self, user_ids: Iterable[str]) -> List[bool]: + """Return a list of booleans, one for each passed user_id""" + list_of_ids = list(user_ids) + if any(s for s in list_of_ids): + return memcache.query_set(self._key, list_of_ids) + # All user ids are empty strings (probably robots): + # save ourselves the Redis call and return a list of False values + return [False] * len(list_of_ids) - global _online_cache, _online_ts, _online_counter + def user_online(self, user_id: str) -> bool: + """Return True if a user is online""" + return self.users_online([user_id])[0] - # First, use a per-process in-memory cache, having a lifetime of 1 minute - now = datetime.utcnow() - if ( - locale in _online_ts - and locale in _online_cache - and _online_ts[locale] > now - timedelta(minutes=_LIFETIME_MEMORY_CACHE) - ): - return _online_cache[locale] + @ttl_cache(seconds=30) # Cache this data for 30 seconds + @staticmethod + def _get_random_sample(key: str, n: int) -> List[str]: + """Return a cached random sample of <= n online users""" + return memcache.random_sample_from_set(key, n) - # Serialize access to the connected user list - # !!! TBD: Convert this to a background task that periodically - # updates the Redis cache from the Firebase database - with _USERLIST_LOCK: + def random_sample(self, n: int) -> List[str]: + """Return a random sample of <= n online users""" + return OnlineStatus._get_random_sample(self._key, n) - # Use the distributed Redis cache, having a lifetime of 5 minutes - online: Union[None, Set[str], List[str]] = memcache.get( - "live:" + locale, namespace="userlist" - ) - if online is None: - # Not found: do a Firebase query, which returns a set - online = get_connected_users(locale) - # Store the result as a list in the Redis cache, with a timeout - memcache.set( - "live:" + locale, - list(online), - time=_LIFETIME_REDIS_CACHE * 60, # Currently 5 minutes - namespace="userlist", - ) - _online_counter += 1 - if _online_counter >= 6: - # Approximately once per half hour (6 * 5 minutes), - # log number of connected users - logging.info(f"Connected users in locale {locale} are {len(online)}") - _online_counter = 0 - else: - # Convert the cached list back into a set - online = set(online) +# Collection of OnlineStatus instances, one for each game locale +_online_status: Dict[str, OnlineStatus] = dict() + - _online_cache[locale] = online - _online_ts[locale] = now +def online_status(locale: str) -> OnlineStatus: + """Obtain an user online status wrapper for a particular locale""" + global _online_status + if (oc := _online_status.get(locale)) is None: + oc = OnlineStatus(locale) + _online_status[locale] = oc + return oc - return online + +class UserLiveDict(TypedDict): + """A dictionary that has at least a 'live' property, of type bool""" + + live: Required[bool] + + +def set_online_status( + user_id_prop: str, + users: Sequence[UserLiveDict], + func_online_status: OnlineStatusFunc, +) -> None: + """Set the live (online) status of the users in the list""" + # Call the function to get the online status of the users + # TODO: We are passing in empty strings for robot players, which is a + # fairly common occurrence. The robots are never marked as online, so + # the Redis roundtrip is unnecessary. We should optimize this. + online = func_online_status(cast(str, u.get(user_id_prop) or "") for u in users) + # Set the live status of the users in the list + for u, o in zip(users, online): + u["live"] = bool(o) def push_notification( @@ -348,3 +373,32 @@ def push_to_user( except Exception as e: logging.warning(f"Exception [{repr(e)}] raised in firebase.push_to_user()") return False + + +@connect.route("/update", methods=["GET"]) +def update() -> ResponseType: + """Update the Redis cache from Firebase presence information. + This method is invoked from a cron job that fetches /connect/update.""" + # Check that we are actually being called internally by + # a GAE cron job or a cloud scheduler + headers = request.headers + task_queue = headers.get("X-AppEngine-QueueName", "") != "" + cron_job = headers.get("X-Appengine-Cron", "") == "true" + cloud_scheduler = request.environ.get("HTTP_X_CLOUDSCHEDULER", "") == "true" + if not any((running_local, task_queue, cloud_scheduler, cron_job)): + # Not called internally, by a cron job or a cloud scheduler + return "Error", 403 # Forbidden + try: + # Get the list of all connected users from Firebase, + # for each supported game locale + for locale in SUPPORTED_LOCALES: + online = get_connected_users(locale) + # Store the result in a Redis set, with an expiry + if not memcache.init_set("live:" + locale, online, time=_CONNECTED_EXPIRY): + logging.warning( + f"Unable to update Redis connection cache for locale {locale}" + ) + return "OK", 200 + except Exception as e: + logging.warning(f"Exception [{repr(e)}] raised in firebase.update()") + return "Error", 500 diff --git a/src/languages.py b/src/languages.py index 40d8b8be..c27dd6be 100755 --- a/src/languages.py +++ b/src/languages.py @@ -53,24 +53,11 @@ _T = TypeVar("_T") -# Map from a generic locale ('en') to a -# more specific default locale ('en_US') -NONGENERIC_DEFAULT: Mapping[str, str] = { - "is": "is_IS", # Icelandic - "en": "en_US", # English (US) - "pl": "pl_PL", # Polish - "nb": "nb_NO", # Norwegian Bokmål - "no": "nb_NO", # Norwegian Bokmål - # We do not map from Norwegian Nynorsk ('nn') to Bokmål ('nb') - "ga": "ga_IE", # Gaeilge/Irish -} - DEFAULT_LANGUAGE = "is_IS" if PROJECT_ID == "netskrafl" else "en_US" DEFAULT_BOARD_TYPE = "standard" if PROJECT_ID == "netskrafl" else "explo" class Alphabet(abc.ABC): - """Base class for alphabets particular to languages, i.e. the letters used in a game""" @@ -184,7 +171,6 @@ def format_timestamp_short(ts: datetime) -> str: class _IcelandicAlphabet(Alphabet): - """The Icelandic alphabet""" order = "aábdðeéfghiíjklmnoóprstuúvxyýþæö" @@ -201,7 +187,6 @@ class _IcelandicAlphabet(Alphabet): class _EnglishAlphabet(Alphabet): - """The English alphabet""" order = "abcdefghijklmnopqrstuvwxyz" @@ -218,7 +203,6 @@ class _EnglishAlphabet(Alphabet): class _PolishAlphabet(Alphabet): - """The Polish alphabet""" order = "aąbcćdeęfghijklłmnńoóprsśtuwyzźż" @@ -234,7 +218,6 @@ class _PolishAlphabet(Alphabet): class _NorwegianAlphabet(Alphabet): - """The Norwegian alphabet""" # Note: Ä, Ö, Ü, Q, X and Z are not included in the @@ -254,7 +237,6 @@ class _NorwegianAlphabet(Alphabet): class TileSet(abc.ABC): - """Abstract base class for tile sets. Concrete classes are found below.""" # The following will be overridden in derived classes @@ -285,7 +267,6 @@ def num_tiles(cls): class OldTileSet(TileSet): - """ The old (original) Icelandic tile set. This tile set is awful. We don't recommend using it. @@ -378,7 +359,6 @@ class OldTileSet(TileSet): class NewTileSet(TileSet): - """ The new Icelandic tile set, created by Skraflfélag Íslands and Miðeind ehf. This tile set is used by default in Netskrafl @@ -471,7 +451,6 @@ class NewTileSet(TileSet): class EnglishTileSet(TileSet): - """ Original ('classic') English tile set. Only included for documentation and reference; not used in Explo. @@ -547,7 +526,6 @@ class EnglishTileSet(TileSet): class NewEnglishTileSet(TileSet): - """ New English Tile Set - Copyright (C) Miðeind ehf. This set was created by a proprietary method, @@ -633,7 +611,6 @@ class NewEnglishTileSet(TileSet): class PolishTileSet(TileSet): - """Polish tile set""" alphabet = PolishAlphabet @@ -719,7 +696,6 @@ class PolishTileSet(TileSet): class OriginalNorwegianTileSet(TileSet): - """ This tile set is presently not used by Netskrafl or Explo. It is only included here for documentation and reference. @@ -796,7 +772,6 @@ class OriginalNorwegianTileSet(TileSet): class NewNorwegianTileSet(TileSet): - """ The new, improved Norwegian tile set was designed by Taral Guldahl Seierstad and is used here @@ -954,7 +929,7 @@ class NewNorwegianTileSet(TileSet): } # Set of all supported locale codes -SUPPORTED_LOCALES = frozenset( +RECOGNIZED_LOCALES = frozenset( TILESETS.keys() | ALPHABETS.keys() | VOCABULARIES.keys() @@ -962,6 +937,37 @@ class NewNorwegianTileSet(TileSet): | LANGUAGES.keys() ) +# Map from recognized locales ('en_ZA', 'no_NO') to the +# currently supported set of game locales ('en_GB', 'nb_NO') +RECOGNIZED_TO_SUPPORTED_LOCALES: Mapping[str, str] = { + "is": "is_IS", # Icelandic + "en": "en_US", # English (US) + "en_AU": "en_GB", # English (UK) + "en_BZ": "en_GB", + "en_CA": "en_GB", + "en_IE": "en_GB", + "en_IN": "en_GB", + "en_JM": "en_GB", + "en_MY": "en_GB", + "en_NZ": "en_GB", + "en_PH": "en_GB", + "en_SG": "en_GB", + "en_TT": "en_GB", + "en_UK": "en_GB", + "en_ZA": "en_GB", + "en_ZW": "en_GB", + "pl": "pl_PL", # Polish + "nb": "nb_NO", # Norwegian Bokmål + "no": "nb_NO", # Norwegian generic + "nn": "nb_NO", # Norwegian Nynorsk + # "ga": "ga_IE", # Gaeilge/Irish !!! TODO: Uncomment this when Irish is supported +} + +# Set of all supported game locales +# This set is used for player presence management +# and to group players together into communities +SUPPORTED_LOCALES = frozenset(RECOGNIZED_TO_SUPPORTED_LOCALES.values()) + class Locale(NamedTuple): lc: str @@ -995,13 +1001,11 @@ class Locale(NamedTuple): @overload -def dget(d: Dict[str, _T], key: str) -> Optional[_T]: - ... +def dget(d: Dict[str, _T], key: str) -> Optional[_T]: ... @overload -def dget(d: Dict[str, _T], key: str, default: _T) -> _T: - ... +def dget(d: Dict[str, _T], key: str, default: _T) -> _T: ... def dget(d: Dict[str, _T], key: str, default: Optional[_T] = None) -> Optional[_T]: @@ -1047,21 +1051,21 @@ def language_for_locale(lc: str) -> str: def to_supported_locale(lc: str) -> str: """Return the locale code if it is supported, otherwise its parent locale, or the fallback DEFAULT_LOCALE if none of the above is found""" - # Defensive programming: we always use underscores in locale codes if not lc: return DEFAULT_LOCALE + # Defensive programming: we always use underscores in locale codes lc = lc.replace("-", "_") - found = lc in SUPPORTED_LOCALES + found = lc in RECOGNIZED_LOCALES while not found: lc = "".join(lc.split("_")[0:-1]) if lc: - found = lc in SUPPORTED_LOCALES + found = lc in RECOGNIZED_LOCALES else: break if found: # We may be down to a generic locale such as 'en' or 'pl'. # Go back to a more specific locale, if available. - return NONGENERIC_DEFAULT.get(lc, lc) + return RECOGNIZED_TO_SUPPORTED_LOCALES.get(lc, lc) # Not found at all: return a global generic locale return DEFAULT_LOCALE diff --git a/src/main.py b/src/main.py index 71da88c9..15e23ab1 100755 --- a/src/main.py +++ b/src/main.py @@ -50,6 +50,7 @@ from config import ( FlaskConfig, + ResponseType, DEFAULT_LOCALE, running_local, host, @@ -68,9 +69,8 @@ from basics import ( ndb_wsgi_middleware, init_oauth, - ResponseType, ) -from firebase import init_firebase_app +from firebase import init_firebase_app, connect_blueprint from dawgdictionary import Wordbase from api import api_blueprint from web import STATIC_FOLDER, web_blueprint @@ -172,6 +172,7 @@ app.register_blueprint(api_blueprint) app.register_blueprint(web_blueprint) app.register_blueprint(stats_blueprint) +app.register_blueprint(connect_blueprint) # Initialize the OAuth wrapper init_oauth(app) diff --git a/src/skraflstats.py b/src/skraflstats.py index 4575cbf1..764a4993 100755 --- a/src/skraflstats.py +++ b/src/skraflstats.py @@ -38,8 +38,7 @@ from flask import request, Blueprint from flask.wrappers import Request -from basics import ResponseType -from config import running_local +from config import running_local, ResponseType from cache import memcache from skrafldb import ( Context, diff --git a/src/skrafluser.py b/src/skrafluser.py index 03b26bb0..027a9a14 100644 --- a/src/skrafluser.py +++ b/src/skrafluser.py @@ -14,6 +14,7 @@ """ from __future__ import annotations +import functools import logging from typing import ( @@ -41,7 +42,7 @@ from config import EXPLO_CLIENT_SECRET, DEFAULT_LOCALE, PROJECT_ID from languages import Alphabet, to_supported_locale -from firebase import online_users +from firebase import online_status, set_online_status from skrafldb import ( PrefsDict, TransactionModel, @@ -79,6 +80,9 @@ class UserSummaryDict(TypedDict): new_board: bool +UserSummaryList = List[UserSummaryDict] + + class UserLoginDict(TypedDict): """Summary data about a login event""" @@ -164,6 +168,9 @@ class StatsSummaryDict(TypedDict): # Nickname character replacement pattern NICKNAME_STRIP = re.compile(r"[\W_]+", re.UNICODE) +# Use a partial function to set the online status within user summaries +set_online_status_for_summaries = functools.partial(set_online_status, "uid") + def make_login_dict( user_id: str, @@ -776,10 +783,10 @@ def blocked(self) -> Set[str]: def _summary_list( self, uids: Iterable[str], *, is_favorite: bool = False - ) -> List[UserSummaryDict]: + ) -> UserSummaryList: """Return a list of summary data about a set of users""" - result: List[UserSummaryDict] = [] - online = online_users(self.locale) + result: UserSummaryList = [] + online = online_status(self.locale) for uid in uids: u = User.load_if_exists(uid) if u is not None: @@ -798,10 +805,11 @@ def _summary_list( ready_timed=u.is_ready_timed(), fairplay=u.fairplay(), favorite=is_favorite or self.has_favorite(uid), - live=uid in online, + live=False, # Will be filled in later new_board=u.new_board(), ) ) + set_online_status_for_summaries(result, online.users_online) return result def list_blocked(self) -> List[UserSummaryDict]: @@ -1180,7 +1188,8 @@ def stats(uid: Optional[str], cuser: User) -> Tuple[int, Optional[UserProfileDic # Is the user online in the current user's locale? live = True # The current user is always live if uid != cuid: - live = uid in online_users(cuser.locale) + online = online_status(cuser.locale or DEFAULT_LOCALE) + live = online.user_online(uid) profile["live"] = live # Include info on whether this user is a favorite of the current user diff --git a/src/web.py b/src/web.py index ba0f6bdd..e3f11700 100644 --- a/src/web.py +++ b/src/web.py @@ -43,7 +43,7 @@ from flask.globals import current_app from authlib.integrations.base_client.errors import MismatchingStateError # type: ignore -from config import DEFAULT_LOCALE, PROJECT_ID, running_local, VALID_ISSUERS +from config import DEFAULT_LOCALE, PROJECT_ID, running_local, VALID_ISSUERS, ResponseType from basics import ( UserIdDict, current_user, @@ -56,7 +56,6 @@ clear_session_userid, RequestData, max_age, - ResponseType, ) from skrafluser import User, UserLoginDict import firebase @@ -214,7 +213,7 @@ def signup() -> ResponseType: @web.route("/skilmalar", methods=["GET"]) def skilmalar() -> ResponseType: """Terms & conditions""" - return render_template("skilmalar.html") + return render_template("skilmalar.html", user=session_user()) @web.route("/billing", methods=["GET", "POST"]) @@ -271,7 +270,7 @@ def page() -> ResponseType: @web.route("/greet") def greet() -> ResponseType: """Handler for the greeting page""" - return render_template("login-explo.html") + return render_template("login-explo.html", user=None) @web.route("/login") @@ -286,7 +285,7 @@ def login() -> ResponseType: @web.route("/login_error") def login_error() -> ResponseType: """An error during login: probably cookies or popups are not allowed""" - return render_template("login-error.html") + return render_template("login-error.html", user=None) @web.route("/logout", methods=["GET"]) @@ -372,7 +371,7 @@ def admin_loaduser() -> ResponseType: @web.route("/admin/main") def admin_main() -> ResponseType: """Show main administration page""" - return render_template("admin.html", project_id=PROJECT_ID) + return render_template("admin.html", user=None, project_id=PROJECT_ID) # noinspection PyUnusedLocal diff --git a/static/assets/messages.json b/static/assets/messages.json index 2a4bf8a1..55666abe 100644 --- a/static/assets/messages.json +++ b/static/assets/messages.json @@ -100,8 +100,8 @@ }, "explain_sound": { "is": "Stillir hvort hljóðmerki heyrast t.d. þegar andstæðingur leikur og þegar sigur vinnst", - "en": "Controls whether sounds are played, e.g. when an opponent moves or when you win", - "nb": "Kontrollerer om lyder spilles av, f.eks. når en motstander flytter eller når du vinner", + "en": "Controls whether sounds are played, e.g. when an opponent makes a move or when you win", + "nb": "Kontrollerer om lyder spilles, f.eks. når en motstander gjør et trekk eller når du vinner", "pl": "Kontroluje, czy dźwięki są odtwarzane, np. gdy przeciwnik wykonuje ruch lub gdy wygrywasz", "ga": "Rialaíonn sé an seinntear fuaimeanna, m.sh. nuair a bhogann freasúra nó nuair a bhuaigh tú" }, @@ -191,7 +191,7 @@ }, " um margföldunargildi reita er sýndur við borðið": { "en": " with square multipliers is shown beside the board", - "nb": " med rute multiplikatorer vises ved siden av brettet", + "nb": " med rute-multiplikatorer vises ved siden av brettet", "pl": " z mnożnikami kwadratów jest pokazany obok planszy", "ga": " le hiolraitheoirí cearnóg le feiceáil in aice leis an mbord" }, @@ -608,14 +608,14 @@ }, "Viðureignir sem standa yfir": { "en": "Games in progress", - "nb": "Spill pågår", + "nb": "Pågående spill", "pl": "Trwające gry", "ga": "Cluichí ar siúl" }, "click_on_game": { "is": " - smelltu á viðureign til að skoða stöðuna og leika ef ", "en": " - click on a game to view it and make a move if ", - "nb": " - klikk på et spill for å se det og gjøre et trekk hvis ", + "nb": " - klikk på et spill for å åpne det og gjøre et trekk hvis ", "pl": " - kliknij na grę, aby ją zobaczyć i wykonać ruch, jeśli ", "ga": " - cliceáil ar chluiche chun é a fheiceáil agus bogadh má " }, @@ -671,7 +671,7 @@ "click_to_review": { "is": " - smelltu á viðureign til að skoða hana og rifja upp", "en": " - click on a game to review it", - "nb": " - klikk på et spill for å se det og gå gjennom det", + "nb": " - klikk på et spill for å åpne det og gå gjennom det", "pl": " - kliknij na grę, aby ją przejrzeć", "ga": " - cliceáil ar chluiche chun athbhreithniú a dhéanamh air" }, diff --git a/static/src/mithril.d.ts b/static/src/mithril.d.ts index 92ca2bbf..1aee2b60 100644 --- a/static/src/mithril.d.ts +++ b/static/src/mithril.d.ts @@ -118,6 +118,7 @@ interface VnodeAttrs { style?: string | object; title?: string; tabindex?: number; + autocomplete?: string; href?: string; src?: string; alt?: string; diff --git a/static/src/page.ts b/static/src/page.ts index 8ce590d0..0b83ed5e 100644 --- a/static/src/page.ts +++ b/static/src/page.ts @@ -642,7 +642,8 @@ class View { initialValue: user.nickname || "", class: "username", maxlength: 15, - id: "nickname" + id: "nickname", + // autocomplete: "nickname", // Chrome doesn't like this } ), nbsp(), m("span.asterisk", "*") @@ -658,7 +659,8 @@ class View { initialValue: user.full_name || "", class: "fullname", maxlength: 32, - id: "full_name" + id: "full_name", + autocomplete: "name", } ) ] @@ -673,7 +675,8 @@ class View { initialValue: user.email || "", class: "email", maxlength: 32, - id: "email" + id: "email", + autocomplete: "email", } ) ] @@ -4318,6 +4321,7 @@ const TextInput: ComponentFunc<{ maxlength: number; id: string; tabindex: number; + autocomplete?: string; }> = (initialVnode) => { // Generic text input field @@ -4337,6 +4341,7 @@ const TextInput: ComponentFunc<{ name: vnode.attrs.id, maxlength: vnode.attrs.maxlength, tabindex: vnode.attrs.tabindex, + autocomplete: vnode.attrs.autocomplete, value: text, oninput: (ev) => { text = (ev.target as HTMLInputElement).value + ""; } } diff --git a/templates/container-base-explo.html b/templates/container-base-explo.html index b4a27a7c..d632ae23 100644 --- a/templates/container-base-explo.html +++ b/templates/container-base-explo.html @@ -1,5 +1,5 @@ - + diff --git a/utils/dawgbuilder.py b/utils/dawgbuilder.py index 758d7c32..0402b5d0 100644 --- a/utils/dawgbuilder.py +++ b/utils/dawgbuilder.py @@ -1059,8 +1059,13 @@ def run_norwegian_filter() -> None: nsf2023 = PackedDawgDictionary(current_alphabet()) nsf2023.load(rpath("nsf2023.bin.dawg")) - # The name of the input file containing - # the list of frequent Norwegian words + # The name of the input file containing the list of frequent Norwegian words. + # This file is available for download from + # https://wortschatz.uni-leipzig.de/en/download/Norwegian%20Bokm%C3%A5l + # under the CC-BY license. It is under copyright as follows: + # © 2024 Universität Leipzig + # / Sächsische Akademie der Wissenschaften / InfAI + # ...and used here, with thanks, under the terms of the license. source = rpath("nob-no_web_2020_300K-words.txt") # Define our tasks