Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .tdd/spec-dictionary-learning-profile-v1.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 17 additions & 5 deletions docs/offline-dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
17 changes: 15 additions & 2 deletions mydictionary/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'; ", ""
Expand All @@ -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),
Expand All @@ -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"
Expand Down
15 changes: 10 additions & 5 deletions mydictionary/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'"
)
Expand All @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions mydictionary/static/dictionary-profile.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion mydictionary/static/dictionary-sw.js
Original file line number Diff line number Diff line change
@@ -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()));
});
Expand Down
Loading
Loading