From 2262a7596623575b202d3a68a399dc55a2943b27 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 17 Sep 2026 19:35:23 +0700 Subject: [PATCH] feat: turn dictionary into learning profile --- .tdd/spec-dictionary-learning-profile-v1.md | 30 +++ docs/offline-dictionary.md | 22 ++- mydictionary/admin.py | 17 +- mydictionary/dictionary.py | 15 +- mydictionary/static/dictionary-profile.js | 81 ++++++++ mydictionary/static/dictionary-sw.js | 2 +- mydictionary/static/dictionary.css | 186 ++++++++++++++++++- mydictionary/static/dictionary.js | 80 ++++++-- mydictionary/templates/dictionary.html | 72 +++++-- tests/browser/dictionary-profile.cjs | 67 +++++++ tests/browser/dictionary-upgrade.cjs | 14 +- tests/browser/dictionary.cjs | 18 +- tests/test_dictionary_learning_profile_v1.py | 50 +++++ tests/test_offline_dictionary.py | 8 +- 14 files changed, 606 insertions(+), 56 deletions(-) create mode 100644 .tdd/spec-dictionary-learning-profile-v1.md create mode 100644 mydictionary/static/dictionary-profile.js create mode 100644 tests/browser/dictionary-profile.cjs create mode 100644 tests/test_dictionary_learning_profile_v1.py 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 @@
-

Your words, at hand.

-

Find a word. Save it. Remember it.

- Download dictionary +

My word profile

+

A living dictionary on this device.

+
+
+
+

Your progress

+

Word totals use the selected language pair; study days cover all activity in this browser. Telegram progress stays in the Lexi Mini App.

+
+ +
+
+
0Saved
+
0Learned
+
0Due now
+
0Days with Lexi
+
+
+
+
Vocabulary learned0 / 0
+ 0% +

Save a word and complete reviews to build your profile.

+
+
+
Last 7 days0
+
+
+
+ +
+
+

Find a word

@@ -38,8 +66,8 @@

Your words, at hand.

- +

+ -
-

Take your dictionary with you

Save these packs for local search and practice without internet.

-
- - Download dictionary - +
+ Export and offline +
+

Save these packs for local search and practice without internet.

+
+ + Download dictionary + +
+

+ Offline: included words and flashcards. AI, new translations and online audio need internet. Browser storage can be cleared by your device. +

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.

+
-

- Offline: included words and flashcards. AI, new translations and online audio need internet. Browser storage can be cleared by your device. -
How to open offline

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.

