memi-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """memi-engine: build your own memi memory card game.
2
+
3
+ Usage:
4
+ from memi_engine import CategoryProvider, MemiConfig, create_app, register
5
+
6
+ class MyCategory(CategoryProvider):
7
+ key = "my:category"
8
+ items = ["Item 1", "Item 2"]
9
+
10
+ register(MyCategory())
11
+
12
+ app = create_app(MemiConfig(title="My Memi"))
13
+ """
14
+
15
+ from memi_engine.app import create_app
16
+ from memi_engine.config import MemiConfig
17
+ from memi_engine.provider import AggregateProvider, CategoryProvider
18
+ from memi_engine.registry import register
19
+ from memi_engine.scientific import SCIENTIFIC_NAMES, ScientificNameProvider
20
+
21
+ __all__ = [
22
+ "SCIENTIFIC_NAMES",
23
+ "AggregateProvider",
24
+ "CategoryProvider",
25
+ "MemiConfig",
26
+ "ScientificNameProvider",
27
+ "create_app",
28
+ "register",
29
+ ]
memi_engine/app.py ADDED
@@ -0,0 +1,256 @@
1
+ """Generic Flask app for memi instances.
2
+
3
+ All category-specific logic lives in CategoryProviders. This module
4
+ handles routing, filtering, and the game loop — without knowing
5
+ anything about specific categories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import random
13
+ import subprocess
14
+
15
+ from flask import (
16
+ Flask,
17
+ Response,
18
+ jsonify,
19
+ render_template,
20
+ request,
21
+ send_from_directory,
22
+ )
23
+
24
+ from memi_engine import images, registry
25
+ from memi_engine.config import MemiConfig
26
+ from memi_engine.menu import build_menu
27
+
28
+ _logger = logging.getLogger(__name__)
29
+
30
+ # Items excluded via the review system
31
+ _excluded_items: set[str] = set()
32
+
33
+
34
+ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
35
+ """Create a Flask app for a memi instance.
36
+
37
+ Args:
38
+ config: MemiConfig with title, themes, footers, etc.
39
+ instance_static: Path to the instance's static folder (for logos, etc.).
40
+ If provided, files here are served alongside the engine's static files.
41
+ """
42
+ images.set_wikipedia_lang(config.wikipedia_lang)
43
+
44
+ engine_dir = os.path.dirname(__file__)
45
+ engine_templates = os.path.join(engine_dir, "templates")
46
+ engine_static = os.path.join(engine_dir, "static")
47
+
48
+ # Disable Flask's built-in static handler — we'll handle it ourselves
49
+ app = Flask(
50
+ __name__,
51
+ template_folder=engine_templates,
52
+ static_folder=None,
53
+ )
54
+
55
+ # Custom static file handler: instance first, then engine
56
+ static_dirs = (
57
+ [instance_static, engine_static] if instance_static else [engine_static]
58
+ )
59
+
60
+ @app.route("/static/<path:filename>")
61
+ def static(filename):
62
+ for d in static_dirs:
63
+ if d and os.path.isfile(os.path.join(d, filename)):
64
+ return send_from_directory(d, filename)
65
+ return "Not found", 404
66
+
67
+ @app.route("/favicon.svg")
68
+ def favicon():
69
+ svg = (
70
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
71
+ f'<rect width="64" height="64" rx="14" fill="{config.favicon_color}"/>'
72
+ '<text x="32" y="46" font-family="system-ui,-apple-system,sans-serif" '
73
+ 'font-size="40" font-weight="700" text-anchor="middle" fill="#fff" '
74
+ 'letter-spacing="-1">m</text>'
75
+ "</svg>"
76
+ )
77
+ return Response(svg, mimetype="image/svg+xml")
78
+
79
+ # Detect git version
80
+ if not config.version:
81
+ try:
82
+ config.version = (
83
+ subprocess.check_output(
84
+ ["git", "rev-parse", "--short", "HEAD"],
85
+ stderr=subprocess.DEVNULL,
86
+ )
87
+ .decode()
88
+ .strip()
89
+ )
90
+ except Exception:
91
+ config.version = "dev"
92
+
93
+ # Load excluded items
94
+ _load_excluded_items(app)
95
+
96
+ # --- Routes ---
97
+
98
+ @app.route("/")
99
+ def index():
100
+ top_level, subs = build_menu()
101
+ # Collect all unique filters from all providers
102
+ all_filters = _collect_filters()
103
+ return render_template(
104
+ "index.html",
105
+ top_level=top_level,
106
+ subcategories=subs,
107
+ version=config.version,
108
+ config=config,
109
+ filters=all_filters,
110
+ )
111
+
112
+ @app.route("/about")
113
+ def about():
114
+ return render_template(
115
+ "about.html", version=config.version, config=config
116
+ )
117
+
118
+ @app.route("/api/random")
119
+ def random_item():
120
+ cats = request.args.get("cats", "")
121
+ cat_list = [c for c in cats.split(",") if registry.get(c)]
122
+ if not cat_list:
123
+ return jsonify({"error": "Unknown category"}), 400
124
+
125
+ seen = (
126
+ set(request.args.get("seen", "").split(","))
127
+ if request.args.get("seen")
128
+ else set()
129
+ )
130
+
131
+ category = random.choice(cat_list)
132
+ provider = registry.get(category)
133
+ items = list(provider.items)
134
+
135
+ # Apply filters
136
+ for filter_name, filter_map in provider.filters.items():
137
+ param = request.args.get(filter_name, "")
138
+ if param:
139
+ allowed = set()
140
+ for val in param.split(","):
141
+ allowed.update(filter_map.get(val, []))
142
+ items = [i for i in items if i in allowed]
143
+ if not items:
144
+ return jsonify({"error": f"No items for {filter_name}"}), 400
145
+
146
+ items = [i for i in items if i not in _excluded_items]
147
+ unseen = [i for i in items if i not in seen]
148
+ if not unseen:
149
+ return jsonify({"error": "All items seen"}), 400
150
+ candidates = random.sample(unseen, min(10, len(unseen)))
151
+
152
+ for item in candidates:
153
+ result = provider.get_image(item)
154
+ if not result or not result.get("image"):
155
+ continue
156
+
157
+ result["item"] = item
158
+
159
+ # Clean up name
160
+ name = result.get("name", item)
161
+ if "(" in name:
162
+ name = name.split("(")[0].strip()
163
+ result["name"] = name
164
+
165
+ if provider.override_name:
166
+ result["name"] = item
167
+
168
+ # Tag
169
+ tag = provider.get_tag(item)
170
+ if tag:
171
+ result["tag"] = tag
172
+ if provider.tag_style:
173
+ result["tag_style"] = provider.tag_style
174
+
175
+ # Clue
176
+ clue = provider.get_clue(item)
177
+ if clue:
178
+ result["clue"] = clue
179
+
180
+ if provider.light_bg:
181
+ result["light_bg"] = True
182
+
183
+ # Footers
184
+ if provider.footers:
185
+ result["footers"] = provider.footers
186
+
187
+ return jsonify(result)
188
+
189
+ return jsonify({"error": "No image found"}), 404
190
+
191
+ @app.route("/api/report", methods=["POST"])
192
+ def report():
193
+ data = request.get_json(silent=True) or {}
194
+ item = data.get("item", "")
195
+ cats = data.get("cats", "")
196
+ if item:
197
+ _logger.info(f"REPORTED: {item} (categories: {cats})")
198
+ logging.getLogger("reports").info(
199
+ f"REPORTED: {item} (categories: {cats})"
200
+ )
201
+ return jsonify({"ok": True})
202
+
203
+ @app.route("/healthz")
204
+ def healthz():
205
+ return jsonify({"status": "ok", "categories": len(registry.get_all())})
206
+
207
+ return app
208
+
209
+
210
+ def _collect_filters() -> dict:
211
+ """Collect all unique filters across all providers.
212
+
213
+ Returns a dict like:
214
+ {
215
+ "continents": {
216
+ "categories": ["geography:countries:flags", ...],
217
+ "options": ["africa", "america", "asia", ...]
218
+ },
219
+ ...
220
+ }
221
+ """
222
+ filters: dict = {}
223
+ for key, provider in registry.get_all().items():
224
+ for filter_name, filter_map in provider.filters.items():
225
+ if filter_name not in filters:
226
+ filters[filter_name] = {
227
+ "categories": [],
228
+ "options": sorted(filter_map.keys()),
229
+ }
230
+ filters[filter_name]["categories"].append(key)
231
+ return filters
232
+
233
+
234
+ def _load_excluded_items(app):
235
+ """Load excluded items from file if it exists."""
236
+ # Use current working directory for data files, not the engine package
237
+ data_dir = os.getcwd()
238
+ excluded_file = os.path.join(data_dir, "excluded_items.txt")
239
+ if os.path.isfile(excluded_file):
240
+ with open(excluded_file) as f:
241
+ for line in f:
242
+ line = line.strip()
243
+ if line:
244
+ _excluded_items.add(line)
245
+
246
+ # Set up report logging (don't crash if we can't write)
247
+ report_log = os.path.join(data_dir, "reported_items.log")
248
+ try:
249
+ handler = logging.FileHandler(report_log)
250
+ handler.setFormatter(
251
+ logging.Formatter("%(asctime)s %(message)s")
252
+ )
253
+ logging.getLogger("reports").addHandler(handler)
254
+ except PermissionError:
255
+ pass # Reports won't be saved but app still works
256
+ logging.getLogger("reports").setLevel(logging.INFO)
memi_engine/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """Configuration for a memi instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class MemiConfig:
10
+ """Configuration for a memi game instance.
11
+
12
+ Attributes:
13
+ title: Game title shown in the header.
14
+ subtitle: Subtitle shown below the title.
15
+ themes: List of available theme names.
16
+ default_theme: Initial theme.
17
+ sponsor_url: URL for the sponsor link (None to hide).
18
+ sponsor_text: Text next to the heart icon.
19
+ about_html: Custom HTML for the about page body.
20
+ analytics_html: Analytics script HTML (e.g. GoatCounter).
21
+ footers: Dict of footer_id -> HTML content for attribution footers.
22
+ related_sites: List of sibling memi games to link from the about page.
23
+ Each item is {"name": ..., "url": ...}.
24
+ version: Version string shown in the footer (auto-detected from git).
25
+ """
26
+
27
+ title: str = "memi"
28
+ subtitle: str = "practise your memory"
29
+ themes: list[str] = field(
30
+ default_factory=lambda: [
31
+ "light", "yellow", "pink", "blue",
32
+ "green", "brown", "grey", "dark",
33
+ ]
34
+ )
35
+ default_theme: str = "light"
36
+ sponsor_url: str | None = None
37
+ sponsor_text: str = "sponsor"
38
+ about_html: str | None = None
39
+ analytics_html: str | None = None
40
+ footers: dict[str, str] = field(default_factory=dict)
41
+ related_sites: list[dict[str, str]] = field(default_factory=list)
42
+ version: str = ""
43
+
44
+ # UI labels (for i18n)
45
+ label_theme: str = "theme"
46
+ label_about: str = "about"
47
+ label_report: str = "report"
48
+ label_reported: str = "reported"
49
+ label_clues_on: str = "clues: on"
50
+ label_clues_off: str = "clues: off"
51
+ label_show_letter: str = "show letter"
52
+ label_pick_category: str = "pick a category"
53
+ label_loading: str = "loading..."
54
+ label_all_done: str = "all done! click to start over"
55
+ label_click_to_reveal: str = "click the image to reveal the answer"
56
+ label_click_for_new: str = "click again for a new one"
57
+ label_back: str = "back to playing"
58
+ label_related_sites: str = "more memi games"
59
+ label_more: str = "know more"
60
+ done_html: str = "" # Custom HTML shown when all items are done
61
+
62
+ # Favicon: background of the rounded square (default dark goldenrod)
63
+ favicon_color: str = "#b8860b"
64
+
65
+ # Wikipedia language edition for the default image / "know more" helpers.
66
+ wikipedia_lang: str = "en"