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.
- memi_engine/__init__.py +29 -0
- memi_engine/app.py +256 -0
- memi_engine/config.py +66 -0
- memi_engine/images.py +579 -0
- memi_engine/menu.py +76 -0
- memi_engine/provider.py +130 -0
- memi_engine/py.typed +0 -0
- memi_engine/registry.py +50 -0
- memi_engine/scientific.py +54 -0
- memi_engine/scientific_names.py +1511 -0
- memi_engine/static/css/style.css +648 -0
- memi_engine/static/js/app.js +420 -0
- memi_engine/templates/about.html +51 -0
- memi_engine/templates/index.html +115 -0
- memi_engine-0.1.0.dist-info/METADATA +270 -0
- memi_engine-0.1.0.dist-info/RECORD +18 -0
- memi_engine-0.1.0.dist-info/WHEEL +4 -0
- memi_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
memi_engine/__init__.py
ADDED
|
@@ -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"
|