- -
+ - {% if offline_download %}{% else %}{% endif %} + {% if offline_download %} + + + {% else %} + + + {% endif %} diff --git a/tests/browser/dictionary-profile.cjs b/tests/browser/dictionary-profile.cjs new file mode 100644 index 0000000..11abcc0 --- /dev/null +++ b/tests/browser/dictionary-profile.cjs @@ -0,0 +1,67 @@ +const assert = require("node:assert/strict"); +const profile = require("../../mydictionary/static/dictionary-profile.js"); + +const now = Date.parse("2026-09-17T12:00:00Z"); + +{ + const activity = profile.sanitizeActivity({ + "2026-09-14": 1, + "2026-09-15": 2, + "2026-09-16": 3, + "2026-09-17": 4, + "2026-02-30": 7, + private: 99, + "2026-09-13": -4, + "2026-09-12": 999999, + }); + assert.deepEqual(activity, { + "2026-09-12": 999, + "2026-09-14": 1, + "2026-09-15": 2, + "2026-09-16": 3, + "2026-09-17": 4, + }); +} + +{ + const snapshot = profile.buildSnapshot({ + saved: { + "en:ru:hello": {interval: 8, due: now + 86_400_000}, + "en:ru:school": {interval: 2, due: now - 1}, + "en:ru:book": {interval: 0, due: now + 120_000}, + "fr:ru:hello": {interval: 30, due: now - 1}, + }, + activity: { + "2026-09-12": 1, + "2026-09-14": 1, + "2026-09-15": 2, + "2026-09-16": 3, + "2026-09-17": 4, + }, + pairPrefix: "en:ru:", + now, + }); + assert.equal(snapshot.saved, 3); + assert.equal(snapshot.learned, 1); + assert.equal(snapshot.learning, 2); + assert.equal(snapshot.due, 1); + assert.equal(snapshot.studyDays, 5); + assert.equal(snapshot.streak, 4); + assert.equal(snapshot.progress, 33); + assert.deepEqual(snapshot.week.map((day) => [day.date, day.count]), [ + ["2026-09-11", 0], ["2026-09-12", 1], ["2026-09-13", 0], + ["2026-09-14", 1], ["2026-09-15", 2], ["2026-09-16", 3], + ["2026-09-17", 4], + ]); + assert.equal(snapshot.week.at(-1).level, 4); +} + +{ + const empty = profile.buildSnapshot({saved: {}, activity: {}, pairPrefix: "en:ru:", now}); + assert.deepEqual( + {saved: empty.saved, learned: empty.learned, due: empty.due, studyDays: empty.studyDays, streak: empty.streak, progress: empty.progress}, + {saved: 0, learned: 0, due: 0, studyDays: 0, streak: 0, progress: 0}, + ); +} + +console.log(JSON.stringify({ok: true, checks: ["bounded activity", "pair metrics", "seven-day chart", "streak", "legacy empty state"]})); diff --git a/tests/browser/dictionary-upgrade.cjs b/tests/browser/dictionary-upgrade.cjs index c867d23..bf35f28 100644 --- a/tests/browser/dictionary-upgrade.cjs +++ b/tests/browser/dictionary-upgrade.cjs @@ -10,7 +10,7 @@ const {chromium} = require("playwright"); const root = path.resolve(__dirname, "../.."); const ownCache = /^lexi-dictionary-[a-f0-9]{16}$/; -const allowedPaths = ["/dictionary/", "/static/dictionary.css", "/static/dictionary.js", "/dictionary/manifest.webmanifest"]; +const allowedPaths = ["/dictionary/", "/static/dictionary-profile.js", "/static/dictionary.css", "/static/dictionary.js", "/dictionary/manifest.webmanifest"]; async function main() { // Render the actual template and public projection without initializing a DB. @@ -23,12 +23,12 @@ from mydictionary.dictionary import build_dictionary_data, dictionary_content_se env = Environment(loader=FileSystemLoader("mydictionary/templates"), autoescape=select_autoescape()) html = env.get_template("dictionary.html").render( dictionary_data=build_dictionary_data(load_catalog()), dictionary_css=None, - dictionary_js=None, dictionary_defaults=None, offline_download=False, + dictionary_profile_js=None, dictionary_js=None, dictionary_defaults=None, offline_download=False, dictionary_csp=dictionary_content_security_policy().replace("frame-ancestors 'none'; ", "")) print(json.dumps({"html": html, "manifest": dictionary_manifest()})) `], {cwd:root, encoding:"utf8", maxBuffer:2 * 1024 * 1024})); - const [javascript, css, worker] = await Promise.all( - ["dictionary.js", "dictionary.css", "dictionary-sw.js"].map(name => + const [profileJavascript, javascript, css, worker] = await Promise.all( + ["dictionary-profile.js", "dictionary.js", "dictionary.css", "dictionary-sw.js"].map(name => fs.readFile(path.join(root, "mydictionary/static", name), "utf8")) ); assert(worker.includes("__DICTIONARY_REVISION__"), "Fixture must use the real revisioned worker"); @@ -50,8 +50,8 @@ print(json.dumps({"html": html, "manifest": dictionary_manifest()})) const style = css + `\n:root { --fixture-revision: ${marker}; }\n`; const manifest = JSON.stringify(rendered.manifest); const revision = crypto.createHash("sha256") - .update(JSON.stringify({html, js, style, manifest, worker})).digest("hex").slice(0, 16); - return {html, js, style, manifest, revision}; + .update(JSON.stringify({html, profileJavascript, js, style, manifest, worker})).digest("hex").slice(0, 16); + return {html, profileJavascript, js, style, manifest, revision}; } const version1 = fixture("v1"); @@ -63,6 +63,7 @@ print(json.dumps({"html": html, "manifest": dictionary_manifest()})) requests.push({pathname, revision:current.revision}); const routes = { "/dictionary/": ["text/html; charset=utf-8", current.html], + "/static/dictionary-profile.js": ["application/javascript", current.profileJavascript], "/static/dictionary.js": ["application/javascript", current.js], "/static/dictionary.css": ["text/css", current.style], "/dictionary/manifest.webmanifest": ["application/manifest+json", current.manifest], @@ -96,6 +97,7 @@ print(json.dumps({"html": html, "manifest": dictionary_manifest()})) const page = await context.newPage(); await page.goto(`${base}/dictionary/?target=en&native=ru&ui=en`); await page.locator(".word-row").first().waitFor(); + await page.locator(".offline-tools > summary").click(); await page.locator("#save-offline").click(); await page.waitForFunction(() => document.getElementById("offline-status").textContent.includes("Dictionary saved")); diff --git a/tests/browser/dictionary.cjs b/tests/browser/dictionary.cjs index 745e589..6c6bd05 100644 --- a/tests/browser/dictionary.cjs +++ b/tests/browser/dictionary.cjs @@ -18,12 +18,25 @@ const path = require("node:path"); try { await page.goto(`${base}/dictionary/?target=de&native=ru&ui=ru`); await page.locator(".word-row").first().waitFor(); + assert.equal(await page.locator(".word-row").count(),8); + assert.equal(await page.locator("#show-more-results").isVisible(),true); + await page.locator("#show-more-results").click(); + assert.equal(await page.locator(".word-row").count(),16); assert.equal(await page.locator("#target-language").inputValue(),"de"); await page.locator("#dictionary-query").fill("школа"); assert.match(await page.locator("#dictionary-results").innerText(),/Schule/); await page.locator(".save-word").first().click(); await page.reload(); assert.equal(await page.locator("#saved-count").innerText(),"1"); + assert.equal(await page.locator("#profile-saved").innerText(),"1"); + assert.equal(await page.locator("#profile-days").innerText(),"1"); + await page.evaluate(()=>{window.print=()=>{document.body.dataset.printed="true";};}); + await page.locator("#print-profile").click(); + assert.equal(await page.locator("body").getAttribute("data-printed"),"true"); + assert.equal(await page.locator("#view-saved").getAttribute("aria-pressed"),"true"); + assert.equal(await page.locator(".word-row").count(),1); + await page.evaluate(()=>window.dispatchEvent(new Event("afterprint"))); + assert.equal(await page.locator("#view-all").getAttribute("aria-pressed"),"true"); await page.locator("#view-saved").click(); assert.equal(await page.locator(".word-row").count(),1); await page.locator("#start-practice").click(); @@ -54,6 +67,7 @@ const path = require("node:path"); await page.locator("#dictionary-query").fill("école"); await page.screenshot({path:path.join(output,"mobile.png"),fullPage:true}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + await page.locator(".offline-tools > summary").click(); const downloadPromise=page.waitForEvent("download"); await page.locator("#download-dictionary").click(); const download=await downloadPromise; @@ -66,7 +80,7 @@ const path = require("node:path"); await page.locator("#save-offline").click(); await page.waitForFunction(()=>document.getElementById("offline-status").textContent.includes("Словарь сохранён")); const cacheUrls=await page.evaluate(async()=>{const results=[];for(const key of await caches.keys()){const c=await caches.open(key);results.push(...(await c.keys()).map(request=>new URL(request.url).pathname));}return results;}); - assert(cacheUrls.every(url=>["/dictionary/","/static/dictionary.css","/static/dictionary.js","/dictionary/manifest.webmanifest"].includes(url))); + assert(cacheUrls.every(url=>["/dictionary/","/static/dictionary-profile.js","/static/dictionary.css","/static/dictionary.js","/dictionary/manifest.webmanifest"].includes(url))); await context.setOffline(true); assert.equal(await page.locator("#translate-link").isVisible(),false); await page.close(); @@ -100,6 +114,6 @@ const path = require("node:path"); await desktop.evaluate(()=>localStorage.setItem("lexi:dictionary:v1",JSON.stringify({preferences:{ui:42,target:[],native:{}},saved:{}}))); await desktop.reload();await desktop.locator(".word-row").first().waitFor(); assert.deepEqual(errors,[]); - console.log(JSON.stringify({ok:true,checks:["42 language pairs","Unicode/reverse lookup","saved persistence","written feedback","no external query traffic","mobile overflow","offline save/remove/resave","cold offline tab","standalone download","malformed storage"],output})); + console.log(JSON.stringify({ok:true,checks:["42 language pairs","progressive results","Unicode/reverse lookup","saved profile persistence","print/PDF snapshot","written feedback","no external query traffic","mobile overflow","offline save/remove/resave","cold offline tab","standalone download","malformed storage"],output})); } finally {await browser.close();} })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/test_dictionary_learning_profile_v1.py b/tests/test_dictionary_learning_profile_v1.py new file mode 100644 index 0000000..77aaac4 --- /dev/null +++ b/tests/test_dictionary_learning_profile_v1.py @@ -0,0 +1,50 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HTML = (ROOT / "mydictionary/templates/dictionary.html").read_text(encoding="utf-8") +CSS = (ROOT / "mydictionary/static/dictionary.css").read_text(encoding="utf-8") +JS = (ROOT / "mydictionary/static/dictionary.js").read_text(encoding="utf-8") +SW = (ROOT / "mydictionary/static/dictionary-sw.js").read_text(encoding="utf-8") +ADMIN = (ROOT / "mydictionary/admin.py").read_text(encoding="utf-8") + + +class DictionaryLearningProfileV1ContractTest(unittest.TestCase): + def test_ac1_profile_precedes_dictionary_controls_and_names_local_scope(self): + profile = HTML.index('id="learning-profile"') + self.assertLess(profile, HTML.index('id="target-language"')) + self.assertLess(profile, HTML.index('id="dictionary-results"')) + for identifier in ( + "profile-saved", "profile-learned", "profile-due", "profile-days", + "profile-progress", "profile-activity", "profile-streak", + ): + self.assertIn(f'id="{identifier}"', HTML) + self.assertIn('data-copy="profile_scope"', HTML) + + def test_ac3_results_are_progressively_revealed(self): + self.assertIn("const pageSize = 8", JS) + self.assertIn('id="show-more-results"', HTML) + self.assertIn('data-copy="show_more"', HTML) + self.assertIn("visibleLimit = pageSize", JS) + + def test_ac4_pdf_and_offline_tools_are_secondary(self): + self.assertIn('id="print-profile"', HTML) + self.assertIn("window.print()", JS) + self.assertRegex(HTML, r']*>') + self.assertNotRegex(HTML, r']*\sopen(?:\s|=|>)') + for identifier in ("save-offline", "download-dictionary", "export-saved"): + self.assertEqual(HTML.count(f'id="{identifier}"'), 1) + self.assertIn("@media print", CSS) + + def test_ac5_profile_runtime_is_shared_online_offline_and_cached(self): + profile_script = 'src="/static/dictionary-profile.js"' + dictionary_script = 'src="/static/dictionary.js"' + self.assertLess(HTML.index(profile_script), HTML.index(dictionary_script)) + self.assertIn('dictionary_profile_js|safe', HTML) + self.assertIn('"dictionary-profile.js"', ADMIN) + self.assertIn('"/static/dictionary-profile.js"', SW) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_offline_dictionary.py b/tests/test_offline_dictionary.py index c0d9500..dbcd1bc 100644 --- a/tests/test_offline_dictionary.py +++ b/tests/test_offline_dictionary.py @@ -185,7 +185,13 @@ def worker_source(): baseline = worker_source() self.assertEqual(worker_source(), baseline) original_read_bytes = Path.read_bytes - for filename in ("dictionary.css", "dictionary.js", "dictionary.html", "dictionary-sw.js"): + for filename in ( + "dictionary-profile.js", + "dictionary.css", + "dictionary.js", + "dictionary.html", + "dictionary-sw.js", + ): def changed_asset(path, filename=filename): source = original_read_bytes(path) return source + b"\n/* revision test */" if path.name == filename else source