Refactor flag handling to discard audio recordings
Browse files- Updated the flagging process to no longer store user audio recordings, retaining only structured diagnostics.
- Modified the `add_flag` method to accept only the row data without audio parameters.
- Adjusted related API endpoints and tests to reflect the removal of audio storage.
- Updated documentation to clarify changes in flagging behavior and data storage.
- README.md +5 -6
- app/auth.py +3 -7
- app/main.py +13 -13
- static/guide.html +2 -4
- static/index.html +67 -60
- tests/test_scoring.py +7 -11
README.md
CHANGED
|
@@ -231,15 +231,15 @@ create table public.tutors (
|
|
| 231 |
alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
|
| 232 |
```
|
| 233 |
|
| 234 |
-
Under each score a teacher or a tutor gets an optional note and the button "🚩 אמרתי נכון, המערכת טעתה". Pressing it sends the recording to `POST /api/flags` (teachers and tutors only), which scores it again and
|
| 235 |
|
| 236 |
- who and what: `email`, `cat`, `phrase`, `he`, `note`;
|
| 237 |
- the answer the scorer matched (`ar`, `ipa`), its free decode (`heard_ipa`), `score` and `unrelated`;
|
| 238 |
- `result`: the whole score response (every word and phoneme with its score and what took its place, the tips, the issues);
|
| 239 |
- `model` and `version`, so a flag can be checked against the model and build that produced it;
|
| 240 |
-
- `audio`:
|
| 241 |
|
| 242 |
-
Create the table
|
| 243 |
|
| 244 |
```sql
|
| 245 |
create table public.flags (
|
|
@@ -257,7 +257,7 @@ create table public.flags (
|
|
| 257 |
result jsonb not null, -- the whole score response
|
| 258 |
model text not null,
|
| 259 |
version text not null default '',
|
| 260 |
-
audio text, --
|
| 261 |
status text not null default 'pending', -- pending, confirmed, rejected, or fixed
|
| 262 |
category text,
|
| 263 |
reviewed_by text,
|
|
@@ -267,7 +267,6 @@ create table public.flags (
|
|
| 267 |
created_at timestamptz not null default now()
|
| 268 |
);
|
| 269 |
alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
|
| 270 |
-
insert into storage.buckets (id, name) values ('flags', 'flags') on conflict (id) do nothing;
|
| 271 |
```
|
| 272 |
|
| 273 |
For a `flags` table created before reviews were added, run once:
|
|
@@ -282,7 +281,7 @@ alter table public.flags
|
|
| 282 |
add column if not exists false_issues jsonb not null default '[]'::jsonb;
|
| 283 |
```
|
| 284 |
|
| 285 |
-
The management panel's **דיווחים** tab lets a teacher
|
| 286 |
|
| 287 |
### The lab (מעבדה)
|
| 288 |
|
|
|
|
| 231 |
alter table public.tutors enable row level security; -- no policy: only the secret key reads and writes it
|
| 232 |
```
|
| 233 |
|
| 234 |
+
Under each score a teacher or a tutor gets an optional note and the button "🚩 אמרתי נכון, המערכת טעתה". Pressing it sends the recording to `POST /api/flags` (teachers and tutors only), which scores it again in memory and then discards the audio. It keeps the structured diagnosis in the table `flags`, written with `SUPABASE_SECRET_KEY`: the sentence, what the model heard, each word's score, and the sounds that were marked down and what took their place. The row holds:
|
| 235 |
|
| 236 |
- who and what: `email`, `cat`, `phrase`, `he`, `note`;
|
| 237 |
- the answer the scorer matched (`ar`, `ipa`), its free decode (`heard_ipa`), `score` and `unrelated`;
|
| 238 |
- `result`: the whole score response (every word and phoneme with its score and what took its place, the tips, the issues);
|
| 239 |
- `model` and `version`, so a flag can be checked against the model and build that produced it;
|
| 240 |
+
- `audio`: always null for new reports; the nullable legacy column is retained so old reports with recordings remain readable.
|
| 241 |
|
| 242 |
+
Create the table once (until then a flag is refused with this command in the message):
|
| 243 |
|
| 244 |
```sql
|
| 245 |
create table public.flags (
|
|
|
|
| 257 |
result jsonb not null, -- the whole score response
|
| 258 |
model text not null,
|
| 259 |
version text not null default '',
|
| 260 |
+
audio text, -- legacy recordings only; new reports leave this null
|
| 261 |
status text not null default 'pending', -- pending, confirmed, rejected, or fixed
|
| 262 |
category text,
|
| 263 |
reviewed_by text,
|
|
|
|
| 267 |
created_at timestamptz not null default now()
|
| 268 |
);
|
| 269 |
alter table public.flags enable row level security; -- no policy: only the secret key reads and writes it
|
|
|
|
| 270 |
```
|
| 271 |
|
| 272 |
For a `flags` table created before reviews were added, run once:
|
|
|
|
| 281 |
add column if not exists false_issues jsonb not null default '[]'::jsonb;
|
| 282 |
```
|
| 283 |
|
| 284 |
+
The management panel's **דיווחים** tab lets a teacher confirm or reject each report, classify it, and select the individual sound judgements that were false. It can still play a legacy recording when an older row has one, but new reports deliberately retain only diagnostics. The tab groups confirmed `expected → heard` pairs by reports, reporters, and phrases. Only confirmed reports should be used for calibration; review and aggregation use the stored structured result without needing the user's voice file.
|
| 285 |
|
| 286 |
### The lab (מעבדה)
|
| 287 |
|
app/auth.py
CHANGED
|
@@ -492,13 +492,9 @@ class SupabaseAuth:
|
|
| 492 |
media_type = mimetypes.guess_type(name)[0] or media_type
|
| 493 |
return data, media_type
|
| 494 |
|
| 495 |
-
def add_flag(self, row: dict
|
| 496 |
-
"""Keeps
|
| 497 |
-
|
| 498 |
-
without it (`audio` null); False tells that. A row that cannot be kept is raised."""
|
| 499 |
-
kept = self._put_object("flags", name, audio, mime)
|
| 500 |
-
self._admin("/rest/v1/flags", "POST", {**row, "audio": name if kept else None})
|
| 501 |
-
return kept
|
| 502 |
|
| 503 |
def list_flags(self, status: str = "pending") -> list[dict]:
|
| 504 |
"""Flagged scores for the teacher's review, newest first."""
|
|
|
|
| 492 |
media_type = mimetypes.guess_type(name)[0] or media_type
|
| 493 |
return data, media_type
|
| 494 |
|
| 495 |
+
def add_flag(self, row: dict) -> None:
|
| 496 |
+
"""Keeps the structured diagnosis of a flagged score, without retaining the user's recording."""
|
| 497 |
+
self._admin("/rest/v1/flags", "POST", {**row, "audio": None})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
|
| 499 |
def list_flags(self, status: str = "pending") -> list[dict]:
|
| 500 |
"""Flagged scores for the teacher's review, newest first."""
|
app/main.py
CHANGED
|
@@ -708,7 +708,10 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 708 |
result.append({**account, "roles": sorted(roles & ROLES),
|
| 709 |
"entitlements": sorted(entitlements), "permissions": sorted(effective),
|
| 710 |
"fixed_roles": sorted(set(fixed))})
|
| 711 |
-
|
|
|
|
|
|
|
|
|
|
| 712 |
"me": actor["id"] if actor else ""}
|
| 713 |
|
| 714 |
@app.put("/api/access/users/{target_id}")
|
|
@@ -749,6 +752,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 749 |
except AuthUnavailable as e:
|
| 750 |
if "answered 404" in str(e):
|
| 751 |
raise HTTPException(503, f"permissions table is missing; run: {CREATE_USER_ACCESS}")
|
|
|
|
|
|
|
| 752 |
raise HTTPException(503, f"Supabase לא זמין: {e}")
|
| 753 |
return {"ok": True}
|
| 754 |
|
|
@@ -1851,7 +1856,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1851 |
result = app.state.scorer.score(wav, phrase, [])
|
| 1852 |
if result.get("error"):
|
| 1853 |
raise HTTPException(400, result["message"])
|
| 1854 |
-
return save_flag(phrase, result,
|
| 1855 |
|
| 1856 |
def tutor_for(user: dict | None) -> Tutor:
|
| 1857 |
"""The paid tutor game, after the tutor.use dependency authorized the account (or on an open service)."""
|
|
@@ -2023,10 +2028,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2023 |
@app.post("/api/flags")
|
| 2024 |
def flag(phrase_id: int = Form(...), audio: UploadFile = File(...), note: str = Form(""),
|
| 2025 |
user: dict | None = Depends(signed_in)):
|
| 2026 |
-
"""A tutor's flag: this recording of the phrase was said right and the score was wrong.
|
| 2027 |
-
|
| 2028 |
-
(see CREATE_FLAGS) and the recording beside it. {"ok", "audio": whether the recording was kept too, "flag": the
|
| 2029 |
-
row that was kept, which the page shows the tutor}."""
|
| 2030 |
if not user or not user["tutor"]:
|
| 2031 |
raise HTTPException(403, "tutors only")
|
| 2032 |
phrase = phrase_of(phrase_id)
|
|
@@ -2041,25 +2044,22 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 2041 |
result = app.state.scorer.score(wav, phrase, refs)
|
| 2042 |
if result.get("error"):
|
| 2043 |
raise HTTPException(400, result["message"])
|
| 2044 |
-
return save_flag(phrase, result,
|
| 2045 |
|
| 2046 |
-
def save_flag(phrase: dict, result: dict,
|
| 2047 |
-
note: str, user: dict) -> dict:
|
| 2048 |
"""Store a reviewed-score candidate from a bank phrase or a dynamic verb-game phrase."""
|
| 2049 |
-
mime = (content_type or "application/octet-stream").lower()
|
| 2050 |
-
ext = AUDIO_EXT.get(mime.partition(";")[0].strip().rpartition("/")[2], "bin")
|
| 2051 |
row = {"email": user["email"].lower(), "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]),
|
| 2052 |
"he": phrase["he"], "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
|
| 2053 |
"heard_ipa": result["heard_ipa"], "score": result["score"], "unrelated": bool(result["unrelated"]),
|
| 2054 |
"note": " ".join(note.split())[:MAX_NOTE], "result": result, "model": MODEL_ID, "version": build_version()}
|
| 2055 |
try:
|
| 2056 |
-
|
| 2057 |
except AuthUnavailable as e:
|
| 2058 |
if "answered 404" in str(e):
|
| 2059 |
raise HTTPException(503, "הטבלה flags לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
|
| 2060 |
f"{CREATE_FLAGS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 2061 |
raise HTTPException(503, f"Supabase לא זמין: {e}")
|
| 2062 |
-
return {"ok": True, "audio":
|
| 2063 |
|
| 2064 |
return app
|
| 2065 |
|
|
|
|
| 708 |
result.append({**account, "roles": sorted(roles & ROLES),
|
| 709 |
"entitlements": sorted(entitlements), "permissions": sorted(effective),
|
| 710 |
"fixed_roles": sorted(set(fixed))})
|
| 711 |
+
# A successful read cannot tell us which CHECK expression PostgreSQL has, and showing the
|
| 712 |
+
# migration unconditionally makes it look unfinished even after it was run. If an old
|
| 713 |
+
# constraint rejects a newly selected entitlement, update_access returns the migration there.
|
| 714 |
+
return {"users": result, "setup": "", "migration": "",
|
| 715 |
"me": actor["id"] if actor else ""}
|
| 716 |
|
| 717 |
@app.put("/api/access/users/{target_id}")
|
|
|
|
| 752 |
except AuthUnavailable as e:
|
| 753 |
if "answered 404" in str(e):
|
| 754 |
raise HTTPException(503, f"permissions table is missing; run: {CREATE_USER_ACCESS}")
|
| 755 |
+
if any(mark in str(e).lower() for mark in ("valid_entitlements", "check constraint", "23514")):
|
| 756 |
+
raise HTTPException(409, f"permissions table needs upgrading; run: {MIGRATE_USER_ACCESS}")
|
| 757 |
raise HTTPException(503, f"Supabase לא זמין: {e}")
|
| 758 |
return {"ok": True}
|
| 759 |
|
|
|
|
| 1856 |
result = app.state.scorer.score(wav, phrase, [])
|
| 1857 |
if result.get("error"):
|
| 1858 |
raise HTTPException(400, result["message"])
|
| 1859 |
+
return save_flag(phrase, result, note, user)
|
| 1860 |
|
| 1861 |
def tutor_for(user: dict | None) -> Tutor:
|
| 1862 |
"""The paid tutor game, after the tutor.use dependency authorized the account (or on an open service)."""
|
|
|
|
| 2028 |
@app.post("/api/flags")
|
| 2029 |
def flag(phrase_id: int = Form(...), audio: UploadFile = File(...), note: str = Form(""),
|
| 2030 |
user: dict | None = Depends(signed_in)):
|
| 2031 |
+
"""A tutor's flag: this recording of the phrase was said right and the score was wrong. It is scored again in
|
| 2032 |
+
memory, then discarded. The structured diagnosis is kept in `flags`; the user's audio is not retained."""
|
|
|
|
|
|
|
| 2033 |
if not user or not user["tutor"]:
|
| 2034 |
raise HTTPException(403, "tutors only")
|
| 2035 |
phrase = phrase_of(phrase_id)
|
|
|
|
| 2044 |
result = app.state.scorer.score(wav, phrase, refs)
|
| 2045 |
if result.get("error"):
|
| 2046 |
raise HTTPException(400, result["message"])
|
| 2047 |
+
return save_flag(phrase, result, note, user)
|
| 2048 |
|
| 2049 |
+
def save_flag(phrase: dict, result: dict, note: str, user: dict) -> dict:
|
|
|
|
| 2050 |
"""Store a reviewed-score candidate from a bank phrase or a dynamic verb-game phrase."""
|
|
|
|
|
|
|
| 2051 |
row = {"email": user["email"].lower(), "cat": phrase["cat"], "phrase": phrase.get("key", phrase["he"]),
|
| 2052 |
"he": phrase["he"], "ar": result["variant"]["ar"], "ipa": result["variant"]["ipa"],
|
| 2053 |
"heard_ipa": result["heard_ipa"], "score": result["score"], "unrelated": bool(result["unrelated"]),
|
| 2054 |
"note": " ".join(note.split())[:MAX_NOTE], "result": result, "model": MODEL_ID, "version": build_version()}
|
| 2055 |
try:
|
| 2056 |
+
app.state.auth.add_flag(row)
|
| 2057 |
except AuthUnavailable as e:
|
| 2058 |
if "answered 404" in str(e):
|
| 2059 |
raise HTTPException(503, "הטבלה flags לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
|
| 2060 |
f"{CREATE_FLAGS} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 2061 |
raise HTTPException(503, f"Supabase לא זמין: {e}")
|
| 2062 |
+
return {"ok": True, "audio": False, "flag": row}
|
| 2063 |
|
| 2064 |
return app
|
| 2065 |
|
static/guide.html
CHANGED
|
@@ -92,7 +92,6 @@ section{display:flex;flex-direction:column;gap:16px}
|
|
| 92 |
.ball.now{border-color:var(--olive);color:var(--olive);box-shadow:0 0 0 6px var(--olive-soft)}
|
| 93 |
.ball.done{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
|
| 94 |
.ball.gold{background:var(--gold-bg);border-color:var(--gold);color:var(--gold)}
|
| 95 |
-
.ball.locked{opacity:.6;font-size:18px}
|
| 96 |
.ball.video{border-style:dashed;color:var(--olive)}
|
| 97 |
.ball .badge{position:absolute;top:-4px;inset-inline-end:-4px;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;background:var(--ok);color:#fff}
|
| 98 |
.ball.gold .badge{background:var(--gold)}
|
|
@@ -253,7 +252,7 @@ a,button{-webkit-tap-highlight-color:transparent}
|
|
| 253 |
<div class="stop L" style="left:calc(26% - 26px);top:304px"><div class="ball started now"><div class="tag">התחילו</div><span>3</span></div><div class="lbl">משפטים א׳<span class="chip read">ער׳ ← עב׳</span><small>תורגלו 4 מתוך 10</small></div></div>
|
| 254 |
<div class="stop" style="right:calc(50% - 26px);top:392px"><div class="ball"><div class="tag hw">שיעורי בית<small>עוד יומיים</small></div>💬</div><div class="lbl">סיפור השוק<small>תורגלו 0 מתוך 12</small></div></div>
|
| 255 |
<span class="n" style="top:468px;right:-25px">6</span>
|
| 256 |
-
<div class="stop" style="right:calc(26% - 26px);top:480px"><div class="ball
|
| 257 |
</div>
|
| 258 |
</div>
|
| 259 |
<ol class="key">
|
|
@@ -262,7 +261,7 @@ a,button{-webkit-tap-highlight-color:transparent}
|
|
| 262 |
<li><span class="n">3</span><div><b>לשוניות</b><span class="d">המסלול, הספרייה הדיגיטלית (סרטונים וטקסטים) וחבר מביא חבר.</span></div></li>
|
| 263 |
<li><span class="n">4</span><div><b>כותרת השיעור</b><span class="d">הפס מראה כמה מהשיעור תורגל. לחיצה עליה פותחת את הנושא הבא שעוד לא נגמר. בסוף כל שיעור יש גביע 🏆 שמזהיב כשהשיעור הושלם.</span></div></li>
|
| 264 |
<li><span class="n">5</span><div><b>הנושא של עכשיו</b><span class="d">מסומן בהילה ובבועה „התחילו” או „המשך”. אם גללתם רחוק, הכפתור „חזרה לנושא הנוכחי” מחזיר אליו.</span></div></li>
|
| 265 |
-
<li><span class="n">6</span><div><b>נושאים
|
| 266 |
</ol>
|
| 267 |
</div>
|
| 268 |
</section>
|
|
@@ -278,7 +277,6 @@ a,button{-webkit-tap-highlight-color:transparent}
|
|
| 278 |
<div class="state"><div class="ball started"><span>3</span></div><b>בתהליך</b><span>הטבעת מראה כמה כבר תורגל</span></div>
|
| 279 |
<div class="state"><div class="ball done"><span class="badge">✓</span>2</div><b>הושלם</b><span>כל המשפטים נאמרו לפחות פעם אחת</span></div>
|
| 280 |
<div class="state"><div class="ball gold"><span class="badge">★</span>1</div><b>זהב</b><span>כל המשפטים קיבלו „מצוין” (85 ומעלה)</span></div>
|
| 281 |
-
<div class="state"><div class="ball locked">🔒</div><b>נעול</b><span>קודם מסיימים את הנושאים שלפניו</span></div>
|
| 282 |
<div class="state"><div class="ball" style="border-color:var(--amber);margin-top:22px"><div class="tag hw" style="font-size:10px">עוד 3 ימים</div>5</div><b>שיעורי בית</b><span>הבועה סופרת את הימים עד ההגשה</span></div>
|
| 283 |
</div>
|
| 284 |
</section>
|
|
|
|
| 92 |
.ball.now{border-color:var(--olive);color:var(--olive);box-shadow:0 0 0 6px var(--olive-soft)}
|
| 93 |
.ball.done{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
|
| 94 |
.ball.gold{background:var(--gold-bg);border-color:var(--gold);color:var(--gold)}
|
|
|
|
| 95 |
.ball.video{border-style:dashed;color:var(--olive)}
|
| 96 |
.ball .badge{position:absolute;top:-4px;inset-inline-end:-4px;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;background:var(--ok);color:#fff}
|
| 97 |
.ball.gold .badge{background:var(--gold)}
|
|
|
|
| 252 |
<div class="stop L" style="left:calc(26% - 26px);top:304px"><div class="ball started now"><div class="tag">התחילו</div><span>3</span></div><div class="lbl">משפטים א׳<span class="chip read">ער׳ ← עב׳</span><small>תורגלו 4 מתוך 10</small></div></div>
|
| 253 |
<div class="stop" style="right:calc(50% - 26px);top:392px"><div class="ball"><div class="tag hw">שיעורי בית<small>עוד יומיים</small></div>💬</div><div class="lbl">סיפור השוק<small>תורגלו 0 מתוך 12</small></div></div>
|
| 254 |
<span class="n" style="top:468px;right:-25px">6</span>
|
| 255 |
+
<div class="stop" style="right:calc(26% - 26px);top:480px"><div class="ball">✎︎</div><div class="lbl">כתיבת משפטים<small>נכתבו 0 מתוך 8</small></div></div>
|
| 256 |
</div>
|
| 257 |
</div>
|
| 258 |
<ol class="key">
|
|
|
|
| 261 |
<li><span class="n">3</span><div><b>לשוניות</b><span class="d">המסלול, הספרייה הדיגיטלית (סרטונים וטקסטים) וחבר מביא חבר.</span></div></li>
|
| 262 |
<li><span class="n">4</span><div><b>כותרת השיעור</b><span class="d">הפס מראה כמה מהשיעור תורגל. לחיצה עליה פותחת את הנושא הבא שעוד לא נגמר. בסוף כל שיעור יש גביע 🏆 שמזהיב כשהשיעור הושלם.</span></div></li>
|
| 263 |
<li><span class="n">5</span><div><b>הנושא של עכשיו</b><span class="d">מסומן בהילה ובבועה „התחילו” או „המשך”. אם גללתם רחוק, הכפתור „חזרה לנושא הנוכחי” מחזיר אליו.</span></div></li>
|
| 264 |
+
<li><span class="n">6</span><div><b>כל הנושאים פתוחים</b><span class="d">אפשר לפתוח כל נושא בכל שיעור, גם בלי לסיים את מה שלפניו.</span></div></li>
|
| 265 |
</ol>
|
| 266 |
</div>
|
| 267 |
</section>
|
|
|
|
| 277 |
<div class="state"><div class="ball started"><span>3</span></div><b>בתהליך</b><span>הטבעת מראה כמה כבר תורגל</span></div>
|
| 278 |
<div class="state"><div class="ball done"><span class="badge">✓</span>2</div><b>הושלם</b><span>כל המשפטים נאמרו לפחות פעם אחת</span></div>
|
| 279 |
<div class="state"><div class="ball gold"><span class="badge">★</span>1</div><b>זהב</b><span>כל המשפטים קיבלו „מצוין” (85 ומעלה)</span></div>
|
|
|
|
| 280 |
<div class="state"><div class="ball" style="border-color:var(--amber);margin-top:22px"><div class="tag hw" style="font-size:10px">עוד 3 ימים</div>5</div><b>שיעורי בית</b><span>הבועה סופרת את הימים עד ההגשה</span></div>
|
| 281 |
</div>
|
| 282 |
</section>
|
static/index.html
CHANGED
|
@@ -68,8 +68,7 @@ header{display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
|
|
| 68 |
/* Home: the course as one winding road down the screen. A lesson opens with a banner and closes with a cup, and its
|
| 69 |
topics are the round buttons along the road, each with its label beside it. The ring around a button fills as its
|
| 70 |
sentences are practised: untouched is plain, started is tinted, finished is filled and gets a badge (gold when every
|
| 71 |
-
sentence reached "מצוין"). The road is green after a finished topic. The one topic to do now carries a bubble and a halo.
|
| 72 |
-
A locked topic (one before it is not finished) is grey with a padlock. */
|
| 73 |
body.home #practice,body.home #driveBtn,body:not(.home) #path,body:not(.home) #today,body:not(.home) #toNow,body.home .where,body:not(.home) .brand,body:not(.home) .maphint{display:none}
|
| 74 |
header .sub.maphint{flex:0 0 100%;color:var(--ink)}
|
| 75 |
/* the student's guide (static/guide.html): a round "?" beside the class pill, and once, a line under the hint */
|
|
@@ -374,11 +373,6 @@ body.memory-game{overflow:hidden}
|
|
| 374 |
.tag small{display:block;font:600 11px/1.3 var(--f-body);opacity:.85}
|
| 375 |
.tag:has(small){text-align:center}
|
| 376 |
.sat.soon{--c:var(--amber)} .sat.late{--c:var(--sumac)}
|
| 377 |
-
.sat.locked{color:var(--muted);cursor:default}
|
| 378 |
-
.sat.locked .dot{background:var(--bg);font-size:20px;filter:grayscale(1);opacity:.75}
|
| 379 |
-
.sat.locked:active .dot{transform:none;box-shadow:0 var(--s) 0 var(--track)}
|
| 380 |
-
.sat.locked .tag{background:var(--ink);color:var(--bg)}
|
| 381 |
-
.chapter.locked,.chapter.locked:active{background:var(--track);box-shadow:0 4px 0 var(--muted);transform:none;cursor:default}
|
| 382 |
.tag.soon{background:var(--amber)} .tag.late{background:var(--sumac)}
|
| 383 |
.sat.now .ring::after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--c);animation:beckon 1.8s ease-out infinite}
|
| 384 |
@keyframes beckon{from{transform:scale(.92);opacity:.9}to{transform:scale(1.18);opacity:0}}
|
|
@@ -510,7 +504,8 @@ body.driving .mic::before{display:none} body.driving .mic-zone.two{display:flex}
|
|
| 510 |
#denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
| 511 |
/* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
|
| 512 |
body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
|
| 513 |
-
body.lab #wrap{max-width:none}
|
|
|
|
| 514 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 515 |
#mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
|
| 516 |
#mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
|
|
@@ -736,7 +731,7 @@ body.lab #wrap{max-width:none}
|
|
| 736 |
#manage.content :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
|
| 737 |
#manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
|
| 738 |
#manage.access :is(#mClasses,#mContent,#mFlags,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
|
| 739 |
-
.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:
|
| 740 |
@media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
|
| 741 |
.flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
|
| 742 |
.flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
|
|
@@ -1726,9 +1721,8 @@ function setTr(el,text){
|
|
| 1726 |
}
|
| 1727 |
/* Home is the path: one road winding down the screen through every lesson. A lesson opens with a banner, which
|
| 1728 |
opens its first unfinished topic; its topics follow as buttons along the road, and a cup closes it.
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
let FREE=false; // a teacher: the whole path is open
|
| 1732 |
let TUTOR_INFO=null, tc=null, tutorList=false; // what /api/tutor allows this account; the open conversation; the games tab shows the scenarios
|
| 1733 |
let lastPhone=null; // the latest ordinary recording's score response, for the teacher's phoneme observer
|
| 1734 |
let home=true; // the path is on screen, not the practice card: nothing is read aloud or recorded
|
|
@@ -1995,7 +1989,7 @@ function drawVerbTable(box){
|
|
| 1995 |
tr.append(one(past),mark("","·","i"),one(future||"")); say.append(tr);
|
| 1996 |
say.onclick=async()=>{
|
| 1997 |
if(vtOnly) vtOnly.classList.remove("on"); vtOnly=say; say.classList.add("on");
|
| 1998 |
-
const a=await answerAudio(p.id); if(a) await playUrl(a.url,true); else await speakLocal(p.ar,"ar");
|
| 1999 |
say.classList.remove("on");
|
| 2000 |
};
|
| 2001 |
row.dataset.find=vtPlain(`${p.he} ${p.tr} ${p.ar}`); row.append(he,say); card.append(row);
|
|
@@ -2339,8 +2333,8 @@ function mgCount(){
|
|
| 2339 |
async function mgSay(p){ // the Arabic, in the lessons' voice, or else the device's
|
| 2340 |
if(mg.audio) mg.audio.pause();
|
| 2341 |
const a=await answerAudio(p.id); if(!mg) return;
|
| 2342 |
-
if(!a){ speakLocal(p.ar,"ar"); return; }
|
| 2343 |
-
const au=answerPlayer(a.url); mg.audio=au; au.play().catch(()=>speakLocal(p.ar,"ar"));
|
| 2344 |
}
|
| 2345 |
function mgAt(b){ const r=b.el.getBoundingClientRect(), f=mg.field.getBoundingClientRect(); return {x:r.left-f.left+r.width/2,y:r.top-f.top+r.height/2}; }
|
| 2346 |
function mgBurst(x,y,many=12){
|
|
@@ -2539,8 +2533,7 @@ $("writeClose").onclick=()=>$("writeDlg").close();
|
|
| 2539 |
$("writeDlg").addEventListener("close",()=>{
|
| 2540 |
if(writeTimer){ clearTimeout(writeTimer); writeTimer=null; sendWriting(writeOpen); } // what was just written is sent at once
|
| 2541 |
writeOpen=null; if(home&&!document.body.classList.contains("manage")) drawPath(true); });
|
| 2542 |
-
/* A lesson's new words ("learn" topic): a card a word, before the lesson's speaking topics
|
| 2543 |
-
every word was marked known once. Nothing is scored; what the student knows is kept on the device. A round goes
|
| 2544 |
through the words not known yet (all of them, shuffled, when every one is); "not yet" sends a word to the round's end. */
|
| 2545 |
let learned=store.get("ihki.learned",{}), learnRounds=store.get("ihki.learnRounds",{}), learning=null; // known words; unfinished rounds; the round on screen
|
| 2546 |
let learnDriving=false, learnDriveRun=0, learnWakeLock=null;
|
|
@@ -2757,15 +2750,6 @@ function drawPath(stay){
|
|
| 2757 |
const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
|
| 2758 |
road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
|
| 2759 |
let next=true, y=0; // next: the first lesson that is not finished is the one to do now
|
| 2760 |
-
let open=true; // every topic so far is finished: the next one is not locked
|
| 2761 |
-
const lock=(b,dot,name)=>{ // a locked stop: a padlock, and a tap says why
|
| 2762 |
-
b.classList.add("locked"); b.setAttribute("aria-disabled","true"); dot.textContent="🔒";
|
| 2763 |
-
b.title=`נעול: ${name}. קודם מסיימים את הנושאים שלפניו`; b.setAttribute("aria-label",b.title);
|
| 2764 |
-
b.onclick=()=>{
|
| 2765 |
-
if(b.querySelector(".tag")) return;
|
| 2766 |
-
const tag=mark("tag",""); tag.append(mark("","נעול","small"),"סיימו את הקודם"); dot.before(tag); setTimeout(()=>tag.remove(),2200);
|
| 2767 |
-
};
|
| 2768 |
-
};
|
| 2769 |
let level=null;
|
| 2770 |
for(let k=0;k<LESSONS.length;k++){
|
| 2771 |
const l=LESSONS[k];
|
|
@@ -2782,21 +2766,11 @@ function drawPath(stay){
|
|
| 2782 |
const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
|
| 2783 |
const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic to do now, when this is the lesson to do now
|
| 2784 |
if(now) next=false;
|
| 2785 |
-
const
|
| 2786 |
-
if(!c) return !open;
|
| 2787 |
-
const t=parts[cats.indexOf(c)], s=!open&&!t.done&&!hws.some(h=>h.c===c);
|
| 2788 |
-
if(t.n&&!t.over&&!c.write&&!((c.learn||c.match)&&whole.done)&&!FREE) open=false; // new words and their game hold back only a lesson not yet practised
|
| 2789 |
-
return s;
|
| 2790 |
-
});
|
| 2791 |
-
const go=parts.every(t=>t.over)?cats[0]:cats.find((c,i)=>!parts[i].over&&!shut[items.findIndex(it=>it.c===c)]); // the topic the banner opens: none in a locked lesson
|
| 2792 |
const head=document.createElement("button");
|
| 2793 |
head.className="chapter"+(whole.gold?" gold":""); head.style.top=y+"px"; head.style.setProperty("--p",`${whole.n?whole.done/whole.n*100:0}%`);
|
| 2794 |
head.append(mark("",l.name),count(whole),document.createElement("i")); head.title=`${l.name}: תורגלו ${whole.done} מתוך ${whole.n}`;
|
| 2795 |
if(go) head.onclick=()=>openCat(go);
|
| 2796 |
-
else{ // a locked lesson: its banner leads back to the topic to do now
|
| 2797 |
-
head.classList.add("locked"); head.setAttribute("aria-disabled","true"); head.title=`נעול: ${l.name}. קודם מסיימים את הנושאים שלפניו`; head.querySelector("small").prepend("🔒 ");
|
| 2798 |
-
head.onclick=()=>{ const at=box.querySelector("[aria-current]"); if(at) at.scrollIntoView({block:"center",behavior:"smooth"}); };
|
| 2799 |
-
}
|
| 2800 |
box.append(head);
|
| 2801 |
y+=64+86; // the banner, room for the bubble, half a button
|
| 2802 |
const seat=(b,i)=>{ // a button's place on the road, and its label
|
|
@@ -2811,7 +2785,7 @@ function drawPath(stay){
|
|
| 2811 |
b.classList.add("video"); dot.textContent="▶\uFE0E"; if(seen) dot.before(mark("badge","✓"));
|
| 2812 |
label.append(mark("",v.title,"b"),mark("",seen?"✓ נצפה":"סרטון","small")); b.append(label);
|
| 2813 |
b.title=`סרטון: ${v.title}`; b.setAttribute("aria-label",b.title);
|
| 2814 |
-
b.onclick=()=>playVideo(v);
|
| 2815 |
stops.push({x,y,went:seen,breakBefore:at===0}); y+=STEP; return;
|
| 2816 |
}
|
| 2817 |
const i=cats.indexOf(c), t=parts[i], cur=now&&i===todo, [b,dot]=ball(t,cur), [x,label]=seat(b,at);
|
|
@@ -2830,7 +2804,7 @@ function drawPath(stay){
|
|
| 2830 |
const did=c.write?"נכתבו":c.learn?"נלמדו":c.match?"הותאמו":"תורגלו";
|
| 2831 |
label.append(mine&&t.over?mark("","✓ שיעורי הבית הושלמו 🎉","small"):count(t,did)); b.append(label);
|
| 2832 |
b.title=(mine&&!t.over?`שיעורי בית, ${hw.msg}. `:"")+`${c.name}: ${did} ${t.done} מתוך ${t.n}`; b.setAttribute("aria-label",b.title);
|
| 2833 |
-
b.onclick=()=>openCat(c);
|
| 2834 |
stops.push({x,y,went:t.over,cur,breakBefore:at===0}); y+=STEP;
|
| 2835 |
});
|
| 2836 |
const cup=document.createElement("div"), x=-SWAY[items.length%8]*amp; // the lesson ends with the masbaha's ornamental finial
|
|
@@ -3717,7 +3691,7 @@ $("flagBtn").onclick=async()=>{
|
|
| 3717 |
const res=await api("/api/flags",{method:"POST",body:fd});
|
| 3718 |
if(!res.ok){ const e=await res.json().catch(()=>({})); throw new Error(e.detail||res.status); }
|
| 3719 |
const out=await res.json();
|
| 3720 |
-
msg.textContent=
|
| 3721 |
$("flagNote").disabled=true; // one flag for a recording
|
| 3722 |
if(out.flag){ drawFlag(out.flag,out.audio); $("flagDlg").showModal(); }
|
| 3723 |
}catch(err){ msg.textContent="הדיווח לא נשמר: "+err.message; btn.disabled=false; }
|
|
@@ -3744,7 +3718,7 @@ function drawFlag(f,audio){
|
|
| 3744 |
fact("פונמות צפויות",mark("ipa",f.ipa)).className="ipa";
|
| 3745 |
fact("פונמות שנשמעו",mark("ipa",f.heard_ipa||"–")).className="ipa";
|
| 3746 |
if(f.note) fact("ההערה שלך",f.note);
|
| 3747 |
-
fact("
|
| 3748 |
fact("המודל",mark("ipa",f.model+(f.version?" · "+f.version:""))).className="ipa";
|
| 3749 |
}
|
| 3750 |
$("flagClose").onclick=()=>$("flagDlg").close();
|
|
@@ -3859,7 +3833,7 @@ async function bubbleOutcome(r,expired=false){
|
|
| 3859 |
if(cur.id!==id||home) return;
|
| 3860 |
const a=await answerAudio(id);
|
| 3861 |
if(cur.id!==id||home) return;
|
| 3862 |
-
if(a) await playUrl(a.url,true); else await speakLocal(cur.ar,"ar");
|
| 3863 |
await pause(80);
|
| 3864 |
}
|
| 3865 |
bubbleResolving=false;
|
|
@@ -4089,7 +4063,13 @@ function playUrl(url,answer){ // answer: the Arabic answer, played at the chos
|
|
| 4089 |
function speakLocal(text,lang){ // device voice, when the server could not speak
|
| 4090 |
return new Promise(res=>{
|
| 4091 |
if(!("speechSynthesis" in window)) return res();
|
| 4092 |
-
const u=new SpeechSynthesisUtterance(text);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4093 |
speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 4094 |
});
|
| 4095 |
}
|
|
@@ -4233,7 +4213,7 @@ const ACCESS_COLUMNS=[
|
|
| 4233 |
{label:"מעבדה",kind:"roles",value:"developer",permission:"lab.access"},
|
| 4234 |
{label:"משחקים",kind:"entitlements",value:"games",permission:"games.use"},
|
| 4235 |
{label:"ספרייה דיגיטלית",kind:"entitlements",value:"library",permission:"library.view"},
|
| 4236 |
-
{label:"דיווח תקלות",kind:"roles",value:"flagger",permission:"flags.create"},
|
| 4237 |
{label:"יאללה נדבר",kind:"entitlements",value:"tutor",permission:"tutor.use"},
|
| 4238 |
{label:"מסלול",kind:"entitlements",value:"path",permission:"path.use"},
|
| 4239 |
];
|
|
@@ -4254,31 +4234,58 @@ function drawAccess(){
|
|
| 4254 |
const note=mark("sub","כל עמודה היא הרשאה נפרדת. יאללה נדבר מופרד ממשחקים כי הוא משתמש בשירות API בתשלום. רק מפתחים יכולים לתת גישה למעבדה.","p"); box.append(note);
|
| 4255 |
if(ACCESS.migration){ const details=document.createElement("details"), summary=document.createElement("summary"), code=mark("",ACCESS.migration,"code"); summary.textContent="שדרוג טבלת הרשאות קיימת"; code.dir="ltr"; details.className="setup"; details.append(summary,code); box.append(details); }
|
| 4256 |
const scroll=mark("access-scroll",""), table=mark("access-table","","table"), head=document.createElement("thead"), hr=document.createElement("tr");
|
| 4257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4258 |
const body=document.createElement("tbody");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4259 |
for(const user of ACCESS.users||[]){
|
| 4260 |
-
const row=document.createElement("tr"), who=document.createElement("td"), email=mark("",user.email,"small"); email.dir="ltr"; who.append(user.name||user.email,email); row.append(who);
|
| 4261 |
for(const col of ACCESS_COLUMNS){
|
| 4262 |
const td=document.createElement("td"), input=document.createElement("input"); input.type="checkbox"; input.dataset.kind=col.kind; input.value=col.value; input.checked=(user[col.kind]||[]).includes(col.value)||(user.permissions||[]).includes("*")||(user.permissions||[]).includes(col.permission);
|
| 4263 |
input.setAttribute("aria-label",`${col.label}: ${user.name||user.email}`);
|
| 4264 |
-
input.disabled=
|
|
|
|
| 4265 |
if((user.fixed_roles||[]).includes(col.value)) input.title="נקבע בהגדרות השרת"; td.append(input); row.append(td);
|
| 4266 |
}
|
| 4267 |
-
const save=mark("btn primary","שמירה","button"), saveCell=mark("save-cell","","td"); saveCell.append(save); row.append(saveCell); save.disabled=
|
| 4268 |
-
save.onclick=async()=>{
|
| 4269 |
-
|
| 4270 |
-
const roles=[...(user.roles||[]).filter(x=>!["admin","developer","flagger"].includes(x)),...selected("roles")];
|
| 4271 |
-
if(!roles.length) roles.push("student");
|
| 4272 |
-
save.disabled=true;
|
| 4273 |
-
try{
|
| 4274 |
-
const res=await api(`/api/access/users/${user.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({roles,entitlements:selected("entitlements")})});
|
| 4275 |
-
const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
|
| 4276 |
-
note.textContent=`ההרשאות של ${user.name||user.email} נשמרו.`; await loadAccess(); drawAccess();
|
| 4277 |
-
}catch(e){ note.textContent="השמירה נכשלה: "+e.message; save.disabled=false; }
|
| 4278 |
-
};
|
| 4279 |
body.append(row);
|
| 4280 |
}
|
| 4281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4282 |
}
|
| 4283 |
function drawManage(){
|
| 4284 |
const classes=CLASS.classes||[];
|
|
|
|
| 68 |
/* Home: the course as one winding road down the screen. A lesson opens with a banner and closes with a cup, and its
|
| 69 |
topics are the round buttons along the road, each with its label beside it. The ring around a button fills as its
|
| 70 |
sentences are practised: untouched is plain, started is tinted, finished is filled and gets a badge (gold when every
|
| 71 |
+
sentence reached "מצוין"). The road is green after a finished topic. The one topic to do now carries a bubble and a halo. */
|
|
|
|
| 72 |
body.home #practice,body.home #driveBtn,body:not(.home) #path,body:not(.home) #today,body:not(.home) #toNow,body.home .where,body:not(.home) .brand,body:not(.home) .maphint{display:none}
|
| 73 |
header .sub.maphint{flex:0 0 100%;color:var(--ink)}
|
| 74 |
/* the student's guide (static/guide.html): a round "?" beside the class pill, and once, a line under the hint */
|
|
|
|
| 373 |
.tag small{display:block;font:600 11px/1.3 var(--f-body);opacity:.85}
|
| 374 |
.tag:has(small){text-align:center}
|
| 375 |
.sat.soon{--c:var(--amber)} .sat.late{--c:var(--sumac)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
.tag.soon{background:var(--amber)} .tag.late{background:var(--sumac)}
|
| 377 |
.sat.now .ring::after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--c);animation:beckon 1.8s ease-out infinite}
|
| 378 |
@keyframes beckon{from{transform:scale(.92);opacity:.9}to{transform:scale(1.18);opacity:0}}
|
|
|
|
| 504 |
#denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
| 505 |
/* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
|
| 506 |
body:not(.manage) #manage,body.lab #manage,body:not(.lab) #lab,body.manage #wrap>header,body.manage #path,body.manage #today,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
|
| 507 |
+
body.lab #wrap,body.manage #wrap:has(#manage.access){max-width:none}
|
| 508 |
+
body.manage #manage{width:100%;max-width:none}
|
| 509 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 510 |
#mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
|
| 511 |
#mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
|
|
|
|
| 731 |
#manage.content :is(#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.content) #mContent{display:none}
|
| 732 |
#manage.flags :is(#mClasses,#mContent,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.flags) #mFlags{display:none}
|
| 733 |
#manage.access :is(#mClasses,#mContent,#mFlags,#mTutors,#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.access) #mAccess{display:none}
|
| 734 |
+
.access-actions{display:flex;justify-content:flex-end;margin:8px 0}.access-scroll{overflow:auto}.access-table{width:100%;border-collapse:collapse;min-width:1180px}.access-table :is(th,td){padding:9px 10px;border-bottom:1px solid var(--line);text-align:center}.access-table th{position:sticky;top:0;background:var(--surface);font-size:13px;white-space:nowrap;z-index:1}.access-table th>span{display:block;margin-bottom:4px}.access-table .access-all{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-weight:600;cursor:pointer}.access-table .access-all small{font-size:11px}.access-table :is(th,td):first-child{text-align:start;position:sticky;right:0;background:var(--surface);z-index:2;min-width:260px}.access-table td:first-child{font-weight:700}.access-table td:first-child small{display:block;color:var(--muted);font-weight:400;direction:ltr;overflow:hidden;text-overflow:ellipsis}.access-table input{width:18px;height:18px}.access-table .save-cell{min-width:96px}
|
| 735 |
@media(max-width:700px){.modes{grid-template-columns:repeat(2,1fr)}}
|
| 736 |
.flag-review{gap:10px}.flag-review h2{margin:0}.flag-review .meta{color:var(--muted);font-size:13px}.flag-review fieldset{border:0;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:7px}
|
| 737 |
.flag-review fieldset label{display:flex;align-items:center;gap:5px;padding:6px 8px;border:1px solid var(--line);border-radius:9px}.flag-review select,.flag-review textarea{font:inherit;border:1px solid var(--line);border-radius:9px;padding:7px;background:var(--surface);color:var(--ink)}
|
|
|
|
| 1721 |
}
|
| 1722 |
/* Home is the path: one road winding down the screen through every lesson. A lesson opens with a banner, which
|
| 1723 |
opens its first unfinished topic; its topics follow as buttons along the road, and a cup closes it.
|
| 1724 |
+
Every topic is open, whatever comes before it on the path. */
|
| 1725 |
+
let FREE=false; // a teacher
|
|
|
|
| 1726 |
let TUTOR_INFO=null, tc=null, tutorList=false; // what /api/tutor allows this account; the open conversation; the games tab shows the scenarios
|
| 1727 |
let lastPhone=null; // the latest ordinary recording's score response, for the teacher's phoneme observer
|
| 1728 |
let home=true; // the path is on screen, not the practice card: nothing is read aloud or recorded
|
|
|
|
| 1989 |
tr.append(one(past),mark("","·","i"),one(future||"")); say.append(tr);
|
| 1990 |
say.onclick=async()=>{
|
| 1991 |
if(vtOnly) vtOnly.classList.remove("on"); vtOnly=say; say.classList.add("on");
|
| 1992 |
+
const a=await answerAudio(p.id); if(a) await playUrl(a.url,true); else await speakLocal(p.ar_pointed||p.ar,"ar");
|
| 1993 |
say.classList.remove("on");
|
| 1994 |
};
|
| 1995 |
row.dataset.find=vtPlain(`${p.he} ${p.tr} ${p.ar}`); row.append(he,say); card.append(row);
|
|
|
|
| 2333 |
async function mgSay(p){ // the Arabic, in the lessons' voice, or else the device's
|
| 2334 |
if(mg.audio) mg.audio.pause();
|
| 2335 |
const a=await answerAudio(p.id); if(!mg) return;
|
| 2336 |
+
if(!a){ speakLocal(p.ar_pointed||p.ar,"ar"); return; }
|
| 2337 |
+
const au=answerPlayer(a.url); mg.audio=au; au.play().catch(()=>speakLocal(p.ar_pointed||p.ar,"ar"));
|
| 2338 |
}
|
| 2339 |
function mgAt(b){ const r=b.el.getBoundingClientRect(), f=mg.field.getBoundingClientRect(); return {x:r.left-f.left+r.width/2,y:r.top-f.top+r.height/2}; }
|
| 2340 |
function mgBurst(x,y,many=12){
|
|
|
|
| 2533 |
$("writeDlg").addEventListener("close",()=>{
|
| 2534 |
if(writeTimer){ clearTimeout(writeTimer); writeTimer=null; sendWriting(writeOpen); } // what was just written is sent at once
|
| 2535 |
writeOpen=null; if(home&&!document.body.classList.contains("manage")) drawPath(true); });
|
| 2536 |
+
/* A lesson's new words ("learn" topic): a card a word, before the lesson's speaking topics. Nothing is scored; what the student knows is kept on the device. A round goes
|
|
|
|
| 2537 |
through the words not known yet (all of them, shuffled, when every one is); "not yet" sends a word to the round's end. */
|
| 2538 |
let learned=store.get("ihki.learned",{}), learnRounds=store.get("ihki.learnRounds",{}), learning=null; // known words; unfinished rounds; the round on screen
|
| 2539 |
let learnDriving=false, learnDriveRun=0, learnWakeLock=null;
|
|
|
|
| 2750 |
const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
|
| 2751 |
road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
|
| 2752 |
let next=true, y=0; // next: the first lesson that is not finished is the one to do now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2753 |
let level=null;
|
| 2754 |
for(let k=0;k<LESSONS.length;k++){
|
| 2755 |
const l=LESSONS[k];
|
|
|
|
| 2766 |
const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
|
| 2767 |
const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic to do now, when this is the lesson to do now
|
| 2768 |
if(now) next=false;
|
| 2769 |
+
const go=cats[parts.findIndex(t=>!t.over)]||cats[0]; // the topic the banner opens: the first unfinished one
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2770 |
const head=document.createElement("button");
|
| 2771 |
head.className="chapter"+(whole.gold?" gold":""); head.style.top=y+"px"; head.style.setProperty("--p",`${whole.n?whole.done/whole.n*100:0}%`);
|
| 2772 |
head.append(mark("",l.name),count(whole),document.createElement("i")); head.title=`${l.name}: תורגלו ${whole.done} מתוך ${whole.n}`;
|
| 2773 |
if(go) head.onclick=()=>openCat(go);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2774 |
box.append(head);
|
| 2775 |
y+=64+86; // the banner, room for the bubble, half a button
|
| 2776 |
const seat=(b,i)=>{ // a button's place on the road, and its label
|
|
|
|
| 2785 |
b.classList.add("video"); dot.textContent="▶\uFE0E"; if(seen) dot.before(mark("badge","✓"));
|
| 2786 |
label.append(mark("",v.title,"b"),mark("",seen?"✓ נצפה":"סרטון","small")); b.append(label);
|
| 2787 |
b.title=`סרטון: ${v.title}`; b.setAttribute("aria-label",b.title);
|
| 2788 |
+
b.onclick=()=>playVideo(v); box.append(b);
|
| 2789 |
stops.push({x,y,went:seen,breakBefore:at===0}); y+=STEP; return;
|
| 2790 |
}
|
| 2791 |
const i=cats.indexOf(c), t=parts[i], cur=now&&i===todo, [b,dot]=ball(t,cur), [x,label]=seat(b,at);
|
|
|
|
| 2804 |
const did=c.write?"נכתבו":c.learn?"נלמדו":c.match?"הותאמו":"תורגלו";
|
| 2805 |
label.append(mine&&t.over?mark("","✓ שיעורי הבית הושלמו 🎉","small"):count(t,did)); b.append(label);
|
| 2806 |
b.title=(mine&&!t.over?`שיעורי בית, ${hw.msg}. `:"")+`${c.name}: ${did} ${t.done} מתוך ${t.n}`; b.setAttribute("aria-label",b.title);
|
| 2807 |
+
b.onclick=()=>openCat(c); box.append(b);
|
| 2808 |
stops.push({x,y,went:t.over,cur,breakBefore:at===0}); y+=STEP;
|
| 2809 |
});
|
| 2810 |
const cup=document.createElement("div"), x=-SWAY[items.length%8]*amp; // the lesson ends with the masbaha's ornamental finial
|
|
|
|
| 3691 |
const res=await api("/api/flags",{method:"POST",body:fd});
|
| 3692 |
if(!res.ok){ const e=await res.json().catch(()=>({})); throw new Error(e.detail||res.status); }
|
| 3693 |
const out=await res.json();
|
| 3694 |
+
msg.textContent="האבחון נשמר למורה. קובץ הקול לא נשמר.";
|
| 3695 |
$("flagNote").disabled=true; // one flag for a recording
|
| 3696 |
if(out.flag){ drawFlag(out.flag,out.audio); $("flagDlg").showModal(); }
|
| 3697 |
}catch(err){ msg.textContent="הדיווח לא נשמר: "+err.message; btn.disabled=false; }
|
|
|
|
| 3718 |
fact("פונמות צפויות",mark("ipa",f.ipa)).className="ipa";
|
| 3719 |
fact("פונמות שנשמעו",mark("ipa",f.heard_ipa||"–")).className="ipa";
|
| 3720 |
if(f.note) fact("ההערה שלך",f.note);
|
| 3721 |
+
fact("פרטיות","קובץ הקול עובד לצורך האבחון ולא נשמר");
|
| 3722 |
fact("המודל",mark("ipa",f.model+(f.version?" · "+f.version:""))).className="ipa";
|
| 3723 |
}
|
| 3724 |
$("flagClose").onclick=()=>$("flagDlg").close();
|
|
|
|
| 3833 |
if(cur.id!==id||home) return;
|
| 3834 |
const a=await answerAudio(id);
|
| 3835 |
if(cur.id!==id||home) return;
|
| 3836 |
+
if(a) await playUrl(a.url,true); else await speakLocal(cur.ar_pointed||cur.ar,"ar");
|
| 3837 |
await pause(80);
|
| 3838 |
}
|
| 3839 |
bubbleResolving=false;
|
|
|
|
| 4063 |
function speakLocal(text,lang){ // device voice, when the server could not speak
|
| 4064 |
return new Promise(res=>{
|
| 4065 |
if(!("speechSynthesis" in window)) return res();
|
| 4066 |
+
const u=new SpeechSynthesisUtterance(text);
|
| 4067 |
+
if(lang==="ar"){
|
| 4068 |
+
const v=arVoice();
|
| 4069 |
+
if(v){ u.voice=v; u.lang=v.lang; } else u.lang="ar-PS";
|
| 4070 |
+
u.rate=answerRate();
|
| 4071 |
+
}else u.lang=lang;
|
| 4072 |
+
u.onend=u.onerror=()=>res();
|
| 4073 |
speechSynthesis.cancel(); speechSynthesis.speak(u);
|
| 4074 |
});
|
| 4075 |
}
|
|
|
|
| 4213 |
{label:"מעבדה",kind:"roles",value:"developer",permission:"lab.access"},
|
| 4214 |
{label:"משחקים",kind:"entitlements",value:"games",permission:"games.use"},
|
| 4215 |
{label:"ספרייה דיגיטלית",kind:"entitlements",value:"library",permission:"library.view"},
|
| 4216 |
+
{label:"דיווחי תקלות",kind:"roles",value:"flagger",permission:"flags.create"},
|
| 4217 |
{label:"יאללה נדבר",kind:"entitlements",value:"tutor",permission:"tutor.use"},
|
| 4218 |
{label:"מסלול",kind:"entitlements",value:"path",permission:"path.use"},
|
| 4219 |
];
|
|
|
|
| 4234 |
const note=mark("sub","כל עמודה היא הרשאה נפרדת. יאללה נדבר מופרד ממשחקים כי הוא משתמש בשירות API בתשלום. רק מפתחים יכולים לתת גישה למעבדה.","p"); box.append(note);
|
| 4235 |
if(ACCESS.migration){ const details=document.createElement("details"), summary=document.createElement("summary"), code=mark("",ACCESS.migration,"code"); summary.textContent="שדרוג טבלת הרשאות קיימת"; code.dir="ltr"; details.className="setup"; details.append(summary,code); box.append(details); }
|
| 4236 |
const scroll=mark("access-scroll",""), table=mark("access-table","","table"), head=document.createElement("thead"), hr=document.createElement("tr");
|
| 4237 |
+
const allFor=col=>[...table.querySelectorAll('tbody input[type="checkbox"]')].filter(x=>!x.disabled&&x.dataset.kind===col.kind&&x.value===col.value);
|
| 4238 |
+
const syncAll=()=>{
|
| 4239 |
+
for(const col of ACCESS_COLUMNS){
|
| 4240 |
+
const master=head.querySelector(`input[data-kind="${col.kind}"][value="${col.value}"]`), inputs=allFor(col), marked=inputs.filter(x=>x.checked).length;
|
| 4241 |
+
master.checked=!!inputs.length&&marked===inputs.length; master.indeterminate=marked>0&&marked<inputs.length; master.disabled=!inputs.length;
|
| 4242 |
+
}
|
| 4243 |
+
};
|
| 4244 |
+
hr.append(mark("","משתמש","th"));
|
| 4245 |
+
for(const col of ACCESS_COLUMNS){
|
| 4246 |
+
const th=document.createElement("th"), label=mark("",col.label,"span"), all=document.createElement("input"), allLabel=mark("access-all","","label");
|
| 4247 |
+
all.type="checkbox"; all.dataset.kind=col.kind; all.value=col.value; all.setAttribute("aria-label",`סימון כל המשתמשים: ${col.label}`); all.title=`סימון כל המשתמשים בעמודה ${col.label}`;
|
| 4248 |
+
all.onchange=()=>{ for(const input of allFor(col)){ input.checked=all.checked; input.closest("tr").dataset.dirty="1"; } syncAll(); };
|
| 4249 |
+
allLabel.append(all,mark("","הכול","small")); th.append(label,allLabel); hr.append(th);
|
| 4250 |
+
}
|
| 4251 |
+
hr.append(mark("","","th")); head.append(hr); table.append(head);
|
| 4252 |
const body=document.createElement("tbody");
|
| 4253 |
+
const selected=(row,kind)=>[...row.querySelectorAll(`input[data-kind="${kind}"]:checked`)].map(x=>x.value);
|
| 4254 |
+
const saveRow=async(row,user,save)=>{
|
| 4255 |
+
const roles=[...(user.roles||[]).filter(x=>!["admin","developer","flagger"].includes(x)),...selected(row,"roles")], entitlements=selected(row,"entitlements");
|
| 4256 |
+
if(!roles.length) roles.push("student");
|
| 4257 |
+
save.disabled=true;
|
| 4258 |
+
try{
|
| 4259 |
+
const res=await api(`/api/access/users/${user.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({roles,entitlements})});
|
| 4260 |
+
const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status);
|
| 4261 |
+
user.roles=roles; user.entitlements=entitlements; delete row.dataset.dirty; save.disabled=false; return true;
|
| 4262 |
+
}catch(e){ note.textContent="השמירה נכשלה: "+e.message; save.disabled=false; return false; }
|
| 4263 |
+
};
|
| 4264 |
for(const user of ACCESS.users||[]){
|
| 4265 |
+
const row=document.createElement("tr"), who=document.createElement("td"), email=mark("",user.email,"small"), rowLocked=user.id===ACCESS.me||((user.fixed_roles||[]).includes("developer")&&!can("lab.access")); email.dir="ltr"; who.append(user.name||user.email,email); row.append(who);
|
| 4266 |
for(const col of ACCESS_COLUMNS){
|
| 4267 |
const td=document.createElement("td"), input=document.createElement("input"); input.type="checkbox"; input.dataset.kind=col.kind; input.value=col.value; input.checked=(user[col.kind]||[]).includes(col.value)||(user.permissions||[]).includes("*")||(user.permissions||[]).includes(col.permission);
|
| 4268 |
input.setAttribute("aria-label",`${col.label}: ${user.name||user.email}`);
|
| 4269 |
+
input.disabled=rowLocked||(col.value==="developer"&&!can("lab.access"))||(user.fixed_roles||[]).includes(col.value);
|
| 4270 |
+
input.onchange=()=>{ row.dataset.dirty="1"; syncAll(); };
|
| 4271 |
if((user.fixed_roles||[]).includes(col.value)) input.title="נקבע בהגדרות השרת"; td.append(input); row.append(td);
|
| 4272 |
}
|
| 4273 |
+
const save=mark("btn primary","שמירה","button"), saveCell=mark("save-cell","","td"); saveCell.append(save); row.append(saveCell); save.disabled=rowLocked;
|
| 4274 |
+
save.onclick=async()=>{ if(await saveRow(row,user,save)) note.textContent=`ההרשאות של ${user.name||user.email} נשמרו.`; };
|
| 4275 |
+
row._access={user,save};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4276 |
body.append(row);
|
| 4277 |
}
|
| 4278 |
+
const saveAll=mark("btn primary","שמירת כל השינויים","button"), actions=mark("access-actions","","div"); actions.append(saveAll);
|
| 4279 |
+
saveAll.onclick=async()=>{
|
| 4280 |
+
const rows=[...body.rows].filter(row=>row.dataset.dirty&&row._access);
|
| 4281 |
+
if(!rows.length){ note.textContent="אין שינויים לשמור."; return; }
|
| 4282 |
+
saveAll.disabled=true; let saved=0;
|
| 4283 |
+
for(const row of rows){ const {user,save}=row._access; if(await saveRow(row,user,save)) saved++; }
|
| 4284 |
+
saveAll.disabled=false; syncAll();
|
| 4285 |
+
if(saved===rows.length) note.textContent=`נשמרו הרשאות עבור ${saved} משתמשים.`;
|
| 4286 |
+
else note.textContent=`נשמרו ${saved} מתוך ${rows.length} משתמשים. בדקו את השורות שלא נשמרו.`;
|
| 4287 |
+
};
|
| 4288 |
+
table.append(body); syncAll(); scroll.append(table); box.append(actions,scroll);
|
| 4289 |
}
|
| 4290 |
function drawManage(){
|
| 4291 |
const classes=CLASS.classes||[];
|
tests/test_scoring.py
CHANGED
|
@@ -1714,24 +1714,21 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
|
|
| 1714 |
def texts(self, fresh=False):
|
| 1715 |
return []
|
| 1716 |
|
| 1717 |
-
def add_flag(self, row
|
| 1718 |
if not self.table:
|
| 1719 |
raise AuthUnavailable("supabase answered 404: no table")
|
| 1720 |
-
self.flags.append(
|
| 1721 |
-
return True
|
| 1722 |
|
| 1723 |
def list_flags(self, status="pending"):
|
| 1724 |
if status != "pending":
|
| 1725 |
return []
|
| 1726 |
-
return [{"id": i + 1, **row, "status": status} for i,
|
| 1727 |
|
| 1728 |
def review_flag(self, flag_id, values):
|
| 1729 |
self.review = (flag_id, values)
|
| 1730 |
|
| 1731 |
def flag_audio(self, flag_id):
|
| 1732 |
-
|
| 1733 |
-
return None
|
| 1734 |
-
return self.flags[flag_id - 1][1], self.flags[flag_id - 1][2]
|
| 1735 |
|
| 1736 |
def calibrations(self, status=None):
|
| 1737 |
return [r for r in self.calibration_rows if not status or r["status"] == status]
|
|
@@ -1780,12 +1777,11 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
|
|
| 1780 |
assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401
|
| 1781 |
assert send(c, "s").status_code == 403 and not a.flags # tutors only
|
| 1782 |
r = send(c, "t", note=" אמרתי ח נכונה ")
|
| 1783 |
-
|
| 1784 |
-
assert r.status_code == 200 and r.json() == {"ok": True, "audio":
|
| 1785 |
assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
|
| 1786 |
assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100
|
| 1787 |
assert row["result"]["tips"][0]["key"] == "ħ>x" and "phoneme_heatmap" not in row["result"]
|
| 1788 |
-
assert audio == buf.getvalue() and mime == "audio/wav" and re.fullmatch(r"\d{4}-\d\d/[0-9a-f]{32}\.wav", name)
|
| 1789 |
assert send(c, "t", phrase_id=10**6).status_code == 404
|
| 1790 |
a.teachers = frozenset({"s@b.c"}) # a teacher flags too
|
| 1791 |
assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200
|
|
@@ -1793,7 +1789,7 @@ def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
|
|
| 1793 |
listed = c.get("/api/flags", headers=hs)
|
| 1794 |
assert listed.status_code == 200 and len(listed.json()["flags"]) == 2
|
| 1795 |
played = c.get("/api/flags/1/audio", headers=hs)
|
| 1796 |
-
assert played.status_code ==
|
| 1797 |
assert c.get("/api/flags/999/audio", headers=hs).status_code == 404
|
| 1798 |
review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition",
|
| 1799 |
"false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": " תקין "})
|
|
|
|
| 1714 |
def texts(self, fresh=False):
|
| 1715 |
return []
|
| 1716 |
|
| 1717 |
+
def add_flag(self, row):
|
| 1718 |
if not self.table:
|
| 1719 |
raise AuthUnavailable("supabase answered 404: no table")
|
| 1720 |
+
self.flags.append(row)
|
|
|
|
| 1721 |
|
| 1722 |
def list_flags(self, status="pending"):
|
| 1723 |
if status != "pending":
|
| 1724 |
return []
|
| 1725 |
+
return [{"id": i + 1, **row, "status": status} for i, row in enumerate(self.flags)]
|
| 1726 |
|
| 1727 |
def review_flag(self, flag_id, values):
|
| 1728 |
self.review = (flag_id, values)
|
| 1729 |
|
| 1730 |
def flag_audio(self, flag_id):
|
| 1731 |
+
return None
|
|
|
|
|
|
|
| 1732 |
|
| 1733 |
def calibrations(self, status=None):
|
| 1734 |
return [r for r in self.calibration_rows if not status or r["status"] == status]
|
|
|
|
| 1777 |
assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401
|
| 1778 |
assert send(c, "s").status_code == 403 and not a.flags # tutors only
|
| 1779 |
r = send(c, "t", note=" אמרתי ח נכונה ")
|
| 1780 |
+
row, = a.flags
|
| 1781 |
+
assert r.status_code == 200 and r.json() == {"ok": True, "audio": False, "flag": row}, r.text
|
| 1782 |
assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
|
| 1783 |
assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100
|
| 1784 |
assert row["result"]["tips"][0]["key"] == "ħ>x" and "phoneme_heatmap" not in row["result"]
|
|
|
|
| 1785 |
assert send(c, "t", phrase_id=10**6).status_code == 404
|
| 1786 |
a.teachers = frozenset({"s@b.c"}) # a teacher flags too
|
| 1787 |
assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200
|
|
|
|
| 1789 |
listed = c.get("/api/flags", headers=hs)
|
| 1790 |
assert listed.status_code == 200 and len(listed.json()["flags"]) == 2
|
| 1791 |
played = c.get("/api/flags/1/audio", headers=hs)
|
| 1792 |
+
assert played.status_code == 404 # reports keep diagnostics, not the user's voice recording
|
| 1793 |
assert c.get("/api/flags/999/audio", headers=hs).status_code == 404
|
| 1794 |
review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition",
|
| 1795 |
"false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": " תקין "})
|