MIL-011: Menu bar

Review record RC-066 (Go, autonomous mode, not independent).

Task: MIL-011#1
Task: MIL-011#2
Task: MIL-011#3
Task: MIL-011#4
Task: MIL-011#5
Task: MIL-011#6
Task: MIL-011#7
Task: MIL-011#8
This commit is contained in:
2026-10-15 09:06:47 +08:00
parent 5d626dc890
commit ce4aacb4bc
28 changed files with 1450 additions and 35 deletions
+74
View File
@@ -78,6 +78,12 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
## @brief File name of the words still to learn.
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
## @brief File name of the words to learn of another word list; {stem} is its stem.
PROGRESS_FILE_NAME_TEMPLATE: Final[str] = "words_to_learn_{stem}.csv"
## @brief Pattern of the characters replaced in a stem (not letter, digit, - or _).
PROGRESS_STEM_FORBIDDEN: Final[str] = r"[^\w-]"
## @brief Replacement of a forbidden character in the progress file name.
PROGRESS_STEM_REPLACEMENT: Final[str] = "_"
## @brief Root folder of the project, for a source run.
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
@@ -105,3 +111,71 @@ CARD_BACK_IMAGE_NAME: Final[str] = "card_back.png"
RIGHT_IMAGE_NAME: Final[str] = "right.png"
## @brief File name of the wrong button image.
WRONG_IMAGE_NAME: Final[str] = "wrong.png"
## @brief Label of the File menu.
MENU_FILE_LABEL: Final[str] = "File"
## @brief Label of the File, Open entry.
MENU_OPEN_LABEL: Final[str] = "Open..."
## @brief Label of the File, Change level entry.
MENU_CHANGE_LEVEL_LABEL: Final[str] = "Change level..."
## @brief Label of the File, Reset word list entry.
MENU_RESET_LABEL: Final[str] = "Reset word list..."
## @brief Label of the File, Exit entry.
MENU_EXIT_LABEL: Final[str] = "Exit"
## @brief Label of the About menu.
MENU_ABOUT_LABEL: Final[str] = "About"
## @brief Label of the About, Contact submenu.
MENU_CONTACT_LABEL: Final[str] = "Contact"
## @brief Index of the mnemonic letter of the File menu.
MENU_FILE_UNDERLINE: Final[int] = 0
## @brief Index of the mnemonic letter of the Open entry.
MENU_OPEN_UNDERLINE: Final[int] = 0
## @brief Index of the mnemonic letter of the Change level entry.
MENU_CHANGE_LEVEL_UNDERLINE: Final[int] = 0
## @brief Index of the mnemonic letter of the Reset word list entry.
MENU_RESET_UNDERLINE: Final[int] = 0
## @brief Index of the mnemonic letter of the Exit entry.
MENU_EXIT_UNDERLINE: Final[int] = 1
## @brief Index of the mnemonic letter of the About menu.
MENU_ABOUT_UNDERLINE: Final[int] = 0
## @brief Index of the mnemonic letter of the Contact submenu.
MENU_CONTACT_UNDERLINE: Final[int] = 0
## @brief Command name of the Open entry, as the menu view maps it.
MENU_COMMAND_OPEN: Final[str] = "open_word_list"
## @brief Command name of the Change level entry.
MENU_COMMAND_CHANGE_LEVEL: Final[str] = "change_level"
## @brief Command name of the Reset word list entry.
MENU_COMMAND_RESET: Final[str] = "reset_word_list"
## @brief Command name of the Exit entry.
MENU_COMMAND_EXIT: Final[str] = "exit_program"
## @brief Name of the author shown under About, Contact.
CONTACT_NAME: Final[str] = "Jens Tirsvad Nielsen"
## @brief Title of the author shown under About, Contact.
CONTACT_TITLE: Final[str] = "Software engineer"
## @brief LinkedIn page of the author.
CONTACT_URL: Final[str] = "https://www.linkedin.com/in/tirsvad74"
## @brief Text of the contact entry: name and title; {name} and {title}.
CONTACT_LABEL_TEMPLATE: Final[str] = "{name}, {title}"
## @brief Title of the open file dialog.
OPEN_DIALOG_TITLE: Final[str] = "Open a word list"
## @brief File types of the open file dialog: description and pattern.
OPEN_FILE_TYPES: Final[tuple[tuple[str, str], ...]] = (
("CSV files", "*.csv"),
("All files", "*.*"),
)
## @brief Title of the reset confirmation.
RESET_CONFIRM_TITLE: Final[str] = "Reset word list"
## @brief Question of the reset confirmation; {name} is the word list file name.
RESET_CONFIRM_TEMPLATE: Final[str] = (
"Start {name} from the beginning? All words will be to learn again."
)
## @brief Title of the message that tells a word list could not be opened.
OPEN_ERROR_TITLE: Final[str] = "Cannot open word list"
## @brief Message of a word list that could not be opened; {name} and {reason}.
OPEN_ERROR_TEMPLATE: Final[str] = "{name} could not be used.\n{reason}"
## @brief Title of the message that tells a word list could not be reset.
RESET_ERROR_TITLE: Final[str] = "Cannot reset word list"
## @brief Message of a word list that could not be reset; {name} and {reason}.
RESET_ERROR_TEMPLATE: Final[str] = "{name} could not be reset.\n{reason}"
@@ -27,3 +27,11 @@ class ProgressRepository(Protocol):
@return Nothing.
"""
...
def reset(self) -> None:
"""Forget the saved words to learn, so every word is to learn again.
@return Nothing.
@throws OSError When the saved progress cannot be removed.
"""
...
@@ -42,3 +42,11 @@ class CsvProgressRepository:
@return Nothing.
"""
write_word_list(self._path, deck.language_pair, deck.cards)
def reset(self) -> None:
"""Delete the words to learn file; nothing happens when it is absent.
@return Nothing.
@throws OSError When the file cannot be deleted.
"""
self._path.unlink(missing_ok=True)
@@ -67,7 +67,8 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
@param path The CSV file: a header of two language codes, then one card a row.
@return The language pair and the cards in file order.
@throws VocabularyNotFoundError When the file does not exist.
@throws VocabularyFormatError When the file is empty or a row is malformed.
@throws VocabularyFormatError When the file is empty, not UTF-8 text, cannot
be read or a row is malformed.
"""
pair: LanguagePair | None = None
cards: list[Card] = []
@@ -83,6 +84,10 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
cards.append(_parse_card(path, reader.line_num, row))
except FileNotFoundError as error:
raise VocabularyNotFoundError(f"word list not found: {path}") from error
except UnicodeDecodeError as error:
raise VocabularyFormatError(f"{path}: file is not UTF-8 text") from error
except OSError as error:
raise VocabularyFormatError(f"{path}: cannot be read: {error}") from error
if pair is None:
raise _format_error(path, 1, "file is empty")
return pair, tuple(cards)
@@ -0,0 +1,30 @@
"""Name of the words to learn file of a word list.
@file progress_file_name.py
@brief Keeps the progress of every word list in a file of its own.
"""
import re
from pathlib import Path
from flash_card.constants import (
PROGRESS_FILE_NAME,
PROGRESS_FILE_NAME_TEMPLATE,
PROGRESS_STEM_FORBIDDEN,
PROGRESS_STEM_REPLACEMENT,
VOCABULARY_FILE_NAME,
)
def progress_file_name(list_path: Path) -> str:
"""Name the words to learn file of a word list.
@param list_path Path of the word list file.
@return The default file name for the delivered list, else a name made of
the list's file name stem with other characters than letters, digits,
hyphen and underscore replaced by an underscore.
"""
if list_path.name == VOCABULARY_FILE_NAME:
return PROGRESS_FILE_NAME
stem = re.sub(PROGRESS_STEM_FORBIDDEN, PROGRESS_STEM_REPLACEMENT, list_path.stem)
return PROGRESS_FILE_NAME_TEMPLATE.format(stem=stem)
+120 -17
View File
@@ -1,7 +1,7 @@
"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, shows the level screen and then the card screen.
@brief Creates the window and menu bar, shows the level screen and then the card.
"""
import argparse
@@ -19,7 +19,6 @@ from flash_card.constants import (
BUNDLED_VOCABULARY_PATH,
DEFAULT_DATA_DIR,
LEVEL_ARGUMENT_VALUES,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
from flash_card.domain.entities.card import Card
@@ -33,6 +32,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
@@ -41,6 +43,8 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
)
from flash_card.presentation.features.level.level_view import LevelView
from flash_card.presentation.features.level.level_view_model import LevelViewModel
from flash_card.presentation.features.menu.menu_view import MenuView
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
@@ -64,34 +68,42 @@ def build_speech(enabled: bool) -> SpeechPort:
class Application:
"""The running program: one window that shows the level screen, then the card."""
"""The running program: one window with a menu, the level screen and the card."""
def __init__(
self,
root: tk.Tk,
vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
speech: SpeechPort,
choose: Callable[[Sequence[Card]], Card] = random.choice,
choose: Callable[[Sequence[Card]], Card],
repositories_for: Callable[
[Path], tuple[VocabularyRepository, ProgressRepository]
],
list_path: Path,
) -> None:
"""Create the application on a window; no screen is shown yet.
@param root The Tk root window.
@param vocabulary_repository Source of the delivered word list.
@param progress_repository Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
@param choose Picks one card from a non-empty sequence.
@param repositories_for Builds the word list and progress repositories of
a word list file; the composition root supplies it.
@param list_path The word list file in use.
"""
## @brief The Tk root window.
self.root = root
## @brief Source of the delivered word list.
self._vocabulary_repository = vocabulary_repository
## @brief Store of the words still to learn.
self._progress_repository = progress_repository
## @brief Speaks the front text of each drawn card.
self._speech = speech
## @brief Picks one card from a non-empty sequence.
self._choose = choose
## @brief Builds the repositories of a word list file.
self._repositories_for = repositories_for
## @brief The word list file in use.
self._list_path = list_path
vocabulary_repository, progress_repository = repositories_for(list_path)
## @brief Source of the word list in use.
self._vocabulary_repository = vocabulary_repository
## @brief Store of the words still to learn of the word list in use.
self._progress_repository = progress_repository
## @brief The screen shown now, or None before the first one.
self._view: LevelView | FlashCardView | None = None
## @brief The exit code of the program.
@@ -113,6 +125,22 @@ class Application:
"""
return self._exit_code
@property
def word_list_name(self) -> str:
"""Return the file name of the word list in use.
@return The file name.
"""
return self._list_path.name
@property
def word_list_folder(self) -> Path:
"""Return the folder of the word list in use.
@return The folder that holds the word list file.
"""
return self._list_path.parent
def show_level_screen(self) -> None:
"""Show the level screen; a choice starts the session.
@@ -134,9 +162,60 @@ class Application:
self._choose,
level,
)
self._close_screen()
self._view = FlashCardView(self.root, FlashCardViewModel(session))
def open_word_list(self, path: Path) -> None:
"""Switch to another word list and show the level screen.
The file is loaded first; nothing changes when it is unusable.
@param path The CSV word list file.
@return Nothing.
@throws VocabularyError When the file is missing or malformed.
"""
vocabulary_repository, progress_repository = self._repositories_for(path)
vocabulary_repository.load()
self._vocabulary_repository = vocabulary_repository
self._progress_repository = progress_repository
self._list_path = path
self.change_level()
def change_level(self) -> None:
"""Drop the current screen and its timer and show the level screen.
@return Nothing.
"""
self._close_screen()
self.show_level_screen()
def reset_word_list(self) -> None:
"""Forget the progress of the word list in use and show the level screen.
@return Nothing.
@throws OSError When the progress cannot be removed.
"""
self._progress_repository.reset()
self.change_level()
def exit_application(self) -> None:
"""Drop the current screen and its timer and close the window.
@return Nothing.
"""
self._close_screen()
self.root.destroy()
def _close_screen(self) -> None:
"""Remove the screen shown now, if any.
@return Nothing.
"""
if isinstance(self._view, LevelView):
self._view.destroy()
self._view = FlashCardView(self.root, FlashCardViewModel(session))
elif isinstance(self._view, FlashCardView):
self._view.close()
self._view = None
def _choose_level(self, level: Level) -> None:
"""Start the session after a click; close the window when it fails.
@@ -163,20 +242,42 @@ def resolve_data_files(data_dir: Path | None) -> tuple[Path, Path]:
vocabulary = folder / VOCABULARY_FILE_NAME
if data_dir is None and not vocabulary.exists():
vocabulary = BUNDLED_VOCABULARY_PATH
return vocabulary, folder / PROGRESS_FILE_NAME
return vocabulary, folder / progress_file_name(vocabulary)
def _repository_builder(
folder: Path,
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
"""Make the function that builds the CSV repositories of a word list.
@param folder The data folder that holds every progress file.
@return A function from a word list path to its repositories; the progress
file is in the data folder, named after the list.
"""
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
return (
CsvVocabularyRepository(list_path),
CsvProgressRepository(folder / progress_file_name(list_path)),
)
return build
def create_application(
data_dir: Path | None = None,
speech_enabled: bool = True,
level: Level | None = None,
word_list: Path | None = None,
) -> Application:
"""Create the window and show the level screen, or start at the given level.
"""Create the window with its menu bar and show the level screen, or start.
@param data_dir Folder of the data files, or None for the default folder
with the delivered word list as fallback.
@param speech_enabled False to run without spoken words.
@param level The level to start at, or None to show the level screen.
@param word_list A word list file to use instead of the one of the data
folder, or None; its progress file is still in the data folder.
@return The application, its window in the root attribute.
@throws VocabularyError When a level is given and the word list or saved
progress is unusable.
@@ -185,10 +286,12 @@ def create_application(
root = tk.Tk()
application = Application(
root,
CsvVocabularyRepository(vocabulary_path),
CsvProgressRepository(progress_path),
build_speech(speech_enabled),
random.choice,
_repository_builder(progress_path.parent),
vocabulary_path if word_list is None else word_list,
)
MenuView(root, MenuViewModel(), application)
if level is None:
application.show_level_screen()
return application
@@ -146,6 +146,15 @@ class FlashCardView:
"""
return self._card_back_image
def close(self) -> None:
"""Cancel the flip timer and remove the widgets of the screen.
@return Nothing.
"""
self._cancel_flip_timer()
for widget in (self.canvas, self.wrong_button, self.right_button):
widget.destroy()
def next_card(self) -> None:
"""Show the next card front and restart the flip timer.
@@ -0,0 +1 @@
"""Menu bar feature."""
@@ -0,0 +1,241 @@
"""View of the menu bar.
@file menu_view.py
@brief Builds the menu bar of the wireframe and asks the dialogs of its commands.
"""
import logging
import tkinter as tk
import webbrowser
from collections.abc import Callable
from pathlib import Path
from tkinter import filedialog, messagebox
from typing import Protocol
from flash_card.constants import (
MENU_ABOUT_UNDERLINE,
MENU_COMMAND_CHANGE_LEVEL,
MENU_COMMAND_EXIT,
MENU_COMMAND_OPEN,
MENU_COMMAND_RESET,
MENU_CONTACT_UNDERLINE,
MENU_FILE_UNDERLINE,
OPEN_DIALOG_TITLE,
OPEN_ERROR_TEMPLATE,
OPEN_ERROR_TITLE,
OPEN_FILE_TYPES,
RESET_CONFIRM_TEMPLATE,
RESET_CONFIRM_TITLE,
RESET_ERROR_TEMPLATE,
RESET_ERROR_TITLE,
)
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
## @brief Logger of the menu view.
_logger = logging.getLogger(__name__)
class MenuApplication(Protocol):
"""What the menu view needs from the application."""
@property
def word_list_name(self) -> str:
"""Return the file name of the word list in use.
@return The file name.
"""
...
@property
def word_list_folder(self) -> Path:
"""Return the folder of the word list in use.
@return The folder.
"""
...
def open_word_list(self, path: Path) -> None:
"""Switch to the word list in a file.
@param path The CSV file.
@return Nothing.
"""
...
def change_level(self) -> None:
"""Show the level screen.
@return Nothing.
"""
...
def reset_word_list(self) -> None:
"""Forget the progress of the word list in use.
@return Nothing.
"""
...
def exit_application(self) -> None:
"""Close the window.
@return Nothing.
"""
...
class MenuView:
"""The menu bar of the window: File and About."""
def __init__(
self,
root: tk.Tk,
view_model: MenuViewModel,
application: MenuApplication,
*,
ask_open_file: Callable[..., str] = filedialog.askopenfilename,
confirm: Callable[..., bool] = messagebox.askyesno,
show_error: Callable[..., str] = messagebox.showerror,
open_url: Callable[[str], bool] = webbrowser.open,
) -> None:
"""Build the menu bar on a root.
@param root The Tk root window.
@param view_model The labels and the contact data.
@param application The commands the entries run.
@param ask_open_file Shows the file dialog and returns the chosen path,
or an empty value when cancelled.
@param confirm Asks a yes or no question.
@param show_error Shows an error message.
@param open_url Opens an address in the web browser.
"""
## @brief The Tk root window.
self._root = root
## @brief The labels and the contact data.
self._view_model = view_model
## @brief The commands the entries run.
self._application = application
## @brief Shows the file dialog.
self._ask_open_file = ask_open_file
## @brief Asks a yes or no question.
self._confirm = confirm
## @brief Shows an error message.
self._show_error = show_error
## @brief Opens an address in the web browser.
self._open_url = open_url
## @brief The menu bar of the window.
self.menubar = tk.Menu(root, tearoff=False)
## @brief The File menu.
self.file_menu = tk.Menu(self.menubar, tearoff=False)
## @brief The About menu.
self.about_menu = tk.Menu(self.menubar, tearoff=False)
## @brief The Contact submenu of the About menu.
self.contact_menu = tk.Menu(self.about_menu, tearoff=False)
self._build()
root.config(menu=self.menubar)
def open_word_list(self) -> None:
"""Ask for a CSV file and switch to it; a refused file shows a message.
@return Nothing.
"""
chosen = self._ask_open_file(
parent=self._root,
title=OPEN_DIALOG_TITLE,
initialdir=str(self._application.word_list_folder),
filetypes=OPEN_FILE_TYPES,
)
if not chosen:
return
path = Path(chosen)
try:
self._application.open_word_list(path)
except VocabularyError as error:
_logger.error("%s", error)
self._show_error(
OPEN_ERROR_TITLE,
OPEN_ERROR_TEMPLATE.format(name=path.name, reason=error),
parent=self._root,
)
def change_level(self) -> None:
"""Show the level screen again.
@return Nothing.
"""
self._application.change_level()
def reset_word_list(self) -> None:
"""Ask to confirm, then start the word list in use from the beginning.
@return Nothing.
"""
name = self._application.word_list_name
question = RESET_CONFIRM_TEMPLATE.format(name=name)
if not self._confirm(RESET_CONFIRM_TITLE, question, parent=self._root):
return
try:
self._application.reset_word_list()
except OSError as error:
_logger.error("%s", error)
self._show_error(
RESET_ERROR_TITLE,
RESET_ERROR_TEMPLATE.format(name=name, reason=error),
parent=self._root,
)
def show_contact(self) -> None:
"""Open the LinkedIn page of the author in the web browser.
@return Nothing.
"""
try:
self._open_url(self._view_model.contact_url)
except (webbrowser.Error, OSError) as error:
_logger.error("%s", error)
def exit_program(self) -> None:
"""Close the window.
@return Nothing.
"""
self._application.exit_application()
def _build(self) -> None:
"""Fill the menus and add them to the menu bar.
@return Nothing.
"""
commands = {
MENU_COMMAND_OPEN: self.open_word_list,
MENU_COMMAND_CHANGE_LEVEL: self.change_level,
MENU_COMMAND_RESET: self.reset_word_list,
MENU_COMMAND_EXIT: self.exit_program,
}
for entry in self._view_model.file_entries:
if entry.command == MENU_COMMAND_EXIT:
self.file_menu.add_separator()
self.file_menu.add_command(
label=entry.label,
underline=entry.underline,
command=commands[entry.command],
)
self.contact_menu.add_command(
label=self._view_model.contact_label, command=self.show_contact
)
self.about_menu.add_cascade(
label=self._view_model.contact_menu_label,
underline=MENU_CONTACT_UNDERLINE,
menu=self.contact_menu,
)
self.menubar.add_cascade(
label=self._view_model.file_label,
underline=MENU_FILE_UNDERLINE,
menu=self.file_menu,
)
self.menubar.add_cascade(
label=self._view_model.about_label,
underline=MENU_ABOUT_UNDERLINE,
menu=self.about_menu,
)
@@ -0,0 +1,102 @@
"""View model of the menu bar.
@file menu_view_model.py
@brief Exposes the labels, the order and the contact data; free of Tkinter.
"""
from dataclasses import dataclass
from flash_card.constants import (
CONTACT_LABEL_TEMPLATE,
CONTACT_NAME,
CONTACT_TITLE,
CONTACT_URL,
MENU_ABOUT_LABEL,
MENU_CHANGE_LEVEL_LABEL,
MENU_CHANGE_LEVEL_UNDERLINE,
MENU_COMMAND_CHANGE_LEVEL,
MENU_COMMAND_EXIT,
MENU_COMMAND_OPEN,
MENU_COMMAND_RESET,
MENU_CONTACT_LABEL,
MENU_EXIT_LABEL,
MENU_EXIT_UNDERLINE,
MENU_FILE_LABEL,
MENU_OPEN_LABEL,
MENU_OPEN_UNDERLINE,
MENU_RESET_LABEL,
MENU_RESET_UNDERLINE,
)
@dataclass(frozen=True, slots=True)
class MenuEntry:
"""One entry of the File menu."""
## @brief Text of the entry.
label: str
## @brief Name of the command of the menu view the entry runs.
command: str
## @brief Index of the mnemonic letter in the label.
underline: int
class MenuViewModel:
"""Data the menu view shows."""
@property
def file_label(self) -> str:
"""Return the label of the File menu.
@return The label.
"""
return MENU_FILE_LABEL
@property
def file_entries(self) -> tuple[MenuEntry, ...]:
"""Return the entries of the File menu.
@return Open, Change level, Reset word list and Exit, in this order.
"""
return (
MenuEntry(MENU_OPEN_LABEL, MENU_COMMAND_OPEN, MENU_OPEN_UNDERLINE),
MenuEntry(
MENU_CHANGE_LEVEL_LABEL,
MENU_COMMAND_CHANGE_LEVEL,
MENU_CHANGE_LEVEL_UNDERLINE,
),
MenuEntry(MENU_RESET_LABEL, MENU_COMMAND_RESET, MENU_RESET_UNDERLINE),
MenuEntry(MENU_EXIT_LABEL, MENU_COMMAND_EXIT, MENU_EXIT_UNDERLINE),
)
@property
def about_label(self) -> str:
"""Return the label of the About menu.
@return The label.
"""
return MENU_ABOUT_LABEL
@property
def contact_menu_label(self) -> str:
"""Return the label of the Contact submenu.
@return The label.
"""
return MENU_CONTACT_LABEL
@property
def contact_label(self) -> str:
"""Return the text of the contact entry.
@return The name and the title of the author.
"""
return CONTACT_LABEL_TEMPLATE.format(name=CONTACT_NAME, title=CONTACT_TITLE)
@property
def contact_url(self) -> str:
"""Return the address the contact entry opens.
@return The LinkedIn page of the author.
"""
return CONTACT_URL