diff --git a/.tdd/spec-dictionary-learning-profile-v1.md b/.tdd/spec-dictionary-learning-profile-v1.md
new file mode 100644
index 0000000..27c655f
--- /dev/null
+++ b/.tdd/spec-dictionary-learning-profile-v1.md
@@ -0,0 +1,30 @@
+# Dictionary learning profile v1
+
+Status: locked 2026-09-17
+
+## Acceptance criteria
+
+- AC-1: The public dictionary leads with a device-scoped learning profile before search and word results. It reports saved words, locally learned words, words due now, and distinct study days without implying access to Telegram account data.
+- AC-2: The profile visualizes the last seven local study days and current streak from bounded browser storage. Saving a word and answering a practice card record local activity.
+- AC-3: The default dictionary view renders at most eight results and exposes one `Show more` action. Search, saved-only view, language changes, and clear reset pagination without breaking reverse or Unicode lookup.
+- AC-4: The profile offers a print/PDF snapshot. CSV, offline installation, standalone HTML download, and their explanatory copy remain available inside one collapsed `Export and offline` disclosure.
+- AC-5: The online and standalone dictionary use the same dependency-free profile calculations, accessible labels, existing Lexi palette, and responsive/print layouts.
+
+## Edge cases
+
+- EC-1: Empty or legacy `lexi:dictionary:v1` storage produces a useful zero-state profile and keeps search/practice working.
+- EC-2: Malformed activity dates/counts are ignored; retained activity is bounded to the most recent 400 days and counts are bounded integers.
+- EC-3: A downloaded dictionary remains self-contained, CSP-hashed, printable, searchable, and usable without network access.
+- EC-4: The public route continues to project only redistribution-approved starter packs and never reads learner/database state.
+
+## Constraints
+
+- Do not expose Telegram profile or progress through the unauthenticated `/dictionary/` route.
+- Do not add runtime dependencies, database schema, remote analytics, or network calls for profile calculations.
+- Preserve all existing search, save, written practice, CSV, service worker, download, CSP, and language-pair contracts.
+
+## Out of scope
+
+- A public shareable Telegram learner profile.
+- Server-generated PDF files or cross-device synchronization.
+- Changes to spaced repetition in the authenticated Telegram/Mini App product.
diff --git a/docs/offline-dictionary.md b/docs/offline-dictionary.md
index 3e3bcb5..ccdb414 100644
--- a/docs/offline-dictionary.md
+++ b/docs/offline-dictionary.md
@@ -16,6 +16,17 @@ Telegram account and does not read learner storage.
bidirectional search, accepted Russian meanings and transcription.
- Saved words and short written recall with corrections. Saved entries have a
simple local next-review timestamp; this is independent of Telegram SRS/XP.
+- A device-local learning profile shows saved, learned and due words for the
+ selected language pair. Study days, the current streak and the seven-day
+ activity chart cover all practice recorded in that browser. No learner or
+ Telegram account data is read.
+- Search results reveal eight entries at a time instead of rendering a long
+ list. A user can explicitly show the next group; a new search, view or
+ language pair resets the list.
+- **PDF / print** creates a readable snapshot of the profile and all saved
+ words for the selected pair through the browser's native print dialog.
+ CSV, the standalone HTML download and offline controls live in one collapsed
+ secondary section instead of competing with daily review.
- UI languages: Russian, English and French. Other requested interface locales
fall back to English; vocabulary-language coverage is independent of UI.
- Queries stay in the browser. The **Translate in Yandex** link sends the
@@ -27,11 +38,11 @@ Telegram account and does not read learner storage.
## Offline use
**Save on this device** registers a worker scoped to `/dictionary/` and caches
-only the public dictionary shell, dictionary CSS/JS and manifest. After saving,
+only the public dictionary shell, profile/dictionary CSS/JS and manifest. After saving,
the page can reopen offline in a supported browser/home-screen installation.
The Mini App, admin pages, authentication, API responses and user identity are
never cached by this worker. HTTP responses retain `no-store`; explicit browser
-Cache API writes apply only to the four public allowlisted resources.
+Cache API writes apply only to the five public allowlisted resources.
The worker revision hashes dictionary content, template, CSS, JS, worker source,
manifest and CSP. New revisions install a complete new cache and remove only
@@ -73,9 +84,10 @@ Browser: start an isolated `OfflineDictionaryTest` app, then run
verifies a v1-to-v2 worker upgrade, coherent new shell/JS/CSS/content, cleanup of
only old dictionary caches, and a cold offline lookup on the new version.
-Browser checks cover 42 non-identical language pairs, reverse and Unicode
-lookup, persisted saved entries, written feedback, no external query requests,
-mobile overflow, offline save/remove/resave, a new tab while network is blocked,
+Browser checks cover 42 non-identical language pairs, progressive result
+reveal, reverse and Unicode lookup, persisted profile activity, a printable
+saved-word snapshot, written feedback, no external query requests, mobile
+overflow, offline save/remove/resave, a new tab while network is blocked,
standalone HTML with no network, and malformed local storage. Physical iOS and
Android file-preview/home-screen behavior still needs pilot device validation.
diff --git a/mydictionary/admin.py b/mydictionary/admin.py
index 8016988..b26d9ee 100644
--- a/mydictionary/admin.py
+++ b/mydictionary/admin.py
@@ -852,6 +852,7 @@ def public_dictionary():
"dictionary.html",
dictionary_data=build_dictionary_data(CATALOG),
dictionary_css=None,
+ dictionary_profile_js=None,
dictionary_js=None,
dictionary_csp=dictionary_content_security_policy().replace(
"frame-ancestors 'none'; ", ""
@@ -866,15 +867,22 @@ def dictionary_download():
css = escape_inline_asset(
(static_dir / "dictionary.css").read_text(encoding="utf-8"), "style"
)
+ profile_javascript = escape_inline_asset(
+ (static_dir / "dictionary-profile.js").read_text(encoding="utf-8"),
+ "script",
+ )
javascript = escape_inline_asset(
(static_dir / "dictionary.js").read_text(encoding="utf-8"), "script"
)
- csp = dictionary_content_security_policy(css=css, javascript=javascript)
+ csp = dictionary_content_security_policy(
+ css=css, javascript=(profile_javascript, javascript)
+ )
dictionary_data = build_dictionary_data(CATALOG)
response = Response(render_template(
"dictionary.html",
dictionary_data=dictionary_data,
dictionary_css=css,
+ dictionary_profile_js=profile_javascript,
dictionary_js=javascript,
dictionary_csp=csp.replace("frame-ancestors 'none'; ", ""),
dictionary_defaults=dictionary_download_defaults(request.args, dictionary_data),
@@ -891,7 +899,12 @@ def dictionary_worker():
static_dir = Path(app.static_folder)
sources = {
filename: (static_dir / filename).read_bytes()
- for filename in ("dictionary.js", "dictionary.css", "dictionary-sw.js")
+ for filename in (
+ "dictionary-profile.js",
+ "dictionary.js",
+ "dictionary.css",
+ "dictionary-sw.js",
+ )
}
sources["dictionary.html"] = (
Path(app.root_path) / "templates" / "dictionary.html"
diff --git a/mydictionary/dictionary.py b/mydictionary/dictionary.py
index 60986ed..3414bae 100644
--- a/mydictionary/dictionary.py
+++ b/mydictionary/dictionary.py
@@ -11,7 +11,7 @@
import hashlib
import json
import re
-from typing import Any, Mapping
+from typing import Any, Mapping, Sequence
from mydictionary.catalog import ContentCatalog
from mydictionary.content import accepted_meanings
@@ -120,16 +120,21 @@ def escape_inline_asset(source: str, element: str) -> str:
flags=re.IGNORECASE)
-def dictionary_content_security_policy(*, css: str = "", javascript: str = "") -> str:
+def dictionary_content_security_policy(
+ *, css: str = "", javascript: str | Sequence[str] = ""
+) -> str:
"""Authorize exact inline download assets without allowing arbitrary code."""
def hash_source(source: str) -> str:
digest = base64.b64encode(hashlib.sha256(source.encode("utf-8")).digest()).decode("ascii")
return f" 'sha256-{digest}'" if source else ""
+ scripts = [javascript] if isinstance(javascript, str) else list(javascript)
+ script_hashes = "".join(hash_source(source) for source in scripts)
+
return (
"default-src 'none'; img-src 'self' data:; "
f"style-src 'self'{hash_source(css)}; "
- f"script-src 'self'{hash_source(javascript)}; "
+ f"script-src 'self'{script_hashes}; "
"connect-src 'self'; worker-src 'self'; manifest-src 'self'; "
"form-action 'none'; frame-ancestors 'none'; base-uri 'none'"
)
@@ -138,9 +143,9 @@ def hash_source(source: str) -> str:
def dictionary_manifest() -> dict[str, Any]:
return {
"id": "/dictionary/",
- "name": "Lexi Dictionary",
+ "name": "Lexi — My Word Profile",
"short_name": "Lexi",
- "description": "A portable starter dictionary for seven languages.",
+ "description": "A private, device-local word profile and offline starter dictionary.",
"start_url": "/dictionary/",
"scope": "/dictionary/",
"display": "standalone",
diff --git a/mydictionary/static/dictionary-profile.js b/mydictionary/static/dictionary-profile.js
new file mode 100644
index 0000000..2b864e7
--- /dev/null
+++ b/mydictionary/static/dictionary-profile.js
@@ -0,0 +1,81 @@
+(function (root) {
+ "use strict";
+
+ const DAY_MS = 86_400_000;
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+ function validDateKey(value) {
+ if (!DATE_PATTERN.test(value)) return false;
+ const [year, month, day] = value.split("-").map(Number);
+ const parsed = new Date(Date.UTC(year, month - 1, day));
+ return parsed.getUTCFullYear() === year
+ && parsed.getUTCMonth() === month - 1
+ && parsed.getUTCDate() === day;
+ }
+
+ function sanitizeActivity(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const rows = Object.entries(value)
+ .filter(([date]) => validDateKey(date))
+ .map(([date, count]) => [date, Math.min(999, Math.max(0, Math.trunc(Number(count) || 0)))])
+ .filter(([, count]) => count > 0)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .slice(-400);
+ return Object.fromEntries(rows);
+ }
+
+ function dateKey(value) {
+ const date = new Date(value);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ function shiftedDate(value, offset) {
+ const date = new Date(value);
+ date.setHours(12, 0, 0, 0);
+ date.setDate(date.getDate() + offset);
+ return date;
+ }
+
+ function buildSnapshot({saved = {}, activity = {}, pairPrefix = "", now = Date.now()} = {}) {
+ const safeActivity = sanitizeActivity(activity);
+ const rows = Object.entries(saved && typeof saved === "object" ? saved : {})
+ .filter(([key, state]) => key.startsWith(pairPrefix) && state && typeof state === "object");
+ const learned = rows.filter(([, state]) => Number(state.interval) >= 7).length;
+ const due = rows.filter(([, state]) => Number.isFinite(Number(state.due)) && Number(state.due) <= now).length;
+ const today = dateKey(now);
+ const yesterday = dateKey(shiftedDate(now, -1));
+ let cursor = safeActivity[today] ? new Date(now) : safeActivity[yesterday] ? shiftedDate(now, -1) : null;
+ let streak = 0;
+ while (cursor && safeActivity[dateKey(cursor)]) {
+ streak += 1;
+ cursor = shiftedDate(cursor, -1);
+ }
+ const week = Array.from({length: 7}, (_, index) => {
+ const date = dateKey(shiftedDate(now, index - 6));
+ return {date, count: safeActivity[date] || 0};
+ });
+ const peak = Math.max(1, ...week.map((day) => day.count));
+ week.forEach((day) => { day.level = day.count ? Math.max(1, Math.ceil((day.count / peak) * 4)) : 0; });
+ return {
+ saved: rows.length,
+ learned,
+ learning: Math.max(0, rows.length - learned),
+ due,
+ studyDays: Object.keys(safeActivity).length,
+ streak,
+ progress: rows.length ? Math.round((learned / rows.length) * 100) : 0,
+ week,
+ };
+ }
+
+ const api = {
+ sanitizeActivity,
+ buildSnapshot,
+ };
+
+ if (typeof module === "object" && module.exports) module.exports = api;
+ else root.LexiDictionaryProfile = api;
+})(typeof globalThis === "object" ? globalThis : this);
diff --git a/mydictionary/static/dictionary-sw.js b/mydictionary/static/dictionary-sw.js
index 2821fd6..6a23891 100644
--- a/mydictionary/static/dictionary-sw.js
+++ b/mydictionary/static/dictionary-sw.js
@@ -1,7 +1,7 @@
"use strict";
const CACHE = "lexi-dictionary-__DICTIONARY_REVISION__";
const SHELL = "/dictionary/";
-const ASSETS = [SHELL, "/static/dictionary.css", "/static/dictionary.js", "/dictionary/manifest.webmanifest"];
+const ASSETS = [SHELL, "/static/dictionary-profile.js", "/static/dictionary.css", "/static/dictionary.js", "/dictionary/manifest.webmanifest"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).then(() => self.skipWaiting()));
});
diff --git a/mydictionary/static/dictionary.css b/mydictionary/static/dictionary.css
index 6dee714..5a55039 100644
--- a/mydictionary/static/dictionary.css
+++ b/mydictionary/static/dictionary.css
@@ -1,6 +1,180 @@
-:root{color-scheme:light dark;--bg:#f7f9fc;--surface:#fff;--ink:#172b3d;--muted:#536679;--line:#d7dfe7;--accent:#235bcc;--on-accent:#fff;--soft:#eaf0fb;--good:#146646;--bad:#9e3725;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
-@media(prefers-color-scheme:dark){:root{--bg:#101b27;--surface:#192937;--ink:#f0f5fb;--muted:#b1c1d2;--line:#394c5d;--accent:#9bbdff;--on-accent:#12203b;--soft:#223c58;--good:#8ce3b6;--bad:#ffb8a8}}
-*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-size:16px;line-height:1.5}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button,.button{min-height:44px;border:1px solid var(--line);border-radius:10px;padding:10px 15px;background:var(--surface);color:var(--ink);font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px}button:hover,.button:hover{background:var(--soft)}button:disabled{opacity:.55;cursor:default}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px}input,select{min-width:0;min-height:44px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:10px 12px}input::placeholder{color:var(--muted);opacity:1}select{width:100%}p,h1,h2{margin:0}h1{font-size:2rem;letter-spacing:-.025em;line-height:1.16;text-wrap:balance}h2{font-size:1.1rem;line-height:1.3}small{font-size:.8rem;color:var(--muted);display:block;line-height:1.55}label{font-size:.85rem;font-weight:650;display:grid;gap:6px}a{color:var(--accent)}[hidden]{display:none!important}.dictionary-shell{max-width:720px;margin:auto;padding:max(20px,env(safe-area-inset-top)) max(18px,env(safe-area-inset-right)) max(32px,env(safe-area-inset-bottom)) max(18px,env(safe-area-inset-left))}.dictionary-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:1.3rem;text-decoration:none;color:var(--ink)}.brand span{font-size:1.8rem}.connection-status{font-size:.78rem;color:var(--muted);max-width:60%;text-align:end}.connection-status.ready{color:var(--good)}.intro{margin:28px 0 24px}.intro p{color:var(--muted);margin-top:8px}.language-pair{display:grid;grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr);align-items:end;gap:10px;margin-bottom:18px}.icon-button{padding:6px;width:44px;flex-shrink:0;font-size:1.35rem}.search-label{margin-bottom:6px}.search-field{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface);padding:5px 8px}.search-field>span{font-size:1.8rem;padding-inline-start:6px}.search-field input{width:100%;border:0;background:transparent;font-size:1.05rem}.search-field input::-webkit-search-cancel-button{display:none}.search-field .icon-button{border:0;background:transparent}.subtle{font-size:.85rem;color:var(--muted);margin-top:10px}.notice{padding:12px;margin-top:12px;border:1px solid var(--line);border-radius:10px;font-size:.85rem}.view-toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:22px}.view-switch{display:flex;gap:2px;border-bottom:1px solid var(--line)}.view-switch button{border:0;border-radius:8px 8px 0 0;background:transparent;font-size:.9rem}.view-switch button[aria-pressed=true]{color:var(--accent);box-shadow:inset 0 -2px var(--accent)}#saved-count{font-variant-numeric:tabular-nums;font-size:.8rem}.practice-button{font-size:.85rem}.results{margin-top:8px}.word-row{padding:17px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:14px;align-items:start}.word-copy{min-width:0;overflow-wrap:anywhere}.word-target{font-size:1.16rem;font-weight:750;unicode-bidi:plaintext}.word-transcription{color:var(--muted);font-size:.85rem;margin-top:3px;unicode-bidi:plaintext}.word-meaning{margin-top:5px;unicode-bidi:plaintext}.word-example{margin-top:10px;font-size:.88rem;color:var(--muted);max-width:65ch;unicode-bidi:plaintext}.save-word{font-size:.84rem;flex-shrink:0}.save-word[aria-pressed=true]{border-color:var(--accent);color:var(--accent);background:var(--soft)}.empty-state{padding:26px 0;color:var(--muted)}.online-translation{margin-top:20px;padding:16px;background:var(--soft);border-radius:12px}.online-translation p{font-size:.88rem}.online-translation a{display:inline-block;margin:12px 0;font-weight:700;min-height:32px}.offline-tools{border-top:1px solid var(--line);margin-top:30px;padding-top:24px}.offline-tools p{font-size:.88rem;color:var(--muted);margin-top:8px}.download-actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}.download-actions>*{font-size:.85rem}.primary{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}.primary:hover{filter:brightness(.93);background:var(--accent)}.text-button{padding-inline:0;color:var(--accent);border:0;background:transparent;font-size:.88rem}.text-button:hover{background:transparent;text-decoration:underline}details{margin-top:12px;font-size:.86rem}summary{cursor:pointer;min-height:40px;padding:8px 0}.local-note{margin-block:14px!important}footer{margin-top:32px;border-top:1px solid var(--line);padding-top:18px;display:flex;align-items:end;justify-content:space-between;gap:20px;color:var(--muted);font-size:.75rem}footer label{font-size:.75rem;max-width:190px}footer select{font-size:.85rem}footer>span{text-align:end}.practice-area{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:18px;margin-top:18px}.practice-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.practice-question{font-size:1.7rem;font-weight:750;margin:20px 0;overflow-wrap:anywhere}.practice-area form{display:flex;gap:8px;margin-top:6px}.practice-area input{width:100%}#practice-feedback{margin-top:14px;white-space:pre-line;overflow-wrap:anywhere}#practice-feedback.correct{color:var(--good)}#practice-feedback.incorrect{color:var(--bad)}#next-word{margin-top:12px}#offline-status{white-space:pre-line}#offline-status.error{color:var(--bad)}
-@media(max-width:420px){h1{font-size:1.7rem}.dictionary-shell{padding-inline:14px}.language-pair{gap:6px}.language-pair select{padding-inline:7px;font-size:.9rem}.save-word{max-width:100px;padding:8px;font-size:.75rem}.word-row{gap:8px}.view-toolbar{align-items:stretch}.practice-button{flex-grow:1}.download-actions>*{flex-grow:1}footer{align-items:start;flex-direction:column;gap:12px}.practice-area{padding:14px}}
-.intro>.button{margin-top:14px;font-size:.85rem}
-@media(prefers-reduced-motion:no-preference){button,.button{transition:background-color 160ms ease-out,color 160ms ease-out}}
+:root {
+ color-scheme: light dark;
+ --bg: #f7f9fc;
+ --surface: #ffffff;
+ --ink: #172b3d;
+ --muted: #536679;
+ --line: #d7dfe7;
+ --accent: #235bcc;
+ --on-accent: #ffffff;
+ --soft: #eaf0fb;
+ --good: #146646;
+ --bad: #9e3725;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #101b27;
+ --surface: #192937;
+ --ink: #f0f5fb;
+ --muted: #b1c1d2;
+ --line: #394c5d;
+ --accent: #9bbdff;
+ --on-accent: #12203b;
+ --soft: #223c58;
+ --good: #8ce3b6;
+ --bad: #ffb8a8;
+ }
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--ink); font-size: 16px; line-height: 1.5; }
+button, input, select { font: inherit; }
+button, a, input, select { touch-action: manipulation; }
+button, .button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 10px 15px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); font-weight: 700; cursor: pointer; text-decoration: none; }
+button:hover, .button:hover { background: var(--soft); }
+button:disabled { opacity: .55; cursor: default; }
+button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
+input, select { min-width: 0; min-height: 44px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--ink); }
+input::placeholder { color: var(--muted); opacity: 1; }
+select { width: 100%; }
+p, h1, h2 { margin: 0; }
+h1 { font-size: 2rem; letter-spacing: -.025em; line-height: 1.16; text-wrap: balance; }
+h2 { font-size: 1.08rem; line-height: 1.3; text-wrap: balance; }
+small { display: block; color: var(--muted); font-size: .8rem; line-height: 1.55; }
+label { display: grid; gap: 6px; font-size: .85rem; font-weight: 700; }
+a { color: var(--accent); }
+[hidden] { display: none !important; }
+
+.dictionary-shell { max-width: 760px; margin: auto; padding: max(20px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(32px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); }
+.dictionary-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); font-size: 1.3rem; font-weight: 800; text-decoration: none; }
+.brand span { font-size: 1.8rem; }
+.connection-status { max-width: 60%; color: var(--muted); font-size: .78rem; text-align: end; }
+.connection-status.ready { color: var(--good); }
+.intro { margin: 28px 0 18px; }
+.intro p { max-width: 62ch; margin-top: 7px; color: var(--muted); }
+
+.learning-profile { overflow: hidden; margin-bottom: 30px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
+.profile-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 18px 18px 16px; }
+.profile-heading p { max-width: 54ch; margin-top: 5px; color: var(--muted); font-size: .82rem; line-height: 1.45; text-wrap: pretty; }
+.print-profile { flex-shrink: 0; min-height: 38px; padding-block: 7px; font-size: .78rem; }
+.profile-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }
+.profile-metric { min-width: 0; padding: 14px 16px; }
+.profile-metric + .profile-metric { border-inline-start: 1px solid var(--line); }
+.profile-metric strong { display: block; font-size: 1.45rem; font-variant-numeric: tabular-nums; line-height: 1.1; }
+.profile-metric span { display: block; margin-top: 5px; color: var(--muted); font-size: .72rem; line-height: 1.25; }
+.profile-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(230px, .85fr); gap: 24px; padding: 18px; }
+.profile-detail-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: .78rem; }
+.profile-detail-heading span { color: var(--muted); }
+.profile-detail-heading strong { font-variant-numeric: tabular-nums; }
+.mastery-block progress { width: 100%; height: 8px; margin-top: 12px; border: 0; border-radius: 8px; overflow: hidden; accent-color: var(--accent); }
+.mastery-block progress::-webkit-progress-bar { background: var(--soft); }
+.mastery-block progress::-webkit-progress-value { background: var(--accent); }
+.mastery-block progress::-moz-progress-bar { background: var(--accent); }
+.profile-practice { width: calc(100% - 36px); margin: 0 18px 18px; }
+.activity-chart { display: grid; grid-template-columns: repeat(7, minmax(20px, 1fr)); gap: 6px; align-items: end; height: 76px; margin-top: 8px; }
+.activity-day { display: grid; grid-template-rows: 1fr auto; gap: 5px; align-items: end; height: 100%; color: var(--muted); font-size: .62rem; text-align: center; }
+.activity-day i { display: block; min-height: 4px; border-radius: 4px 4px 2px 2px; background: var(--soft); }
+.activity-day.level-1 i { height: 25%; background: color-mix(in srgb, var(--accent) 35%, var(--soft)); }
+.activity-day.level-2 i { height: 45%; background: color-mix(in srgb, var(--accent) 55%, var(--soft)); }
+.activity-day.level-3 i { height: 70%; background: color-mix(in srgb, var(--accent) 75%, var(--soft)); }
+.activity-day.level-4 i { height: 100%; background: var(--accent); }
+
+.dictionary-workspace { margin-top: 4px; }
+.dictionary-workspace h2 { margin-bottom: 14px; }
+.language-pair { display: grid; grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr); align-items: end; gap: 10px; margin-bottom: 18px; }
+.icon-button { width: 44px; flex-shrink: 0; padding: 6px; font-size: 1.35rem; }
+.search-label { margin-bottom: 6px; }
+.search-field { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
+.search-field > span { padding-inline-start: 6px; font-size: 1.8rem; }
+.search-field input { width: 100%; border: 0; background: transparent; font-size: 1.05rem; }
+.search-field input::-webkit-search-cancel-button { display: none; }
+.search-field .icon-button { border: 0; background: transparent; }
+.subtle { margin-top: 10px; color: var(--muted); font-size: .85rem; }
+.notice { margin-top: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; font-size: .85rem; }
+.view-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 22px; }
+.view-switch { display: flex; gap: 2px; border-bottom: 1px solid var(--line); }
+.view-switch button { border: 0; border-radius: 8px 8px 0 0; background: transparent; font-size: .9rem; }
+.view-switch button[aria-pressed="true"] { color: var(--accent); box-shadow: inset 0 -2px var(--accent); }
+#saved-count { font-size: .8rem; font-variant-numeric: tabular-nums; }
+.results { margin-top: 8px; }
+.word-row { display: flex; align-items: start; justify-content: space-between; gap: 14px; padding: 17px 0; border-bottom: 1px solid var(--line); }
+.word-copy { min-width: 0; overflow-wrap: anywhere; }
+.word-target { font-size: 1.16rem; font-weight: 750; unicode-bidi: plaintext; }
+.word-transcription { margin-top: 3px; color: var(--muted); font-size: .85rem; unicode-bidi: plaintext; }
+.word-meaning { margin-top: 5px; unicode-bidi: plaintext; }
+.word-example { max-width: 65ch; margin-top: 10px; color: var(--muted); font-size: .88rem; unicode-bidi: plaintext; }
+.save-word { flex-shrink: 0; font-size: .84rem; }
+.save-word[aria-pressed="true"] { border-color: var(--accent); background: var(--soft); color: var(--accent); }
+.show-more-results { width: 100%; margin-top: 12px; }
+.empty-state { padding: 26px 0; color: var(--muted); }
+.online-translation { margin-top: 20px; padding: 16px; border-radius: 12px; background: var(--soft); }
+.online-translation p { font-size: .88rem; }
+.online-translation a { display: inline-block; min-height: 32px; margin: 12px 0; font-weight: 700; }
+
+.primary { border-color: var(--accent); background: var(--accent); color: var(--on-accent); }
+.primary:hover { background: var(--accent); filter: brightness(.93); }
+.text-button { padding-inline: 0; border: 0; background: transparent; color: var(--accent); font-size: .88rem; }
+.text-button:hover { background: transparent; text-decoration: underline; }
+.practice-area { margin-top: 18px; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
+.practice-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.practice-question { margin: 20px 0; font-size: 1.7rem; font-weight: 750; overflow-wrap: anywhere; }
+.practice-area form { display: flex; gap: 8px; margin-top: 6px; }
+.practice-area input { width: 100%; }
+#practice-feedback { margin-top: 14px; white-space: pre-line; overflow-wrap: anywhere; }
+#practice-feedback.correct { color: var(--good); }
+#practice-feedback.incorrect { color: var(--bad); }
+#next-word { margin-top: 12px; }
+
+.offline-tools { margin-top: 30px; border-top: 1px solid var(--line); }
+.offline-tools > summary { display: flex; align-items: center; justify-content: space-between; min-height: 56px; color: var(--ink); cursor: pointer; font-weight: 750; list-style: none; }
+.offline-tools > summary::-webkit-details-marker { display: none; }
+.offline-tools > summary span:last-child { color: var(--accent); font-size: 1.45rem; transition: transform 180ms ease-out; }
+.offline-tools[open] > summary span:last-child { transform: rotate(90deg); }
+.offline-tools-body { padding: 2px 0 18px; }
+.offline-tools-body > p { margin-top: 8px; color: var(--muted); font-size: .88rem; }
+.download-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
+.download-actions > * { font-size: .85rem; }
+.offline-instructions { max-width: 68ch; }
+.local-note { margin-block: 14px !important; }
+#offline-status { white-space: pre-line; }
+#offline-status.error { color: var(--bad); }
+footer { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-top: 32px; padding-top: 18px; border-top: 1px solid var(--line); color: var(--muted); font-size: .75rem; }
+footer label { max-width: 190px; font-size: .75rem; }
+footer select { font-size: .85rem; }
+footer > span { text-align: end; }
+
+@media (max-width: 560px) {
+ h1 { font-size: 1.7rem; }
+ .dictionary-shell { padding-inline: 14px; }
+ .profile-heading { align-items: stretch; flex-direction: column; gap: 12px; }
+ .print-profile { align-self: start; }
+ .profile-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .profile-metric:nth-child(3) { border-inline-start: 0; }
+ .profile-metric:nth-child(n + 3) { border-top: 1px solid var(--line); }
+ .profile-detail-grid { grid-template-columns: 1fr; gap: 20px; }
+ .language-pair { gap: 6px; }
+ .language-pair select { padding-inline: 7px; font-size: .9rem; }
+ .save-word { max-width: 104px; padding: 8px; font-size: .75rem; }
+ .word-row { gap: 8px; }
+ .download-actions > * { flex-grow: 1; }
+ footer { align-items: start; flex-direction: column; gap: 12px; }
+ .practice-area { padding: 14px; }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ button, .button, .offline-tools > summary span:last-child { transition: background-color 160ms ease-out, color 160ms ease-out, transform 180ms ease-out; }
+}
+
+@media print {
+ :root { color-scheme: light; --bg: #ffffff; --surface: #ffffff; --ink: #111827; --muted: #4b5563; --line: #cbd5e1; --accent: #235bcc; --soft: #eef3ff; }
+ body { background: #ffffff; }
+ .dictionary-shell { max-width: none; padding: 0; }
+ .connection-status, .print-profile, .profile-practice, .dictionary-workspace, .practice-area, .show-more-results, .online-translation, .offline-tools, footer, .save-word { display: none !important; }
+ .intro { margin-block: 18px 12px; }
+ .learning-profile { break-inside: avoid; }
+ .results { margin-top: 24px; }
+ .word-row { break-inside: avoid; }
+}
diff --git a/mydictionary/static/dictionary.js b/mydictionary/static/dictionary.js
index 9d15e32..efe2776 100644
--- a/mydictionary/static/dictionary.js
+++ b/mydictionary/static/dictionary.js
@@ -2,6 +2,7 @@
"use strict";
const data = JSON.parse(document.getElementById("dictionary-data").textContent);
const packs = data.packs;
+ const profile = window.LexiDictionaryProfile;
const $ = (id) => document.getElementById(id);
const isFile = document.body.dataset.download === "true" || location.protocol === "file:";
const storageKey = "lexi:dictionary:v1";
@@ -16,8 +17,15 @@
title: "Tes mots, à portée de main.", intro: "Trouve une traduction. Garde-la. Retiens-la.", target: "J’apprends", native: "Traduction", swap: "Inverser les langues", search_label: "Mot ou courte phrase", placeholder: "Chercher dans les deux langues", clear: "Effacer", all: "Dictionnaire", saved: "Enregistrés", practice: "Réviser 5 mots", practice_title: "Un peu de pratique", close: "Fermer", answer_label: "Écris la traduction", check: "Vérifier", show: "Voir la réponse", next: "Mot suivant", online_hint: "Pour les mots et phrases hors de ce pack, utilise la traduction en ligne.", translate: "Traduire avec Yandex ↗", provider_note: "Seul ce clic envoie le texte à Yandex. Internet nécessaire.", offline_title: "Emporte ton dictionnaire", offline_body: "Enregistre les packs pour chercher et pratiquer sans internet.", save_offline: "Enregistrer sur cet appareil", download: "Télécharger le dictionnaire", export: "Exporter mes mots · CSV", offline_limit: "Hors ligne : mots inclus et exercices. IA, nouvelles traductions et audio en ligne nécessitent internet. Le navigateur peut vider son stockage.", how_open: "Comment ouvrir hors ligne", instructions: "Ouvre cette page dans Safari ou Chrome, enregistre-la sur cet appareil et ajoute-la à l’écran d’accueil. Tu peux aussi télécharger le fichier HTML et l’ouvrir dans un navigateur. Certains aperçus de fichiers sur téléphone ne sont pas interactifs.", local_note: "Mots et exercices restent dans ce navigateur, sans synchronisation avec ta progression Telegram.", interface: "Langue de l’interface", free: "Dictionnaire de base gratuit", remove_offline: "Supprimer les packs hors ligne", online: "Recherche locale · en ligne", offline: "Recherche locale · hors ligne", file_ready: "Dictionnaire téléchargé", cached: "Disponible hors ligne", pack: "{count} mots dans cette paire · recherche et pratique gratuites", results: "{count} résultats", truncated: "{shown} sur {count} affichés. Affine ta recherche.", save: "+ Garder", unsave: "✓ Gardé", no_match: "Ce mot n’est pas encore dans le pack. Essaie une recherche plus courte ou traduis-le en ligne.", no_saved: "Appuie sur « + Garder » pour retrouver ici tes mots à réviser.", empty_filter: "Aucun mot enregistré ne correspond.", saving: "Enregistrement…", save_success: "Dictionnaire enregistré. Ouvre cette page une fois dans ton navigateur pour pouvoir la rouvrir sans internet.", save_error: "Impossible d’enregistrer hors ligne. Réessaie dans Safari ou Chrome, ou télécharge le fichier HTML.", removed: "Packs hors ligne supprimés. Tes mots sont conservés.", storage_error: "Le navigateur ne peut pas enregistrer les changements. Exporte tes mots avant de fermer.", correct: "Exact !", correction: "Traduction correcte :", complete: "C’est assez pour aujourd’hui. Reviens demain pour une courte révision.", score: "{correct} sur {total} sans indice", position: "Mot {index} sur {total}", unsupported: "Cette langue n’est pas encore téléchargeable. Une paire disponible a été sélectionnée. Les autres langues restent disponibles dans Telegram.", remind: "À réessayer", no_export: "Enregistre d’abord un mot.", file_help: "Tous les packs sont déjà dans ce fichier. Ouvre-le dans un navigateur sans internet. Les favoris d’un autre navigateur ne sont pas copiés ici.", offline_provider: "Les nouvelles traductions nécessitent internet. Les mots téléchargés fonctionnent toujours.", recall: "Essaie de te rappeler avant d’afficher la réponse.", copied: "Mots enregistrés exportés."
}
};
+ const profileCopy = {
+ en: {title: "My word profile", intro: "Words and progress on this device.", profile_title: "Your progress", profile_scope: "Word totals use the selected language pair; study days cover all activity in this browser. Telegram progress stays in the Lexi Mini App.", metric_saved: "Saved", metric_learned: "Learned", metric_due: "Due now", metric_days: "Days with Lexi", mastery: "Vocabulary learned", last_seven: "Last 7 days", streak: "{count} day streak", profile_empty: "Save a word and complete reviews to build your profile.", print_profile: "PDF / print", search_heading: "Find a word", show_more: "Show more", export_tools: "Export and offline", practice: "Review now", truncated: "Showing {shown} of {count}"},
+ ru: {title: "Мой словарь", intro: "Слова и прогресс на этом устройстве.", profile_title: "Твой прогресс", profile_scope: "Слова считаются для выбранной языковой пары, а дни — по всем занятиям в этом браузере. Прогресс Telegram остаётся в Mini App Lexi.", metric_saved: "Сохранено", metric_learned: "Изучено", metric_due: "К повторению", metric_days: "Дней с Lexi", mastery: "Изучено слов", last_seven: "Последние 7 дней", streak: "Серия: {count} дн.", profile_empty: "Сохрани слово и повторяй его — здесь появится прогресс.", print_profile: "PDF / печать", search_heading: "Найти слово", show_more: "Показать ещё", export_tools: "Экспорт и офлайн", practice: "Повторить сейчас", truncated: "Показано {shown} из {count}"},
+ fr: {title: "Mon dictionnaire", intro: "Mots et progression sur cet appareil.", profile_title: "Ta progression", profile_scope: "Les mots concernent la paire de langues choisie ; les jours regroupent toute l’activité de ce navigateur. La progression Telegram reste dans la Mini App Lexi.", metric_saved: "Enregistrés", metric_learned: "Appris", metric_due: "À réviser", metric_days: "Jours avec Lexi", mastery: "Vocabulaire appris", last_seven: "7 derniers jours", streak: "Série : {count} j", profile_empty: "Enregistre un mot et révise-le pour construire ton profil.", print_profile: "PDF / imprimer", search_heading: "Trouver un mot", show_more: "Afficher plus", export_tools: "Export et hors ligne", practice: "Réviser maintenant", truncated: "{shown} sur {count} affichés"},
+ };
+ Object.entries(profileCopy).forEach(([locale, values]) => Object.assign(copy[locale], values));
let saved = {};
let preferences = {};
+ let activity = {};
try {
const stored = JSON.parse(localStorage.getItem(storageKey) || "{}");
if (stored.saved && typeof stored.saved === "object" && !Array.isArray(stored.saved)) {
@@ -25,6 +33,7 @@
// favourites, while bounding deliberately malformed local storage.
saved = Object.fromEntries(Object.entries(stored.saved).slice(0, 10000).filter(([key, value]) => /^[a-z]{2}:[a-z]{2}:[\w-]{1,100}$/.test(key) && value && typeof value === "object"));
}
+ activity = profile.sanitizeActivity(stored.activity);
preferences = Object.fromEntries(Object.entries(stored.preferences || {}).filter(([key,value]) => ["target","native","ui"].includes(key) && typeof value === "string" && /^[a-z]{2}$/.test(value)));
} catch (_) { /* Local lookup also works when storage is unavailable. */ }
const params = new URLSearchParams(location.search);
@@ -39,13 +48,20 @@
let view = "all";
let offlineReady = false;
let session = null;
+ const pageSize = 8;
+ let visibleLimit = pageSize;
const normalized = (value) => String(value || "").normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
const searchable = (value) => normalized(value).normalize("NFD").replace(/\p{M}/gu, "");
const t = (key, values = {}) => Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), copy[ui][key] || copy.en[key] || key);
function persist() {
- try { localStorage.setItem(storageKey, JSON.stringify({saved, preferences: {target, native, ui}})); }
+ try { localStorage.setItem(storageKey, JSON.stringify({saved, activity, preferences: {target, native, ui}})); }
catch (_) { $("offline-status").textContent = t("storage_error"); $("offline-status").className = "error"; }
}
+ function recordActivity() {
+ const now = new Date();
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+ activity = profile.sanitizeActivity({...activity, [date]: Math.min(999, (Number(activity[date]) || 0) + 1)});
+ }
function entries() {
const pack = packs.find((item) => item.target_language === target);
const meaningPack = packs.find((item) => item.target_language === native);
@@ -77,6 +93,7 @@
document.documentElement.lang = ui;
document.title = `Lexi · ${t("all")}`;
document.querySelectorAll("[data-copy]").forEach((node) => {node.textContent = t(node.dataset.copy);});
+ document.querySelectorAll("[data-copy-aria]").forEach((node) => {node.setAttribute("aria-label",t(node.dataset.copyAria));});
document.querySelectorAll("[data-label]").forEach((node) => {node.setAttribute("aria-label",t(node.dataset.label));});
$("dictionary-query").placeholder = t("placeholder");
$("target-language").setAttribute("aria-label",t("target"));
@@ -101,16 +118,41 @@
const button=document.createElement("button"); button.type="button"; button.className="save-word";
button.textContent=t(saved[entry.key] ? "unsave":"save"); button.setAttribute("aria-pressed",String(Boolean(saved[entry.key]))); button.setAttribute("aria-label",`${button.textContent}: ${entry.target}`);
button.addEventListener("click",()=>{
- if (saved[entry.key]) delete saved[entry.key]; else saved[entry.key]={due:Date.now(),interval:0};
+ if (saved[entry.key]) delete saved[entry.key];
+ else { saved[entry.key]={due:Date.now(),interval:0}; recordActivity(); }
persist(); render();
const replacement=Array.from($("dictionary-results").querySelectorAll(".save-word")).find((candidate)=>candidate.getAttribute("aria-label")?.endsWith(`: ${entry.target}`));
if (replacement) replacement.focus({preventScroll:true});
});
item.append(content,button); return item;
}
+ function renderProfile() {
+ const snapshot = profile.buildSnapshot({saved, activity, pairPrefix:`${target}:${native}:`, now:Date.now()});
+ $("profile-saved").textContent = snapshot.saved;
+ $("profile-learned").textContent = snapshot.learned;
+ $("profile-due").textContent = snapshot.due;
+ $("profile-days").textContent = snapshot.studyDays;
+ $("profile-mastery-value").textContent = `${snapshot.learned} / ${snapshot.saved}`;
+ $("profile-progress").value = snapshot.progress;
+ $("profile-progress").textContent = `${snapshot.progress}%`;
+ $("profile-empty").hidden = snapshot.saved > 0;
+ $("profile-streak").textContent = t("streak", {count:snapshot.streak});
+ const weekday = new Intl.DateTimeFormat(ui, {weekday:"narrow"});
+ $("profile-activity").replaceChildren(...snapshot.week.map((day) => {
+ const item = document.createElement("span");
+ item.className = `activity-day level-${day.level}`;
+ item.setAttribute("role", "listitem");
+ item.setAttribute("aria-label", `${day.date}: ${day.count}`);
+ const bar = document.createElement("i"); bar.setAttribute("aria-hidden", "true");
+ const label = document.createElement("b"); label.textContent = weekday.format(new Date(`${day.date}T12:00:00`));
+ item.append(bar, label);
+ return item;
+ }));
+ }
function render() {
const all=entries(); const query=$("dictionary-query").value.trim(); const needle=searchable(query);
const selected=all.filter((entry)=>saved[entry.key]);
+ renderProfile();
$("saved-count").textContent=selected.length;
$("pack-note").textContent=t("pack",{count:all.length});
$("view-all").setAttribute("aria-pressed",String(view==="all")); $("view-saved").setAttribute("aria-pressed",String(view==="saved"));
@@ -119,8 +161,9 @@
const rank=!needle ? 0 : fields.includes(needle) ? 0 : fields.some((field)=>field.startsWith(needle)) ? 1 : fields.some((field)=>field.includes(needle)) ? 2 : 9;
return {entry,rank};
}).filter((item)=>item.rank<9).sort((a,b)=>a.rank-b.rank).map((item)=>item.entry);
- const shown=matches.slice(0,50); $("dictionary-results").replaceChildren(...shown.map(row));
- $("result-status").textContent=matches.length>50?t("truncated",{shown:50,count:matches.length}):t("results",{count:matches.length});
+ const shown=matches.slice(0,visibleLimit); $("dictionary-results").replaceChildren(...shown.map(row));
+ $("result-status").textContent=matches.length>shown.length?t("truncated",{shown:shown.length,count:matches.length}):t("results",{count:matches.length});
+ $("show-more-results").hidden=shown.length>=matches.length;
$("empty-results").hidden=matches.length>0;
$("empty-copy").textContent=t(view==="saved" ? selected.length ? "empty_filter":"no_saved":"no_match");
$("start-practice").disabled=all.length===0;
@@ -131,15 +174,17 @@
$("online-translation").querySelector("p").textContent=t(navigator.onLine?"online_hint":"offline_provider");
}
function closePractice(){session=null;$("practice-area").hidden=true;$("dictionary-results").hidden=false;render();}
- function changePair(){closePractice();$("pair-notice").hidden=true;$("dictionary-query").value="";persist();applyCopy();render();}
+ function resetResults(){visibleLimit=pageSize;}
+ function changePair(){closePractice();$("pair-notice").hidden=true;$("dictionary-query").value="";resetResults();persist();applyCopy();render();}
$("target-language").addEventListener("change",()=>{const old=target;target=$("target-language").value;if(native===target) native=old;changePair();});
$("native-language").addEventListener("change",()=>{const old=native;native=$("native-language").value;if(target===native) target=old;changePair();});
$("swap-languages").addEventListener("click",()=>{[target,native]=[native,target];changePair();});
$("interface-language").addEventListener("change",()=>{ui=$("interface-language").value;closePractice();persist();applyCopy();render();});
- $("dictionary-query").addEventListener("input",render);
- $("clear-query").addEventListener("click",()=>{$("dictionary-query").value="";render();$("dictionary-query").focus();});
- $("view-all").addEventListener("click",()=>{view="all";render();});
- $("view-saved").addEventListener("click",()=>{view="saved";render();});
+ $("dictionary-query").addEventListener("input",()=>{resetResults();render();});
+ $("clear-query").addEventListener("click",()=>{$("dictionary-query").value="";resetResults();render();$("dictionary-query").focus();});
+ $("view-all").addEventListener("click",()=>{view="all";resetResults();render();});
+ $("view-saved").addEventListener("click",()=>{view="saved";resetResults();render();});
+ $("show-more-results").addEventListener("click",()=>{visibleLimit+=pageSize;render();});
function practiceWord() {
const entry=session.words[session.index];
$("practice-position").textContent=t("position",{index:session.index+1,total:session.words.length});
@@ -169,7 +214,9 @@
$("practice-feedback").textContent=`${t(correct?"correct":"correction")}\n${entry.meaning}`;
$("practice-feedback").className=correct?"correct":"incorrect";
$("practice-input").disabled=true;$("check-answer").disabled=true;$("show-answer").hidden=true;$("next-word").hidden=false;
- if(saved[entry.key]){const previous=Math.max(0,Math.min(30,Number(saved[entry.key].interval)||0));const interval=correct?Math.min(30,Math.max(1,previous*2)):0;saved[entry.key]={interval,due:Date.now()+(correct?interval*86400000:120000)};persist();}
+ recordActivity();
+ if(saved[entry.key]){const previous=Math.max(0,Math.min(30,Number(saved[entry.key].interval)||0));const interval=correct?Math.min(30,Math.max(1,previous*2)):0;saved[entry.key]={interval,due:Date.now()+(correct?interval*86400000:120000)};}
+ persist();renderProfile();
}
$("practice-form").addEventListener("submit",(event)=>{event.preventDefault();answer();});
$("show-answer").addEventListener("click",()=>answer(true));
@@ -190,7 +237,18 @@
const url=URL.createObjectURL(new Blob([csv],{type:"text/csv;charset=utf-8"}));const link=document.createElement("a");link.href=url;link.download=`lexi-${target}-${native}.csv`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);$("offline-status").textContent=t("copied");
}
$("export-saved").addEventListener("click",downloadCsv);
- const offlineAssets=["/dictionary/","/static/dictionary.css","/static/dictionary.js","/dictionary/manifest.webmanifest"];
+ let printState = null;
+ function restorePrintState(){
+ if(!printState)return;
+ view=printState.view;visibleLimit=printState.visibleLimit;$("dictionary-query").value=printState.query;
+ printState=null;document.body.classList.remove("printing");render();
+ }
+ $("print-profile").addEventListener("click",()=>{
+ printState={view,visibleLimit,query:$("dictionary-query").value};
+ view="saved";visibleLimit=10000;$("dictionary-query").value="";document.body.classList.add("printing");render();window.print();
+ });
+ window.addEventListener("afterprint",restorePrintState);
+ const offlineAssets=["/dictionary/","/static/dictionary-profile.js","/static/dictionary.css","/static/dictionary.js","/dictionary/manifest.webmanifest"];
const ownCache=(key)=>/^lexi-dictionary-[a-f0-9]{16}$/.test(key);
async function inspectOffline(){
if(isFile||!("caches" in window)||!("serviceWorker" in navigator))return;
diff --git a/mydictionary/templates/dictionary.html b/mydictionary/templates/dictionary.html
index 23b76f2..456e682 100644
--- a/mydictionary/templates/dictionary.html
+++ b/mydictionary/templates/dictionary.html
@@ -20,10 +20,38 @@
Find a word. Save it. Remember it. A living dictionary on this device. Word totals use the selected language pair; study days cover all activity in this browser. Telegram progress stays in the Lexi Mini App. Save a word and complete reviews to build your profile.Your words, at hand.
- My word profile
+ Your progress
+ Find a word
Your words, at hand.
A little practice
Your words, at hand.
Save these packs for local search and practice without internet.
Save these packs for local search and practice without internet.
+Open this page in Safari or Chrome, save it on this device, then add it to your home screen. You can also download the HTML file and open it in a browser.
+Saved words and practice stay in this browser. They do not sync with your Telegram progress.
+Open this page in Safari or Chrome, save it on this device, then add it to your home screen. You can also download the HTML file and open it in a browser. Some phone file previews do not run interactive files.
Saved words and practice stay in this browser. They do not sync with your Telegram progress.
- - +