Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 786419ce01 | |||
| abe84b9f90 | |||
| 222005c0ba | |||
| 4be3fbde50 | |||
| d8717d0fbd | |||
| 9bd40c5d1c | |||
| f26b522207 | |||
| 5c01a5c7ed | |||
| cbb6edf372 | |||
| 1bee332ae1 | |||
| a13c0b77fa | |||
| 014c96db1e | |||
| 572d479f12 | |||
| e51b57d9c7 | |||
| 4dd92f83a0 | |||
| eca68e1a28 | |||
| 51fcb6e4b7 | |||
| 2067e6efb1 | |||
| d1d52d806d |
@@ -51,3 +51,110 @@ git push origin master
|
|||||||
- Node.js/Express backend, SQLite (better-sqlite3, sync)
|
- Node.js/Express backend, SQLite (better-sqlite3, sync)
|
||||||
- Frontend: vanilla JS, без бандлера
|
- Frontend: vanilla JS, без бандлера
|
||||||
- ast-index проиндексирован: `ast-index rebuild` при добавлении новых файлов
|
- ast-index проиндексирован: `ast-index rebuild` при добавлении новых файлов
|
||||||
|
|
||||||
|
## Feature: Конструктор симуляций (SimForge)
|
||||||
|
|
||||||
|
Движок авторинга интерактивных 2D-симуляций из JSON-спеки (данные, НЕ код). План: `plans/sim-builder/`.
|
||||||
|
|
||||||
|
### Phase 0 — Learnings
|
||||||
|
|
||||||
|
- **Спека = данные.** Любое числовое свойство объекта = число ИЛИ строка-выражение. Выражения шарятся между людьми → движок безопасный, ⛔ без `eval`/`new Function`.
|
||||||
|
- **`window.SimExpr`** (`frontend/js/labs/_sim_expr.js`): токенайзер → AST → evaluate. `compile(src)->{ast,fn,error}`; `fn(env)` НИКОГДА не бросает (NaN/∞/деление на 0 → 0). Whitelist: `+ - * / ^ %`, унарный `- + !`, сравнения `< <= > >= == !=`, логика `&& ||`, тернарник `?:`, функции `sin cos tan tg ctg cot asin..arctg sqrt abs exp ln log log2 log10 floor ceil round sign min max mod atan2 pow hypot`, константы `pi e tau`. Идентификаторы (вкл. точечные `obj.x`) — только из `env`. Парсер — расширение `y=f(x)` из `graph.js`; `-2^2 == 4` (парити). Также `evalSafe`, `compileValue`, `parse`, `tokenize`, `FUNCTIONS`, `CONSTANTS`.
|
||||||
|
- **`window.SimEngine.mount(host, spec)`** (`_sim_engine.js`) → `{ play, pause, reset, setParam, getParam, isRunning, destroy, el }`. Canvas (мир→экран, равные оси, Y вверх) + KaTeX-оверлей подписей (`katex.renderToString`, как graph.js) + слайдеры из `params[]`. Выражения компилируются 1 раз в mount; в rAF — только evaluate. `env = { t, <params>, w, h, xmin..ymax, <objId>.x, <objId>.y }`. Объекты: `point segment vector circle rect polyline path label`. **Формат спеки v1 — в шапке `_sim_engine.js`.**
|
||||||
|
- **`window.registerSpecSim(spec)`** (`_sim_adapter.js`): спека → манифест LabRegistry (ленивый хост `#sim-spec-host-<id>` в `#lab-sim`; `stop` прячет, `destroy` уничтожает). Так спек-сим открывается тем же путём, что рукописные ~40 (через `openSim` → реестр).
|
||||||
|
- Демо `customdemo` — `_sim_demo.js`, за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1` (ученикам не светится).
|
||||||
|
- Подключение: 3 каркасных `<script>` eager после `_graph_panel.js` в `lab.html`, демо — после `_register-all.js`. `_sim_deps.js` не трогать (каркас грузится до диспетчера).
|
||||||
|
|
||||||
|
### Phase 1 — Learnings
|
||||||
|
|
||||||
|
- **Новые типы объектов** (в `_sim_engine.js`, формат — в шапке файла):
|
||||||
|
- `plot` — график `f(var)` на canvas движка в мир-координатах (НЕ через `GraphPanelUI` — тот stacked time-series в фикс. оверлее, не `y=f(x)`). Поля: `expr`, `var` (деф.`x`), `range:[a,b]` (числа/выражения, деф. xmin..xmax), `samples` (клампится 2..2000, деф.200), `trace` (точка var=t пишется в trail; при trace без range статич. кривая не рисуется), `color/width`. Свободная переменная подставляется во временную копию env-ключа (восстанавливается после).
|
||||||
|
- `readout` — живой бейдж на DOM-оверлее (`_labelLayer`, как label). Поля: `expr`, `label`, `unit`, `precision` (0..8, деф.2), `x/y` (мир-коорд.; без них — авто-столбик верх-право, счётчик `_readoutSlot` сбрасывается на кадр). Ошибка — мягко через `SimExpr.evalSafe` (AST компилируется 1 раз в prepare), показывает «—».
|
||||||
|
- `vector` — новая форма `origin:[ox,oy]+dx/dy` (конец = origin + (dx,dy)); старая `x1/y1/x2/y2` сохранена; стрелка из Ф0.
|
||||||
|
- **Drag** (`point`/`circle` с `drag:{param,axis,min,max,paramY}`): pointer events на canvas (мышь+тач, `touchAction:none`); хит-тест в экранных px (допуск 16px, ближайшая ручка), приоритет ручек. `axis:'xy'` требует `paramY`. Курсор → мир через `_toWorld` (инверсия `_toPx`) → `_setParamClamped` (clamp по `drag.min/max` И по диапазону параметра из `_paramRange` — не полагаться на DOM-clamp слайдера). Слушатели снимаются в `destroy`. Drag только point/circle (вершины polyline/конец вектора — не реализовано).
|
||||||
|
- Тестировать движок headless: `vm.createContext` + ручной DOM/canvas-стаб (canvas-ctx через `Proxy` с noop). `_renderFrame` рано выходит при `_cw/_ch==0` — выставить вручную. `setParam`/drag используют `new Event('input')` (браузерно безопасно, в стабе нужен `Event`).
|
||||||
|
- ⛔ `lab.html`/`lab-glue.js` — зона параллельной сессии (Ф2 измерит. инструменты); Ф1 их НЕ трогала, работала только в `_sim_engine.js`/`_sim_demo.js`.
|
||||||
|
|
||||||
|
### Phase 2 — Learnings
|
||||||
|
|
||||||
|
- **Физический режим** (всё в `_sim_engine.js`, формат — в шапке файла): блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. gravity/friction/restitution/k/length/damping/mass/нач.позиция/vx/vy — число ИЛИ выражение от params (вычисляются на **reset**, не каждый кадр — для стабильности).
|
||||||
|
- **`window.SimPhysics`** — экспортированный интегратор (`step(state,dtFrame)`, `integrate`, `resolveCollisions`). Полу-неявный (симплектический) Эйлер `v+=a·dt; x+=v·dt` — та же математика, что `_fx_motion.spring`, обобщённая на N связанных тел. Фикс-шаг с накопителем (кламп dt 1/2000..1/30, кап подшагов 8, кламп скорости 1e4, вязкое трение `exp(-friction·dt)`) → энергия не «взрывается». Упругие столкновения круг-круг (импульс по нормали + позиционная коррекция по обратным массам) и круг-стена. Чистая функция над state, без DOM/eval — переиспользуемо headless. Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
|
||||||
|
- **`_fx_motion` API не подходит** для спек-движка напрямую: `tween`/`springFactory` — rAF-замыкания, тянущие ОДНО значение к цели, не связанные тела с силами. Переиспользована только их интеграционная математика (формула спринга), а не сами функции.
|
||||||
|
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в `_buildEnv` ПЕРВЫМИ (до формульных центров) — это снимает forward-ref проблему однопроходного env для тел: формульный объект, ссылающийся на тело (`segment x2:'ball.x'`), видит актуальную позицию в том же кадре. point/circle с `body` рисуются из env-полей тела, а не из выражения x/y.
|
||||||
|
- **Drag тела**: тело (point/circle с `body`, не fixed) перетаскиваемо по умолчанию (без `drag`-конфига). Тащишь — `body.x/y = курсор`, тело временно `fixed` в `_stepPhysics`; отпускаешь — `body.vx/vy` из сглаженной оценки скорости курсора (кламп 40 м/с). Хит-тест тела — max(16px, экранный радиус). drag-ручки Ф1 и физ-тела сосуществуют.
|
||||||
|
- **Гочи**: (1) имя param **`e`** зарезервировано — это число Эйлера в SimExpr (parser проверяет CONSTANTS до env), выражение `e` даст 2.718, не значение param. Брать `el`/`elast`. (2) Радиус тела для коллизий: circle — мировой `r`; point — экранные px → мир через `_scale`, поэтому физика собирается в `reset()` ПОСЛЕ первого `_fit()`. (3) Слайдеры во время play меняют только env (readout/формулы), но НЕ силы/тела до reset (намеренно). На паузе при `t==0` — пересборка для предпросмотра старта.
|
||||||
|
- Headless-тест физики: виртуальные часы (`vclock`) синхронны с `performance.now()` и таймстампом rAF (иначе первый кадр получит огромный/отрицательный dt и ничего не сдвинется).
|
||||||
|
|
||||||
|
### Phase 3 — Learnings
|
||||||
|
|
||||||
|
- **Персистентность**: таблица `custom_sims` (миграция **071**), API `/api/custom-sims` (контроллер `customSimController.js`, роутер `customSims.js`, смонтировано в `server.js` после `/api/materials`), клиент `LS.customSimsList/Get/Create/Update/Delete`. Спека хранится как `spec_json` TEXT(JSON); парс — только на чтение/валидацию, на сервере НЕ исполняется. `version` ++ на каждом update со `spec`.
|
||||||
|
- **`validateSpec(spec)` — серверная защита БЕЗ исполнения** (спека шарится между людьми): размер ≤200KB, `specVersion`=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500симв./глубина≤8/points≤1000), whitelist типов объектов (point|segment|vector|circle|rect|polyline|path|label|plot|readout), physics-границы (restitution 0..1, dt 1/2000..1/30, body.mass>0). Строки-выражения (x/y/expr/…) НЕ парсятся (это делает безопасный SimExpr на клиенте) — проверяется только длина. Текст-поля (text/label/unit/meta/param.label/drag.param/id) **обрезаются и экранируются** (`& < >` → entities). Возврат `{ ok, error?, clean? }` — в БД пишется `clean` (санитизированная).
|
||||||
|
- **Ownership-паттерн = studentMaterialsController**: read-роуты auth-only (видимость own+published решает контроллер), мутации — inline `requireRole('teacher','admin')` + per-row проверка (`owner_id === req.user.id || role==='admin'` → иначе 403; нет строки → 404). НЕ blanket `router.use(requireRole)` — иначе ученик не увидит published.
|
||||||
|
- **lint:routes (baseline 0)**: `:id`-роуты прикрыты router-level `authMiddleware` (линтер видит `router.use(<guard>)`); read `GET/PUT/DELETE /:id` дополнительно помечены `// @public-by-design:` с указанием на ownership-проверку в хендлере (как в materials.js).
|
||||||
|
- **Тесты**: setup.js строит СВОЙ Express-app и НЕ монтирует новые роуты — тест-файл должен сам `app.use('/api/custom-sims', require(...))` (как lab-links.test.js). `getToken(role)`/`inject(method,path,body,token)` — готовые хелперы. seedRow(PRAGMA table_info) — для прямого посева строк, устойчив к дрейфу схемы (здесь не понадобился: пользователи создаются через getToken, спеки — через API).
|
||||||
|
- **Окружение тестов**: 8 fail из 209 = 3 baseline (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`chemistry7/8-*`, `math5/6-page`), падающих на `Cannot find module 'jsdom'` (devDep не установлен) — оба класса не связаны с бэкенд-фазой.
|
||||||
|
|
||||||
|
### Phase 4 — Learnings
|
||||||
|
|
||||||
|
- **Билдер = `frontend/sim-builder.html` + `frontend/js/sim-builder.js`** (логика модульна: html держит только разметку/стили/bootstrap). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost}) -> Builder`. Состояние `Builder.st`; `_uid` на объектах/стенах/пружинах — UI-метка, вырезается в `buildSpec()`. Доступ teacher/admin: `LS.initPage()` → `{isTeacher,isAdmin}` → редирект `/dashboard` (паттерн live-quiz.html).
|
||||||
|
- **Подключение движка тем же путём, что lab.html**: `<script src="/js/labs/_sim_expr.js">` + `_sim_engine.js`. Гочи маршрутизации: `/js` мапится на **корневой** `js/` (api.js/sidebar.js/mobile.js/notifications.js), а в нём НЕТ `labs/` → запрос `/js/labs/*` и `/js/sim-builder.js` проваливается на `express.static(frontendDir)` и отдаёт `frontend/js/...`. Это уже работающий механизм (lab.html), не трогать server.js.
|
||||||
|
- **Генерация спеки**: `buildSpec()` → JSON v1. `stripObj()` убирает `_uid`/пустые поля. **plot** хранит в UI `range_a/range_b` отдельно и материализуется `normalizePlotForSpec` → `range:[a,b]` (границы — число ИЛИ выражение). `stripObj` переопределён в конце IIFE на plot-aware версию — работает т.к. `buildSpec` вызывает её в рантайме (function-declaration binding мутабелен). Числовые поля хранятся «как введено» (число/строка) — `SimExpr.compileValue` ест оба, серверная `validateSpec` не парсит.
|
||||||
|
- **Выражения = только SimExpr** (без eval/Function): `SimExpr.compile(v).error` → inline-ошибка у поля; `FUNCTIONS`/`CONSTANTS` — **обычные объекты** (ключи=имена, не Set) → палитра через `Object.keys`. `exprError()` пропускает чистые числа и пустые строки.
|
||||||
|
- **Запрет имени param**: не только `e` (число Эйлера), но и `pi/E/PI/tau/t/w/h` (служебные env-переменные движка) — иначе слайдер затрёт системное имя.
|
||||||
|
- **Drag-on-preview**: переиспользует геометрию движка — `inst.canvas` + `inst._toWorld(px,py)` (px относит. canvas getBoundingClientRect). Пишет x/y (point/circle/label/readout/rect) или x2/y2 (segment/vector). Только на паузе (`!inst.isRunning()`), чтобы не конфликтовать со встроенным drag/анимацией движка.
|
||||||
|
- **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400.
|
||||||
|
- **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар.
|
||||||
|
- **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23.
|
||||||
|
|
||||||
|
### Phase 5 — Learnings
|
||||||
|
|
||||||
|
- **id-неймспейс custom: гочи LabRegistry**. `LabRegistry.get/has` обрезают часть после `:` (`_baseId`), т.к. встроенные используют `base:arg` (`emfield:E`, `stereo:cube`). Поэтому регистрировать `custom:42` НЕЛЬЗЯ — `has('custom:42')` искал бы `_byId['custom']`. Решение: в реестре id **без двоеточия** `customsim_<dbid>`, а наружу (deep-link/клик/`data-open`) — `custom:<dbid>`. Конвертация одной функцией `LabCustom.resolveId` через хук в начале `openSim` (lab-init.js, +7 строк).
|
||||||
|
- **Ленивый манифест-заглушка вместо ранней загрузки spec**. На старте /lab грузим только мету (`customSimsList`, без spec) и регистрируем заглушку с асинхронным `open()`. При первом открытии: `ensureSpec(dbid)` (`customSimGet`, кэш+дедуп) → `registerSpecSim(spec)` (Ф0-адаптер) **заменяет заглушку на месте** (`LabRegistry.register` сохраняет позицию по тому же id) → `setActive(real)` + `real.open(ctx)`. Дисп. в `openSim` уже умеет Promise-возврат `open()` (Ф3). Повторное открытие — синхронно (реальный манифест в реестре). Движок `_sim_*` уже eager (Ф0) → ленивый файл не нужен, `_sim_deps.js` не трогаем.
|
||||||
|
- **Аддитивность в чужих файлах**: вся логика — в новом IIFE `window.LabCustom` в КОНЦЕ lab-glue.js; в существующий код добавлены только хуки: `renderSims()` merge +`&& !m._custom` (1 терм) + вызов `renderSection`; init зовёт `init()`. Секция «Мои симуляции» (`#custom-sim-section`) создаётся **динамически** в `#lab-home` — без правок lab.html/CSS (тот же приём, что `_loadRelated` в Ф-каталоге). Карточки переиспользуют `.sim-card/.sim-cat/.sim-preview`; бейджи/кнопки — inline-стиль + SVG `.ic` (без эмодзи).
|
||||||
|
- **Owner-only действия**: `owner_id === user.id` (user из `LS.initPage()`, поле `id` — канон всего фронта, ср. `t.createdBy === user.id` в theory.html). Edit → `location.href='/sim-builder?id='+dbid`; Delete → `LS.customSimDelete` + убрать карточку. Делегированный клик по контейнеру секции: `data-act` (edit/del, `stopPropagation`) vs `data-open` (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published).
|
||||||
|
- **Embed/Ф7 заметка**: для `?sim=custom:*` открытие отложено до `LabCustom.init()` (и в обычном, и в embed-режиме). `_loadRelated('customsim_<id>')` дергает `/api/lab/sims/.../related` (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): `LabCustom.ensureSpec(dbid)`.
|
||||||
|
- **Смоук на РЕАЛЬНОМ registry/adapter**: harness грузит настоящие `_registry.js`+`_sim_adapter.js` в `vm`-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE `LabCustom` из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет `window.LS` (api.js ставит и `window.LS`, и глобал `LS`) — в стабе надо ставить ОБА; `document.getElementById` стаба должен находить и динамически `appendChild`-нутые элементы (регистрировать по id в appendChild). 22/22.
|
||||||
|
|
||||||
|
### Phase 6 — Learnings
|
||||||
|
|
||||||
|
- **Раздача классу = доступ + уведомление, НЕ копия.** Ключевое отличие от «Моих материалов» (`shareMaterial`): там оригинал ПРИВАТНЫЙ, поэтому каждому ученику делается независимая КОПИЯ. У custom-sim published И ТАК видна всем в каталоге (`list`/`get` отдают published любому; custom-sim НЕ гейтится `content_access` allowlist'ом 'sim' — тот гейтит ТОЛЬКО legacy `lab_sims`). Поэтому share = (1) авто-публикация `status→published`, (2) адресное уведомление ученикам класса. Копия и запись content_access избыточны. Решение зафиксировано в CONTEXT.md.
|
||||||
|
- **Долговечное уведомление: `pushNotif`, НЕ `sse.emit`.** materials.share шлёт `emit(uid, {...})` (только SSE, теряется если оффлайн) — там персистентность даёт сама копия. Для share без копии нужен durable канал: `require('../utils/notifications').pushNotif(uid, type, message, link)` — пишет в таблицу `notifications` И шлёт SSE. Ссылка `/lab?sim=custom:<id>` (Ф5 deep-link).
|
||||||
|
- **`lab_sim_links.sim_id` — TEXT** (см. мигр.043), поэтому курикулумные связи custom переиспользуют ту же таблицу с `sim_id='custom:<id>'` — отдельная таблица не нужна. Связями СВОЕЙ симуляции рулит владелец/admin (а не только admin как у lab_sims в lab.js — custom-sim принадлежит учителю). DELETE симуляции должен чистить её связи вручную (у lab_sim_links нет FK на custom_sims). `/api/lab/links?kind=...&ref_id=` (обратный поиск) джойнит `lab_sims` — для custom не сработает (отдельный bulk-эндпоинт — остаток).
|
||||||
|
- **Шаблоны = данные в JS, не код/файл.** `TEMPLATES` (массив спек v1) прямо в sim-builder.js; «Создать из шаблона» собирает синтетический sim-объект `{ id:null, status:'draft', spec, title, cat }` и зовёт существующий `loadFromSim` → simId сбрасывается в null + `history.replaceState('/sim-builder')`, чтобы первое «Сохранить» создало запись. `loadFromSim` уже корректно раскладывает plot-`range`→`range_a/range_b` (Ф4) — шаблоны с графиками round-trip без потерь.
|
||||||
|
- **publish-toggle через PUT status.** Снять с публикации = `customSimUpdate(id, { status:'draft' })` (контроллер Ф3 уже принимает `status` в update). В билдере для уже сохранённой sim — `setStatus` (без полного save, не бампит version зря); в каталоге — кнопка publish/unpublish на owner-карточке.
|
||||||
|
- **clone-источник:** своя любая ИЛИ чужая published (чужой draft → 403). Кнопка «Клонировать к себе» — только на чужой published-карточке и только для teacher/admin (`_isTeacherUser()`). Копируется `spec_json` как есть (уже санитизирован при сохранении оригинала), status=draft, version=1, title += ' (копия)'.
|
||||||
|
- **Аддитивность сохранена**: lab-glue.js правлен только внутри IIFE `LabCustom` (ICON-блок + `_cardHtml` actions + делегат + 3 новые функции + экспорт); lab.html/classroom.html не тронуты. Кнопки — inline-стиль + SVG `.ic`, без эмодзи.
|
||||||
|
|
||||||
|
### Phase 7 — Learnings
|
||||||
|
|
||||||
|
- **Доска грузит sim в IFRAME, НЕ монтирует движок напрямую.** Ключевое открытие: `onSimOpen(simId)` в classroom.html просто ставит `cr-sim-frame.src = /lab?embed=1&sim=<simId>`. Значит custom-sim на доску = переиспользование Ф5-пути: iframe `/lab?embed=1&sim=custom:<id>` сам монтирует SimEngine через `LabCustom.init→openSim→registerSpecSim`. Никакого прямого `SimEngine.mount` в классруме — план («смонтировать SimEngine в container доски») был неточен, фактический конвейер чище.
|
||||||
|
- **Синхрон состояния — обобщённый мост `sim_state`/`apply_sim_state` (postMessage), НЕ per-sim код в классруме.** Каждая встроенная sim в embed зовёт `_registerSimState(id, getState, applyState)` + `_startStateEmit(id)` (lab-glue.js, top-level). Учительский iframe постит `{type:'sim_state',state}` родителю → classroom relay `POST /sim/state` → SSE → ученик постит `{type:'apply_sim_state',state}` в свой iframe → `_simStateRegistry[_autoSim].applyState`. Custom-sim просто подключается к тому же реестру: `_bridgeCustomSimState(real)` с getState=`{params,running}` / applyState=`setParam`+play/pause поверх `real.instance()` (SimEngine: `.params`, `setParam`, `isRunning`, `play`, `pause`).
|
||||||
|
- **Ключ реестра состояния = `_autoSim` (raw `custom:<dbid>`), НЕ реестровый id.** Обработчик `apply_sim_state` берёт `_simStateRegistry[_autoSim]`, а `_autoSim` — это сырой URL-param `custom:<dbid>` (двоеточие!), хотя в LabRegistry sim лежит под `customsim_<dbid>` (resolveId). Регистрировать мост надо под `_autoSim`, иначе ученик не применит state. Гоча неочевидная.
|
||||||
|
- **simId с двоеточием ломал бэкенд-валидацию.** `simOpen` валидировал `^[a-z0-9_-]{1,40}$` — двоеточие в `custom:5` не проходило. Добавлена ветка `^custom:(\d+)$` + проверка доступа (own|published|admin → иначе 404/403). Доступ дублируется на `GET /custom-sims/:id` (ensureSpec в iframe) — две линии обороны, чужой draft не утечёт.
|
||||||
|
- **Закрытие = `frame.src='about:blank'` сносит весь iframe-документ** (SimEngine, rAF, listeners, `_simStateRegistry`) — явный `destroy()` в классруме не нужен, чисто по построению. Смена sim — тот же сброс src + новый load.
|
||||||
|
- **classroom.html (8240 строк) — искать через vex по DOM-id** (`cr-sim-picker-grid`, `cr-sim-frame`), затем точечный Read. ast-index НЕ индексирует inline-`<script>` в HTML (символы `crOpenSimPicker` и т.п. → пусто); vex тоже не парсит тела inline-функций. Для тел функций в HTML — Grep tool (документированный escape-hatch ast-index.md: «ONLY when ast-index returns empty»). Проверка инлайна: извлечь `<script>` без src в temp .js → `node --check` → удалить.
|
||||||
|
|
||||||
|
### SimForge improvements — P1 (Рабочее поле) — Learnings
|
||||||
|
|
||||||
|
Раунд полировки сверх фаз 0–7. План: `plans/sim-builder/IMPROVEMENTS.md`. Всё в `frontend/js/labs/_sim_engine.js` (один движок → эффект и в билдере, и в /lab, и на доске).
|
||||||
|
|
||||||
|
- **Первопричина «съехало вправо»**: `_build` раскладывал `root` как `display:flex` с фикс-панелью `width:260px` СЛЕВА + `stage` справа → у пустой/новой sim панель всё равно занимала 260px, сцена смещалась. **Фикс — раскладка, НЕ `_fit`** (`_fit` был корректен): `root`(relative) → `stage`(`position:absolute;inset:0`, canvas+labels на всю площадь) + контролы как **плавающая overlay-панель** (`position:absolute;left/top:10px;z-index:5;pointer-events:auto`, сворачивается `_togglePanel`, есть только при наличии `params`) + бар кнопок вида (`right/bottom:10px`). Пустое место сцены под панелью доступно для pan (`pointer-events:auto` только на карточке). sim-builder.html НЕ потребовался — старый CSS `.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже растягивает новый full-bleed root.
|
||||||
|
- **Transform-модель (zoom/pan)**: `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit по viewport) и ЭФФЕКТИВНЫЙ `_scale/_offX/_offY` (его используют `_toPx/_toWorld` — сигнатуры без изменений). `_zoom` — пользовательский множитель к базе; `_viewLocked` — был ли zoom/pan (тогда ресайз СОХРАНЯЕТ мир-центр+zoom, не сбрасывает вид). Публичное API вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport). Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке — мир-точка под курсором инвариантна; кламп `_zoom` 0.1..50×), `_setupZoomPan()` (колесо `{passive:false}` + pan на pointer events), `_visibleWorld(W,H)` (видимые мир-границы для сетки/осей с учётом zoom/pan).
|
||||||
|
- **Pan vs drag-ручек — приоритет хит-теста**: хит-тест ручек/тел вынесен из замыкания `_setupDrag` в общий метод `_pickHandleAt(lx,ly)`. Drag-листенеры регистрируются ПЕРВЫМИ (если `_hasHandles`), pan — после; `_onPanDown` стартует pan, только если `!_dragging && !_pickHandleAt(...)` → ручка/тело всегда побеждает. Курсор сцены `grab` (пустое место паним), `grabbing` при pan.
|
||||||
|
- **Сетка адаптивна к zoom**: `_niceStep(targetPx)` завязан на `_scale` (мир→px), шаги 1/2/5·10^n; `_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`); линии округляются к `.5px` (резкость, без «ступенек»). `_drawAxes` — оси X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений (светлый текст + тень на тёмном фоне, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
|
||||||
|
- **destroy** снимает wheel-листенер + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver — утечек нет.
|
||||||
|
- Иконки кнопок (`_chevIcon/_fitIcon/_resetViewIcon`) — inline SVG `.ic`-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились.
|
||||||
|
- **Верификация P1**: `node --check` OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, грузятся через `require`) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по `_toPx`), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек.
|
||||||
|
- **На P2 (графика объектов)**: расширять `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot` и чтение стилей в `_prepareObjects` (там уже читаются color/fill/width).
|
||||||
|
|
||||||
|
### SimForge improvements — P2 (Качество графики объектов) — Learnings
|
||||||
|
|
||||||
|
Всё в `frontend/js/labs/_sim_engine.js`. Расширено чтение стилей в `_prepareObjects` + применение в `_drawObject`.
|
||||||
|
|
||||||
|
- **Два хелпера вместо повтора в каждой ветке**: `_applyStroke(ctx,o)` ставит `globalAlpha=opacity`, `lineWidth=width`, `lineJoin/lineCap='round'`, `setLineDash` по `lineStyle` (хелпер `_dashFor`, паттерн масштабируется от width), и glow→`shadowColor/shadowBlur` (если `o.glow`). `_fillStyleFor(ctx,o,x0,y0,x1,y1)` строит линейный градиент `gradient:[c0,c1]` по переданному bbox (try/catch — мусорный цвет падает на `fillColor`) или возвращает сплошной `fillColor`/null. **Каждая ветка `_drawObject` обёрнута в свой `ctx.save()/restore()`** → состояние (alpha/dash/shadow/join) НЕ протекает между объектами.
|
||||||
|
- **Безопасность цвета**: все новые цветовые поля (включая стопы `gradient`, `glowColor`/`shadow`) идут ТОЛЬКО в canvas-стоки (`fillStyle`/`strokeStyle`/`createLinearGradient`+`addColorStop`/`shadowColor`) — canvas игнорит мусор, XSS нет. ⛔ В DOM `style.cssText` пользовательские цвета НЕ кладутся (это `_drawLabel`/`_drawReadout` — НЕ трогались в P2).
|
||||||
|
- **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1; `lineStyle` solid|dashed|dotted; `width` (0 → у circle/rect только заливка); `fill`/`fillColor`; `gradient:[c0,c1]` (приоритетнее fill, верт. по bbox, полигон — только при `closed`); `glow:true`/`shadow:'#c'`/`shadow:{blur}`/`glowColor`/`glowBlur` (деф. ВЫКЛ); `pointStyle` filled|hollow|cross|ring; `trailFade`(деф.true)/`trailWidth`(1.6)/`trailLen`(2000,макс 5000). Полные дефолты — IMPROVEMENTS.md Handoff P2.
|
||||||
|
- **Стрелки векторов**: `_arrowHead(ctx,a,b,color,width)` — заполненный «барбед»-треугольник (вырез у основания, не «галочка»), длина `_arrowHeadLen(width)=max(9,width*3.2)`px; тело линии укорочено на длину головы (`headLen*0.9`), голова всегда сплошная (`setLineDash([])` перед ней). **Точки** `_drawPoint(ctx,o,px,py,r)` — 4 стиля; filled-деф. = заполненный кружок + тонкая белая обводка (если не glow). **Трассы** `_drawTrail(ctx,pts,o)` — при `trailFade` рисуется ПОСЕГМЕНТНО (alpha 0.08→0.68 от хвоста к голове, «комета»), иначе одной полупрозрачной линией.
|
||||||
|
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
|
||||||
|
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
|
||||||
|
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
|
||||||
|
|||||||
@@ -10,10 +10,27 @@ function simOpen(req, res) {
|
|||||||
return res.status(403).json({ error: 'Нет доступа' });
|
return res.status(403).json({ error: 'Нет доступа' });
|
||||||
|
|
||||||
const { simId, title } = req.body;
|
const { simId, title } = req.body;
|
||||||
if (!simId || typeof simId !== 'string' || !/^[a-z0-9_-]{1,40}$/.test(simId))
|
if (!simId || typeof simId !== 'string')
|
||||||
return res.status(400).json({ error: 'Неверный simId' });
|
return res.status(400).json({ error: 'Неверный simId' });
|
||||||
|
|
||||||
emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (title || simId).slice(0, 80) });
|
// Конструктор симуляций (Фаза 7): custom-симуляция в формате 'custom:<dbid>'.
|
||||||
|
// На доску можно класть только СВОЮ симуляцию (владелец) ИЛИ published
|
||||||
|
// (доступную всем) — проверяем на сервере, draft чужого не пройдёт.
|
||||||
|
let resolvedTitle = title;
|
||||||
|
const custom = /^custom:(\d+)$/.exec(simId);
|
||||||
|
if (custom) {
|
||||||
|
const cid = Number(custom[1]);
|
||||||
|
const sim = db.prepare('SELECT id, owner_id, status, title FROM custom_sims WHERE id=?').get(cid);
|
||||||
|
if (!sim) return res.status(404).json({ error: 'Симуляция не найдена' });
|
||||||
|
if (sim.owner_id !== req.user.id && sim.status !== 'published' && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к симуляции' });
|
||||||
|
}
|
||||||
|
if (!resolvedTitle) resolvedTitle = sim.title || 'Симуляция';
|
||||||
|
} else if (!/^[a-z0-9_-]{1,40}$/.test(simId)) {
|
||||||
|
return res.status(400).json({ error: 'Неверный simId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (resolvedTitle || simId).slice(0, 80) });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Custom simulations ("Конструктор симуляций" / SimForge), Фаза 3.
|
||||||
|
*
|
||||||
|
* Учитель/админ сохраняет интерактивную 2D-симуляцию как ДАННЫЕ (JSON-спека).
|
||||||
|
* CRUD под авторизацией с проверкой владения; спека валидируется на входе
|
||||||
|
* через validateSpec — БЕЗ исполнения (спека шарится между людьми, server
|
||||||
|
* не запускает движок выражений). draft видит только владелец; published —
|
||||||
|
* публичная (каталог /lab, Фаза 5).
|
||||||
|
*
|
||||||
|
* Стиль следует studentMaterialsController: node:sqlite db.prepare,
|
||||||
|
* per-row ownership на каждой мутации, статусы 400/403/404.
|
||||||
|
*/
|
||||||
|
const db = require('../db/db');
|
||||||
|
const { pushNotif } = require('../utils/notifications');
|
||||||
|
|
||||||
|
/* ── Лимиты валидации спеки ──────────────────────────────────────────── */
|
||||||
|
const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON
|
||||||
|
const MAX_PARAMS = 50;
|
||||||
|
const MAX_OBJECTS = 200;
|
||||||
|
const MAX_WALLS = 20;
|
||||||
|
const MAX_SPRINGS = 50;
|
||||||
|
const MAX_EXPR_LEN = 500; // длина строки-выражения (x/y/expr/…)
|
||||||
|
const MAX_DEPTH = 8; // глубина вложенности JSON (анти-bomb)
|
||||||
|
const MAX_TEXT_LEN = 300; // подписи/заголовки/единицы
|
||||||
|
const MAX_POINTS = 1000; // точек в polyline/path/points
|
||||||
|
|
||||||
|
// Типы объектов из whitelist (см. формат спеки v1 в _sim_engine.js).
|
||||||
|
const OBJECT_TYPES = new Set([
|
||||||
|
'point', 'segment', 'vector', 'circle', 'rect',
|
||||||
|
'polyline', 'path', 'label', 'plot', 'readout',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STATUSES = new Set(['draft', 'published']);
|
||||||
|
const CATS = new Set(['math', 'phys', 'chem', 'bio', 'game']);
|
||||||
|
|
||||||
|
/* Экранирование подписей как ТЕКСТА (не HTML): спека рендерится в KaTeX/canvas,
|
||||||
|
но мы режем угловые скобки/амперсанд, чтобы исключить инъекцию при возможном
|
||||||
|
попадании строки в HTML-контекст. Также обрезаем по длине. */
|
||||||
|
function sanitizeText(v, max = MAX_TEXT_LEN) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
let s = String(v).slice(0, max);
|
||||||
|
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Цвет: пропускаем ТОЛЬКО безопасные формы (#hex, rgb()/rgba()/hsl()/hsla(),
|
||||||
|
имя-слово). Иначе возвращаем undefined — поле выкидывается, движок берёт дефолт.
|
||||||
|
Цель: строка вида "#fff;background:url(https://evil)" не должна утечь в
|
||||||
|
style.cssText при рендере шаренной/опубликованной спеки (CSS-инъекция → исходящий GET). */
|
||||||
|
function sanitizeColor(v) {
|
||||||
|
if (v === null || v === undefined) return undefined;
|
||||||
|
const s = String(v).trim().slice(0, 40);
|
||||||
|
if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return s;
|
||||||
|
if (/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/.test(s)) return s;
|
||||||
|
if (/^[a-zA-Z]{1,30}$/.test(s)) return s; // named color (red, transparent, ...)
|
||||||
|
return undefined; // небезопасно — выкинуть
|
||||||
|
}
|
||||||
|
function applyColorFields(out, src, keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (src[k] === undefined) continue;
|
||||||
|
const c = sanitizeColor(src[k]);
|
||||||
|
if (c === undefined) delete out[k]; else out[k] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Строка-выражение: число оставляем числом; строку обрезаем по длине, но НЕ
|
||||||
|
парсим/исполняем (это делает безопасный SimExpr на клиенте). Отклоняем
|
||||||
|
только превышение длины. */
|
||||||
|
function checkExpr(v, label, errs) {
|
||||||
|
if (typeof v === 'string' && v.length > MAX_EXPR_LEN) {
|
||||||
|
errs.push(`${label}: выражение длиннее ${MAX_EXPR_LEN} символов`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Глубина вложенности — простая защита от «бомбы» из вложенных структур. */
|
||||||
|
function depthOK(node, depth) {
|
||||||
|
if (depth > MAX_DEPTH) return false;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const x of node) if (!depthOK(x, depth + 1)) return false;
|
||||||
|
} else if (node && typeof node === 'object') {
|
||||||
|
for (const k of Object.keys(node)) if (!depthOK(node[k], depth + 1)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* validateSpec(spec) — серверная валидация спеки БЕЗ исполнения.
|
||||||
|
* Возвращает { ok:true, clean } с очищенной (санитизированной) спекой,
|
||||||
|
* либо { ok:false, error } для ответа 400.
|
||||||
|
*/
|
||||||
|
function validateSpec(spec) {
|
||||||
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
||||||
|
return { ok: false, error: 'spec должна быть объектом' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Размер сериализованного JSON.
|
||||||
|
let json;
|
||||||
|
try { json = JSON.stringify(spec); }
|
||||||
|
catch { return { ok: false, error: 'spec не сериализуется в JSON' }; }
|
||||||
|
if (Buffer.byteLength(json, 'utf8') > MAX_SPEC_BYTES) {
|
||||||
|
return { ok: false, error: `spec превышает ${Math.round(MAX_SPEC_BYTES / 1024)} KB` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глубина вложенности.
|
||||||
|
if (!depthOK(spec, 0)) return { ok: false, error: 'слишком глубокая вложенность spec' };
|
||||||
|
|
||||||
|
// specVersion.
|
||||||
|
if (spec.specVersion !== undefined && spec.specVersion !== 1) {
|
||||||
|
return { ok: false, error: 'неподдерживаемая specVersion (ожидается 1)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const errs = [];
|
||||||
|
const clean = {};
|
||||||
|
clean.specVersion = 1;
|
||||||
|
|
||||||
|
// meta: title/desc — текст.
|
||||||
|
if (spec.meta && typeof spec.meta === 'object') {
|
||||||
|
clean.meta = {};
|
||||||
|
if (spec.meta.title !== undefined) clean.meta.title = sanitizeText(spec.meta.title);
|
||||||
|
if (spec.meta.desc !== undefined) clean.meta.desc = sanitizeText(spec.meta.desc, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewport — числовые границы пропускаем как есть; bg санитизируем (CSS-инъекция).
|
||||||
|
if (spec.viewport && typeof spec.viewport === 'object' && !Array.isArray(spec.viewport)) {
|
||||||
|
clean.viewport = { ...spec.viewport };
|
||||||
|
if (clean.viewport.bg !== undefined) {
|
||||||
|
const c = sanitizeColor(clean.viewport.bg);
|
||||||
|
if (c === undefined) delete clean.viewport.bg; else clean.viewport.bg = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// time — конфиг t-цикла (autoplay/loop/duration/speed).
|
||||||
|
if (spec.time && typeof spec.time === 'object' && !Array.isArray(spec.time)) {
|
||||||
|
clean.time = spec.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// params[] — слайдеры.
|
||||||
|
const params = Array.isArray(spec.params) ? spec.params : [];
|
||||||
|
if (params.length > MAX_PARAMS) return { ok: false, error: `params > ${MAX_PARAMS}` };
|
||||||
|
clean.params = params.map((p, i) => {
|
||||||
|
if (!p || typeof p !== 'object') { errs.push(`params[${i}]: не объект`); return {}; }
|
||||||
|
const out = { ...p };
|
||||||
|
if (p.label !== undefined) out.label = sanitizeText(p.label, 120);
|
||||||
|
if (p.unit !== undefined) out.unit = sanitizeText(p.unit, 40);
|
||||||
|
if (p.name !== undefined) out.name = sanitizeText(p.name, 60);
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// objects[] — фигуры/подписи/графики/телá.
|
||||||
|
const objects = Array.isArray(spec.objects) ? spec.objects : [];
|
||||||
|
if (objects.length > MAX_OBJECTS) return { ok: false, error: `objects > ${MAX_OBJECTS}` };
|
||||||
|
clean.objects = objects.map((o, i) => {
|
||||||
|
if (!o || typeof o !== 'object') { errs.push(`objects[${i}]: не объект`); return {}; }
|
||||||
|
const type = String(o.type || '');
|
||||||
|
if (!OBJECT_TYPES.has(type)) errs.push(`objects[${i}]: недопустимый type "${type}"`);
|
||||||
|
|
||||||
|
const out = { ...o };
|
||||||
|
// Текстовые поля.
|
||||||
|
if (o.text !== undefined) out.text = sanitizeText(o.text, 1000);
|
||||||
|
if (o.label !== undefined) out.label = sanitizeText(o.label, 120);
|
||||||
|
if (o.unit !== undefined) out.unit = sanitizeText(o.unit, 40);
|
||||||
|
if (o.id !== undefined) out.id = sanitizeText(o.id, 60);
|
||||||
|
|
||||||
|
// Цвета — вайтлист (иначе CSS-инъекция через style.cssText при рендере).
|
||||||
|
applyColorFields(out, o, ['color', 'fill', 'fillColor', 'trailColor', 'bg']);
|
||||||
|
|
||||||
|
// Строки-выражения: координаты/радиусы/выражения/диапазоны.
|
||||||
|
for (const k of ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'r', 'w', 'h', 'dx', 'dy', 'expr', 'size', 'width', 'precision', 'samples']) {
|
||||||
|
if (o[k] !== undefined) checkExpr(o[k], `objects[${i}].${k}`, errs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// points[] (polyline/path) — ограничиваем число точек.
|
||||||
|
if (Array.isArray(o.points) && o.points.length > MAX_POINTS) {
|
||||||
|
errs.push(`objects[${i}].points > ${MAX_POINTS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// body{} — физическое тело (mass/vx/vy/fixed). mass>0.
|
||||||
|
if (o.body && typeof o.body === 'object' && !Array.isArray(o.body)) {
|
||||||
|
const b = o.body;
|
||||||
|
for (const k of ['mass', 'vx', 'vy']) if (b[k] !== undefined) checkExpr(b[k], `objects[${i}].body.${k}`, errs);
|
||||||
|
if (typeof b.mass === 'number' && !(b.mass > 0)) errs.push(`objects[${i}].body.mass должна быть > 0`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// drag{} — параметр-привязка.
|
||||||
|
if (o.drag && typeof o.drag === 'object' && o.drag.param !== undefined) {
|
||||||
|
out.drag = { ...o.drag };
|
||||||
|
out.drag.param = sanitizeText(o.drag.param, 60);
|
||||||
|
if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// physics{} — глобальный блок сил/мира.
|
||||||
|
if (spec.physics && typeof spec.physics === 'object' && !Array.isArray(spec.physics)) {
|
||||||
|
const ph = spec.physics;
|
||||||
|
const cph = { ...ph };
|
||||||
|
|
||||||
|
// gravity: {x,y} — числа/выражения.
|
||||||
|
if (ph.gravity && typeof ph.gravity === 'object') {
|
||||||
|
checkExpr(ph.gravity.x, 'physics.gravity.x', errs);
|
||||||
|
checkExpr(ph.gravity.y, 'physics.gravity.y', errs);
|
||||||
|
}
|
||||||
|
// friction/restitution/dt — числа/выражения + границы для числовых.
|
||||||
|
for (const k of ['friction', 'restitution', 'dt']) if (ph[k] !== undefined) checkExpr(ph[k], `physics.${k}`, errs);
|
||||||
|
if (typeof ph.restitution === 'number' && (ph.restitution < 0 || ph.restitution > 1)) {
|
||||||
|
errs.push('physics.restitution вне диапазона 0..1');
|
||||||
|
}
|
||||||
|
if (typeof ph.dt === 'number' && (ph.dt < 1 / 2000 || ph.dt > 1 / 30)) {
|
||||||
|
errs.push('physics.dt вне диапазона 1/2000..1/30');
|
||||||
|
}
|
||||||
|
|
||||||
|
// walls[] — лимит.
|
||||||
|
if (Array.isArray(ph.walls)) {
|
||||||
|
if (ph.walls.length > MAX_WALLS) return { ok: false, error: `physics.walls > ${MAX_WALLS}` };
|
||||||
|
for (let i = 0; i < ph.walls.length; i++) {
|
||||||
|
const wl = ph.walls[i];
|
||||||
|
if (wl && typeof wl === 'object') {
|
||||||
|
for (const k of ['x1', 'y1', 'x2', 'y2']) if (wl[k] !== undefined) checkExpr(wl[k], `physics.walls[${i}].${k}`, errs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// springs[] — лимит + поля.
|
||||||
|
if (Array.isArray(ph.springs)) {
|
||||||
|
if (ph.springs.length > MAX_SPRINGS) return { ok: false, error: `physics.springs > ${MAX_SPRINGS}` };
|
||||||
|
for (let i = 0; i < ph.springs.length; i++) {
|
||||||
|
const sp = ph.springs[i];
|
||||||
|
if (sp && typeof sp === 'object') {
|
||||||
|
for (const k of ['k', 'length', 'damping']) if (sp[k] !== undefined) checkExpr(sp[k], `physics.springs[${i}].${k}`, errs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean.physics = cph;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') };
|
||||||
|
return { ok: true, clean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Сериализация строки БД → ответ API ──────────────────────────────── */
|
||||||
|
function rowToSim(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
let spec = null;
|
||||||
|
try { spec = JSON.parse(row.spec_json); } catch { spec = null; }
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
owner_id: row.owner_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
subject: row.subject,
|
||||||
|
grade: row.grade,
|
||||||
|
cat: row.cat,
|
||||||
|
status: row.status,
|
||||||
|
version: row.version,
|
||||||
|
spec,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Метаданные из body — общая нормализация для create/update. */
|
||||||
|
function readMeta(b) {
|
||||||
|
return {
|
||||||
|
title: b.title !== undefined ? sanitizeText(b.title) : undefined,
|
||||||
|
description: b.description !== undefined ? sanitizeText(b.description, 2000) : undefined,
|
||||||
|
subject: b.subject !== undefined ? (b.subject != null ? String(b.subject).slice(0, 60) : null) : undefined,
|
||||||
|
grade: b.grade !== undefined ? (Number.isFinite(Number(b.grade)) ? Number(b.grade) : null) : undefined,
|
||||||
|
cat: b.cat !== undefined ? (CATS.has(String(b.cat)) ? String(b.cat) : null) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/custom-sims — свои (любой статус) + чужие published.
|
||||||
|
Без выдачи spec_json в списке (тяжело); spec приходит в GET /:id. */
|
||||||
|
function list(req, res) {
|
||||||
|
const uid = req.user.id;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, owner_id, title, description, subject, grade, cat, status, version, created_at, updated_at
|
||||||
|
FROM custom_sims
|
||||||
|
WHERE owner_id = ? OR status = 'published'
|
||||||
|
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||||
|
`).all(uid);
|
||||||
|
res.json({ sims: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/custom-sims/:id — свой (любой статус) ИЛИ чужой published. */
|
||||||
|
function get(req, res) {
|
||||||
|
const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.owner_id !== req.user.id && row.status !== 'published') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
res.json({ sim: rowToSim(row) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/custom-sims — создать (teacher/admin). Body: { title?, description?,
|
||||||
|
subject?, grade?, cat?, status?, spec }. */
|
||||||
|
function create(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const v = validateSpec(b.spec);
|
||||||
|
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||||
|
|
||||||
|
const m = readMeta(b);
|
||||||
|
const status = STATUSES.has(String(b.status)) ? String(b.status) : 'draft';
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`).run(
|
||||||
|
req.user.id,
|
||||||
|
m.title ?? null,
|
||||||
|
m.description ?? null,
|
||||||
|
m.subject ?? null,
|
||||||
|
m.grade ?? null,
|
||||||
|
m.cat ?? null,
|
||||||
|
JSON.stringify(v.clean),
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PUT /api/custom-sims/:id — обновить (владелец/admin). Любое поле опционально;
|
||||||
|
spec, если передан, валидируется заново и поднимает version. */
|
||||||
|
function update(req, res) {
|
||||||
|
const row = db.prepare('SELECT owner_id, version FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = req.body || {};
|
||||||
|
const fields = [], args = [];
|
||||||
|
|
||||||
|
if (b.spec !== undefined) {
|
||||||
|
const v = validateSpec(b.spec);
|
||||||
|
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||||
|
fields.push('spec_json = ?'); args.push(JSON.stringify(v.clean));
|
||||||
|
fields.push('version = ?'); args.push(row.version + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = readMeta(b);
|
||||||
|
if (m.title !== undefined) { fields.push('title = ?'); args.push(m.title); }
|
||||||
|
if (m.description !== undefined) { fields.push('description = ?'); args.push(m.description); }
|
||||||
|
if (m.subject !== undefined) { fields.push('subject = ?'); args.push(m.subject); }
|
||||||
|
if (m.grade !== undefined) { fields.push('grade = ?'); args.push(m.grade); }
|
||||||
|
if (m.cat !== undefined) { fields.push('cat = ?'); args.push(m.cat); }
|
||||||
|
if (b.status !== undefined && STATUSES.has(String(b.status))) { fields.push('status = ?'); args.push(String(b.status)); }
|
||||||
|
|
||||||
|
if (!fields.length) return res.json({ ok: true });
|
||||||
|
fields.push("updated_at = datetime('now')");
|
||||||
|
args.push(req.params.id);
|
||||||
|
db.prepare(`UPDATE custom_sims SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DELETE /api/custom-sims/:id — удалить (владелец/admin). */
|
||||||
|
function remove(req, res) {
|
||||||
|
const row = db.prepare('SELECT owner_id FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id);
|
||||||
|
// Заодно чистим осиротевшие курикулумные связи (sim_id = 'custom:<id>').
|
||||||
|
try { db.prepare("DELETE FROM lab_sim_links WHERE sim_id = ?").run('custom:' + req.params.id); } catch (_e) { /* таблица может отсутствовать */ }
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
Фаза 6 — раздача классу / клонирование / курикулумная привязка.
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Проверка владения симуляцией (владелец ИЛИ admin). Возвращает row или null. */
|
||||||
|
function ownedSim(req) {
|
||||||
|
const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return { row: null, code: 404 };
|
||||||
|
if (row.owner_id !== req.user.id && req.user.role !== 'admin') return { row: null, code: 403 };
|
||||||
|
return { row, code: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/custom-sims/:id/share body: { classId }
|
||||||
|
*
|
||||||
|
* РЕШЕНИЕ (копия vs доступ): published custom-sim И ТАК видна всем в каталоге
|
||||||
|
* /lab (list/get отдают published любому). Поэтому раздача классу — это НЕ копия
|
||||||
|
* (как у «Моих материалов», где копия нужна т.к. оригинал приватный), а:
|
||||||
|
* 1) авто-публикация (status -> published), чтобы ученики могли открыть;
|
||||||
|
* 2) адресное ДОЛГОВЕЧНОЕ уведомление ученикам класса со ссылкой
|
||||||
|
* /lab?sim=custom:<id> (notifications-таблица + SSE через pushNotif).
|
||||||
|
* Отдельная запись content_access не нужна: custom-sim не гейтится allowlist'ом
|
||||||
|
* 'sim' (тот гейтит только legacy lab_sims); published виден всем. */
|
||||||
|
function share(req, res) {
|
||||||
|
const { row, code } = ownedSim(req);
|
||||||
|
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||||
|
|
||||||
|
const b = req.body || {};
|
||||||
|
const classId = Number(b.classId);
|
||||||
|
if (!Number.isFinite(classId)) return res.status(400).json({ error: 'classId required' });
|
||||||
|
|
||||||
|
const cls = db.prepare('SELECT id, teacher_id, name FROM classes WHERE id = ?').get(classId);
|
||||||
|
if (!cls) return res.status(404).json({ error: 'class not found' });
|
||||||
|
if (cls.teacher_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'not your class' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авто-публикация, чтобы ученики могли открыть симуляцию по ссылке.
|
||||||
|
if (row.status !== 'published') {
|
||||||
|
db.prepare("UPDATE custom_sims SET status = 'published', updated_at = datetime('now') WHERE id = ?").run(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||||
|
const simTitle = row.title || 'симуляция';
|
||||||
|
const link = '/lab?sim=custom:' + row.id;
|
||||||
|
const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id);
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for (const uid of recipients) {
|
||||||
|
if (!uid || uid === req.user.id) continue;
|
||||||
|
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link);
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
res.json({ ok: true, sent, status: 'published' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
|
||||||
|
* Источник: своя (любой статус) ИЛИ чужая published. Заголовок += « (копия)».
|
||||||
|
* Метаданные (subject/grade/cat) копируются; status всегда draft; version=1. */
|
||||||
|
function clone(req, res) {
|
||||||
|
const src = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!src) return res.status(404).json({ error: 'not found' });
|
||||||
|
// Клонировать можно свою (любую) или чужую только published.
|
||||||
|
if (src.owner_id !== req.user.id && src.status !== 'published' && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = src.title || 'Симуляция';
|
||||||
|
const title = sanitizeText(baseTitle + ' (копия)');
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', 1)
|
||||||
|
`).run(
|
||||||
|
req.user.id,
|
||||||
|
title,
|
||||||
|
src.description,
|
||||||
|
src.subject,
|
||||||
|
src.grade,
|
||||||
|
src.cat,
|
||||||
|
src.spec_json,
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Курикулумная привязка: переиспользуем lab_sim_links с sim_id='custom:<id>'.
|
||||||
|
sim_id в таблице — TEXT, поэтому отдельная таблица не нужна. Управляет
|
||||||
|
связями ВЛАДЕЛЕЦ симуляции (или admin), а не только admin как у lab_sims:
|
||||||
|
custom-sim принадлежит учителю. ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
const LINK_KINDS = new Set(['textbook', 'topic', 'kmap', 'question']);
|
||||||
|
|
||||||
|
function decorateLink(l) {
|
||||||
|
const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null };
|
||||||
|
if (l.kind === 'textbook') {
|
||||||
|
const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id);
|
||||||
|
if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; }
|
||||||
|
out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id);
|
||||||
|
} else if (l.kind === 'topic') {
|
||||||
|
const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id));
|
||||||
|
if (tp) out.label = out.label || tp.name;
|
||||||
|
}
|
||||||
|
if (!out.label) out.label = l.kind + ':' + l.ref_id;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/custom-sims/:id/related → { links:{ textbook:[], topic:[], kmap:[], question:[] } }
|
||||||
|
Доступно любому, кто может видеть симуляцию (own ИЛИ published). */
|
||||||
|
function related(req, res) {
|
||||||
|
const sim = db.prepare('SELECT id, owner_id, status FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||||
|
if (!sim) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (sim.owner_id !== req.user.id && sim.status !== 'published' && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
const simId = 'custom:' + sim.id;
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = db.prepare(
|
||||||
|
'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id'
|
||||||
|
).all(simId);
|
||||||
|
} catch (_e) {
|
||||||
|
return res.json({ links: {}, needs_migration: true });
|
||||||
|
}
|
||||||
|
const links = { textbook: [], topic: [], kmap: [], question: [] };
|
||||||
|
for (const l of rows) (links[l.kind] || (links[l.kind] = [])).push(decorateLink(l));
|
||||||
|
res.json({ links });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/custom-sims/:id/links body: { kind, ref_id, label? } — добавить связь.
|
||||||
|
Владелец/admin. */
|
||||||
|
function addLink(req, res) {
|
||||||
|
const { row, code } = ownedSim(req);
|
||||||
|
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||||
|
|
||||||
|
const b = req.body || {};
|
||||||
|
const kind = String(b.kind || '');
|
||||||
|
const refId = String(b.ref_id || '').trim();
|
||||||
|
if (!LINK_KINDS.has(kind)) return res.status(400).json({ error: 'неверный kind' });
|
||||||
|
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
|
||||||
|
|
||||||
|
// Мягкая валидация существования цели (как в lab.js).
|
||||||
|
if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) {
|
||||||
|
return res.status(404).json({ error: 'учебник не найден: ' + refId });
|
||||||
|
}
|
||||||
|
if (kind === 'topic') {
|
||||||
|
const tid = Number(refId);
|
||||||
|
if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) {
|
||||||
|
return res.status(404).json({ error: 'тема не найдена: ' + refId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = b.label != null ? (String(b.label).trim().slice(0, 200) || null) : null;
|
||||||
|
const simId = 'custom:' + row.id;
|
||||||
|
try {
|
||||||
|
const info = db.prepare(
|
||||||
|
'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(simId, kind, refId, label, req.user.id);
|
||||||
|
const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?')
|
||||||
|
.get(info.lastInsertRowid);
|
||||||
|
res.json({ ok: true, link: decorateLink(created) });
|
||||||
|
} catch (e) {
|
||||||
|
if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DELETE /api/custom-sims/:id/links/:linkId — удалить связь. Владелец/admin. */
|
||||||
|
function removeLink(req, res) {
|
||||||
|
const { row, code } = ownedSim(req);
|
||||||
|
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
|
||||||
|
const linkId = Number(req.params.linkId);
|
||||||
|
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
|
||||||
|
const simId = 'custom:' + row.id;
|
||||||
|
const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId);
|
||||||
|
if (!info.changes) return res.status(404).json({ error: 'связь не найдена' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
list, get, create, update, remove, validateSpec,
|
||||||
|
share, clone, related, addLink, removeLink,
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const sharp = require('sharp');
|
||||||
const { UPLOADS_DIR } = require('../config');
|
const { UPLOADS_DIR } = require('../config');
|
||||||
|
|
||||||
const { checkMagicBytes } = require('../utils/magic');
|
const { checkMagicBytes } = require('../utils/magic');
|
||||||
@@ -173,16 +174,41 @@ function uploadFile(req, res) {
|
|||||||
* teacher library upload above. Image-only; saved into uploads/materials and
|
* teacher library upload above. Image-only; saved into uploads/materials and
|
||||||
* served statically (public), so the returned URL renders in <img>, opens in
|
* served statically (public), so the returned URL renders in <img>, opens in
|
||||||
* a new tab and downloads without an auth header. Returns { url }. */
|
* a new tab and downloads without an auth header. Returns { url }. */
|
||||||
function uploadPersonalFile(req, res) {
|
async function uploadPersonalFile(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
|
||||||
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
||||||
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||||
try { fs.unlinkSync(filePath); } catch {}
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user storage quota: reject before the file becomes usable. Accounting
|
||||||
|
// is by student_materials.bytes (the uploaded file is not a material yet).
|
||||||
|
const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id);
|
||||||
|
const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB
|
||||||
|
if (used.b + (req.file.size || 0) > maxBytes) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
|
return res.status(413).json({ error: 'Превышен лимит хранилища материалов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/uploads/materials/' + req.file.filename;
|
||||||
|
// Server-side thumbnail (downscaled webp) for fast grid rendering; the full
|
||||||
|
// image stays for viewing/annotating/download. Best-effort — on any failure
|
||||||
|
// (animated gif, decode error) thumbUrl is null and the client uses `url`.
|
||||||
|
let thumbUrl = null;
|
||||||
|
try {
|
||||||
|
const thumbName = path.basename(req.file.filename, path.extname(req.file.filename)) + '_thumb.webp';
|
||||||
|
const thumbPath = path.resolve(UPLOADS_DIR, 'materials', thumbName);
|
||||||
|
await sharp(filePath).rotate().resize(480, 480, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(thumbPath);
|
||||||
|
thumbUrl = '/uploads/materials/' + thumbName;
|
||||||
|
} catch (e) { thumbUrl = null; }
|
||||||
|
|
||||||
|
return res.status(201).json({ url, thumbUrl });
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'Upload failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json({ url: '/uploads/materials/' + req.file.filename });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
||||||
|
|||||||
@@ -10,52 +10,156 @@ function safeImg(url) {
|
|||||||
return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : '';
|
return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SM-2 (Anki-стиль: кнопки различаются) ─────────────────────────────────
|
/* ── Планировщик с learning-steps (Anki-стиль) ─────────────────────────────
|
||||||
quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко.
|
quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко.
|
||||||
В отличие от чистого SM-2, интервал зависит от оценки уже на первых повторах:
|
|
||||||
на новой карте Снова/Трудно/Знаю → 1д, Легко → 4д; на зрелых — Трудно ×1.2,
|
|
||||||
Знаю ×ef, Легко ×ef×1.3 (easy-бонус). ВАЖНО: клиентское превью
|
|
||||||
fcNextInterval() в flashcards.html — точная копия этой логики интервалов.
|
|
||||||
─────────────────────────────────────────────────────────────────────── */
|
|
||||||
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
|
|
||||||
function sm2(easeFactor, intervalDays, repetitions, quality) {
|
|
||||||
let ef = easeFactor;
|
|
||||||
let n = repetitions;
|
|
||||||
let iv = intervalDays;
|
|
||||||
|
|
||||||
if (quality < 3) {
|
Карточка живёт в одном из состояний:
|
||||||
n = 0;
|
learning — новая, проходит шаги обучения FC_LEARN_STEPS (минуты);
|
||||||
iv = 1; // Снова — сброс
|
relearning — зрелая, провалена → снова шаги FC_RELEARN_STEPS;
|
||||||
} else {
|
review — выпущена, день-интервалы SM-2.
|
||||||
if (n === 0) {
|
|
||||||
iv = (quality === 5) ? 4 : 1; // выпуск: Легко 4д, иначе 1д
|
На learning/relearning «Снова» возвращает на шаг 0 (минуты!) и карта
|
||||||
} else if (n === 1) {
|
ПОВТОРНО показывается в той же сессии (re-queue делает клиент по флагу
|
||||||
iv = (quality === 3) ? 3 : (quality === 4) ? 6 : Math.round(6 * FC_EASY_BONUS);
|
graduated=false). «Знаю» продвигает шаг; пройдя последний — выпуск в review
|
||||||
|
(FC_GRAD_IV дн.), «Легко» выпускает сразу (FC_EASY_IV дн.). На review «Снова»
|
||||||
|
= lapse → relearning (ef −0.2, интервал ×0.5). Зрелые интервалы: Трудно ×1.2,
|
||||||
|
Знаю ×ef, Легко ×ef×1.3.
|
||||||
|
|
||||||
|
Под-дневные интервалы хранятся в due_at, поэтому interval_days — целые дни
|
||||||
|
последнего выпуска. ВАЖНО: клиентское превью fcPreview() в flashcards.html —
|
||||||
|
зеркало интервальной части этой логики.
|
||||||
|
─────────────────────────────────────────────────────────────────────── */
|
||||||
|
const FC_LEARN_STEPS = [1, 10]; // минуты — шаги новой карты
|
||||||
|
const FC_RELEARN_STEPS = [10]; // минуты — шаги после провала зрелой
|
||||||
|
const FC_GRAD_IV = 1; // дней — выпуск через «Знаю»
|
||||||
|
const FC_EASY_IV = 4; // дней — выпуск через «Легко»
|
||||||
|
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3, FC_MIN_EF = 1.3;
|
||||||
|
|
||||||
|
/* prev: { state, learning_step, ease_factor, interval_days, repetitions, lapses }.
|
||||||
|
Возвращает следующее расписание + dueInSec (для клиентского re-queue) и
|
||||||
|
graduated (карта в review → из сессии можно убрать). */
|
||||||
|
function schedule(prev, quality, nowMs) {
|
||||||
|
let state = prev.state || 'new';
|
||||||
|
let step = prev.learning_step || 0;
|
||||||
|
let ef = prev.ease_factor || 2.5;
|
||||||
|
let iv = prev.interval_days || 0;
|
||||||
|
let reps = prev.repetitions || 0;
|
||||||
|
let lapses = prev.lapses || 0;
|
||||||
|
let dueSec;
|
||||||
|
|
||||||
|
const learning = (state === 'new' || state === 'learning' || state === 'relearning');
|
||||||
|
|
||||||
|
if (learning) {
|
||||||
|
const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS;
|
||||||
|
if (quality === 5) { // Легко → выпуск сразу
|
||||||
|
state = 'review'; step = 0; reps = Math.max(reps, 1);
|
||||||
|
iv = FC_EASY_IV; dueSec = iv * 86400;
|
||||||
|
} else if (quality < 3) { // Снова → первый шаг (минуты)
|
||||||
|
if (state === 'new') state = 'learning';
|
||||||
|
step = 0; dueSec = steps[0] * 60;
|
||||||
|
} else if (quality === 3) { // Трудно → повтор текущего шага
|
||||||
|
if (state === 'new') state = 'learning';
|
||||||
|
dueSec = steps[Math.min(step, steps.length - 1)] * 60;
|
||||||
|
} else { // Знаю → следующий шаг
|
||||||
|
if (state === 'new') state = 'learning';
|
||||||
|
step += 1;
|
||||||
|
if (step >= steps.length) { // прошёл все шаги → выпуск
|
||||||
|
const grad = (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV;
|
||||||
|
state = 'review'; step = 0; reps = Math.max(reps, 1);
|
||||||
|
iv = grad; dueSec = iv * 86400;
|
||||||
|
} else {
|
||||||
|
dueSec = steps[step] * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ef = Math.max(FC_MIN_EF, ef); // ease меняем только на review-ответах
|
||||||
|
} else { // state === 'review'
|
||||||
|
if (quality < 3) { // провал → relearning
|
||||||
|
lapses += 1;
|
||||||
|
ef = Math.max(FC_MIN_EF, ef - 0.20);
|
||||||
|
iv = Math.max(1, Math.round(iv * 0.5)); // целевой интервал после переучивания
|
||||||
|
reps = 0; state = 'relearning'; step = 0;
|
||||||
|
dueSec = FC_RELEARN_STEPS[0] * 60;
|
||||||
} else {
|
} else {
|
||||||
if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT));
|
if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT));
|
||||||
else if (quality === 4) iv = Math.round(iv * ef);
|
else if (quality === 4) iv = Math.max(iv + 1, Math.round(iv * ef));
|
||||||
else iv = Math.round(iv * ef * FC_EASY_BONUS);
|
else iv = Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS));
|
||||||
|
reps += 1;
|
||||||
|
ef = Math.max(FC_MIN_EF, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
||||||
|
dueSec = iv * 86400;
|
||||||
}
|
}
|
||||||
n++;
|
|
||||||
}
|
}
|
||||||
ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
|
||||||
const due = new Date(Date.now() + iv * 86400000).toISOString();
|
const dueAt = new Date(nowMs + dueSec * 1000).toISOString();
|
||||||
return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due };
|
return { state, learningStep: step, easeFactor: ef, intervalDays: iv, repetitions: reps,
|
||||||
|
lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
|
/* ── доступ к колоде: владелец / админ / расшарена (user|class) ──────────────
|
||||||
|
{ deck, owner, canRead, canEdit }. canEdit — владелец или админ (правка карт/
|
||||||
|
колоды). canRead — ещё и тот, кому колода назначена напрямую или через класс
|
||||||
|
(учится с личным прогрессом: flashcard_reviews keyed по user_id+card_id). */
|
||||||
|
function deckAccess(deckId, user) {
|
||||||
|
const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ?`).get(deckId);
|
||||||
|
if (!deck) return { deck: null, owner: false, canRead: false, canEdit: false };
|
||||||
|
if (deck.user_id === user.id || user.role === 'admin')
|
||||||
|
return { deck, owner: deck.user_id === user.id, canRead: true, canEdit: true };
|
||||||
|
const shared = db.prepare(`
|
||||||
|
SELECT 1 FROM flashcard_deck_access a
|
||||||
|
WHERE a.deck_id = ? AND (
|
||||||
|
(a.type = 'user' AND a.target_id = ?) OR
|
||||||
|
(a.type = 'class' AND a.target_id IN (SELECT class_id FROM class_members WHERE user_id = ?))
|
||||||
|
) LIMIT 1
|
||||||
|
`).get(deckId, user.id, user.id);
|
||||||
|
return { deck, owner: false, canRead: !!shared, canEdit: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now)
|
||||||
|
+ новые в пределах дневного лимита (как в getStudySession). 7 binds:
|
||||||
|
uid, deck, uid, deck, deck, deck, uid. */
|
||||||
|
function deckDueCount(deckId, uid) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT (
|
||||||
|
(SELECT COUNT(*) FROM flashcard_cards c
|
||||||
|
JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||||
|
WHERE c.deck_id = ? AND r.due_at <= datetime('now'))
|
||||||
|
+ MIN(
|
||||||
|
(SELECT COUNT(*) FROM flashcard_cards c
|
||||||
|
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||||
|
WHERE c.deck_id = ? AND r.id IS NULL),
|
||||||
|
MAX(0, (SELECT new_per_day FROM flashcard_decks WHERE id = ?)
|
||||||
|
- (SELECT COUNT(*) FROM flashcard_reviews r
|
||||||
|
JOIN flashcard_cards c ON c.id = r.card_id
|
||||||
|
WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now')))
|
||||||
|
)
|
||||||
|
) AS n
|
||||||
|
`).get(uid, deckId, uid, deckId, deckId, deckId, uid).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/flashcards/decks ───────────────────────────────────────────────
|
||||||
|
Свои колоды + назначенные мне (через class/user). shared/can_edit/owner_name —
|
||||||
|
для UI: общие открываются только на чтение и изучение. */
|
||||||
function listDecks(req, res) {
|
function listDecks(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
const decks = db.prepare(`
|
const decks = db.prepare(`
|
||||||
SELECT d.*,
|
SELECT d.*, u.name AS owner_name,
|
||||||
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
|
CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit,
|
||||||
(SELECT COUNT(*) FROM flashcard_cards c
|
CASE WHEN d.user_id = ? THEN 0 ELSE 1 END AS shared
|
||||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
|
||||||
WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count
|
|
||||||
FROM flashcard_decks d
|
FROM flashcard_decks d
|
||||||
|
JOIN users u ON u.id = d.user_id
|
||||||
WHERE d.user_id = ?
|
WHERE d.user_id = ?
|
||||||
ORDER BY d.created_at DESC
|
OR EXISTS (SELECT 1 FROM flashcard_deck_access a
|
||||||
`).all(uid, uid);
|
WHERE a.deck_id = d.id AND a.type = 'user' AND a.target_id = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM flashcard_deck_access a
|
||||||
|
JOIN class_members cm ON cm.class_id = a.target_id AND cm.user_id = ?
|
||||||
|
WHERE a.deck_id = d.id AND a.type = 'class')
|
||||||
|
ORDER BY shared ASC, d.created_at DESC
|
||||||
|
`).all(uid, uid, uid, uid, uid);
|
||||||
|
|
||||||
|
const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`);
|
||||||
|
for (const d of decks) {
|
||||||
|
d.card_count = cardStmt.get(d.id).n;
|
||||||
|
d.due_count = deckDueCount(d.id, uid);
|
||||||
|
}
|
||||||
res.json({ decks });
|
res.json({ decks });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,20 +196,21 @@ function deleteDeck(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */
|
/* ── GET /api/flashcards/decks/:id/cards ─────────────────────────────────────
|
||||||
|
Доступно владельцу и тем, кому колода назначена (общая открывается read-only —
|
||||||
|
can_edit подсказывает фронту прятать редактирование). */
|
||||||
function getCards(req, res) {
|
function getCards(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
const acc = deckAccess(req.params.id, req.user);
|
||||||
.get(req.params.id, uid);
|
if (!acc.canRead) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
|
||||||
const cards = db.prepare(`
|
const cards = db.prepare(`
|
||||||
SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed
|
SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed
|
||||||
FROM flashcard_cards c
|
FROM flashcard_cards c
|
||||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||||
WHERE c.deck_id = ?
|
WHERE c.deck_id = ?
|
||||||
ORDER BY c.order_idx, c.id
|
ORDER BY c.order_idx, c.id
|
||||||
`).all(uid, deck.id);
|
`).all(uid, acc.deck.id);
|
||||||
res.json({ cards });
|
res.json({ cards, can_edit: acc.canEdit });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
|
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
|
||||||
@@ -209,31 +314,45 @@ function deleteCard(req, res) {
|
|||||||
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
|
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
|
||||||
function getStudySession(req, res) {
|
function getStudySession(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
const acc = deckAccess(req.params.id, req.user); // владелец ИЛИ кому назначена
|
||||||
.get(req.params.id, uid);
|
if (!acc.canRead) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
const deck = acc.deck;
|
||||||
// due cards first, then new cards (no review yet), limit 20
|
|
||||||
const cards = db.prepare(`
|
// 1) Карты к повторению (есть строка отзыва, due_at<=now): learning — по минутам,
|
||||||
|
// review — по дням. Отдаём по возрастанию due_at (срочные learning впереди).
|
||||||
|
const dueCards = db.prepare(`
|
||||||
SELECT c.id, c.front, c.back, c.front_image, c.back_image,
|
SELECT c.id, c.front, c.back, c.front_image, c.back_image,
|
||||||
COALESCE(r.ease_factor, 2.5) AS ease_factor,
|
r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed,
|
||||||
COALESCE(r.interval_days, 1) AS interval_days,
|
r.state, r.learning_step, 1 AS seen
|
||||||
COALESCE(r.repetitions, 0) AS repetitions,
|
FROM flashcard_cards c
|
||||||
COALESCE(r.due_at, datetime('now')) AS due_at,
|
JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||||
r.last_reviewed,
|
WHERE c.deck_id = ? AND r.due_at <= datetime('now')
|
||||||
CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen
|
ORDER BY r.due_at ASC
|
||||||
|
LIMIT 100
|
||||||
|
`).all(uid, deck.id);
|
||||||
|
|
||||||
|
// 2) Новые карты (без отзыва), но не больше дневного лимита за вычетом уже
|
||||||
|
// введённых сегодня — защита от перегруза на большой колоде.
|
||||||
|
const newToday = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS n FROM flashcard_reviews r
|
||||||
|
JOIN flashcard_cards c ON c.id = r.card_id
|
||||||
|
WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now')
|
||||||
|
`).get(deck.id, uid).n;
|
||||||
|
const newBudget = Math.max(0, (deck.new_per_day || 20) - newToday);
|
||||||
|
const newCards = newBudget > 0 ? db.prepare(`
|
||||||
|
SELECT c.id, c.front, c.back, c.front_image, c.back_image,
|
||||||
|
2.5 AS ease_factor, 0 AS interval_days, 0 AS repetitions,
|
||||||
|
datetime('now') AS due_at, NULL AS last_reviewed,
|
||||||
|
'new' AS state, 0 AS learning_step, 0 AS seen
|
||||||
FROM flashcard_cards c
|
FROM flashcard_cards c
|
||||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||||
WHERE c.deck_id = ?
|
WHERE c.deck_id = ? AND r.id IS NULL
|
||||||
AND (r.id IS NULL OR r.due_at <= datetime('now'))
|
ORDER BY c.order_idx, c.id
|
||||||
ORDER BY seen ASC, r.due_at ASC
|
LIMIT ?
|
||||||
LIMIT 20
|
`).all(uid, deck.id, newBudget) : [];
|
||||||
`).all(uid, deck.id);
|
|
||||||
const total_due = db.prepare(`
|
const cards = dueCards.concat(newCards);
|
||||||
SELECT COUNT(*) AS n FROM flashcard_cards c
|
res.json({ cards, total_due: cards.length });
|
||||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
|
||||||
WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
|
|
||||||
`).get(uid, deck.id).n;
|
|
||||||
res.json({ cards, total_due });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
|
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
|
||||||
@@ -243,33 +362,48 @@ function submitReview(req, res) {
|
|||||||
if (quality === undefined || quality < 0 || quality > 5)
|
if (quality === undefined || quality < 0 || quality > 5)
|
||||||
return res.status(400).json({ error: 'quality 0-5 required' });
|
return res.status(400).json({ error: 'quality 0-5 required' });
|
||||||
|
|
||||||
const card = db.prepare(`
|
// Отзыв может ставить владелец И ученик, которому колода назначена (свой прогресс).
|
||||||
SELECT c.id FROM flashcard_cards c
|
const card = db.prepare(`SELECT id, deck_id FROM flashcard_cards WHERE id = ?`).get(req.params.id);
|
||||||
JOIN flashcard_decks d ON d.id = c.deck_id
|
|
||||||
WHERE c.id = ? AND d.user_id = ?
|
|
||||||
`).get(req.params.id, uid);
|
|
||||||
if (!card) return res.status(404).json({ error: 'Not found' });
|
if (!card) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!deckAccess(card.deck_id, req.user).canRead) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
const existing = db.prepare(
|
const existing = db.prepare(
|
||||||
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
|
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
|
||||||
).get(uid, card.id);
|
).get(uid, card.id);
|
||||||
|
|
||||||
const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
|
const prev = existing || { state: 'new', learning_step: 0, ease_factor: 2.5,
|
||||||
const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality);
|
interval_days: 0, repetitions: 0, lapses: 0 };
|
||||||
|
const next = schedule(prev, quality, Date.now());
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE flashcard_reviews
|
UPDATE flashcard_reviews
|
||||||
SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now')
|
SET state=?, learning_step=?, ease_factor=?, interval_days=?, repetitions=?,
|
||||||
|
lapses=?, due_at=?, last_reviewed=datetime('now')
|
||||||
WHERE user_id=? AND card_id=?
|
WHERE user_id=? AND card_id=?
|
||||||
`).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id);
|
`).run(next.state, next.learningStep, next.easeFactor, next.intervalDays,
|
||||||
|
next.repetitions, next.lapses, next.dueAt, uid, card.id);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed)
|
INSERT INTO flashcard_reviews
|
||||||
VALUES (?,?,?,?,?,?,datetime('now'))
|
(user_id, card_id, state, learning_step, ease_factor, interval_days,
|
||||||
`).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt);
|
repetitions, lapses, due_at, last_reviewed, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
|
||||||
|
`).run(uid, card.id, next.state, next.learningStep, next.easeFactor,
|
||||||
|
next.intervalDays, next.repetitions, next.lapses, next.dueAt);
|
||||||
}
|
}
|
||||||
res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays });
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
graduated: next.graduated, // true → карта в review (из сессии можно убрать)
|
||||||
|
due_in_sec: next.dueInSec,
|
||||||
|
next_review: next.dueAt,
|
||||||
|
interval_days: next.intervalDays,
|
||||||
|
next: {
|
||||||
|
state: next.state, learning_step: next.learningStep,
|
||||||
|
ease_factor: next.easeFactor, interval_days: next.intervalDays,
|
||||||
|
repetitions: next.repetitions,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */
|
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */
|
||||||
@@ -358,9 +492,76 @@ function uploadImage(req, res) {
|
|||||||
res.json({ url: `/uploads/flashcards/${req.file.filename}` });
|
res.json({ url: `/uploads/flashcards/${req.file.filename}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══ Шаринг колоды учителем (назначение классу/ученику) ══════════════════════
|
||||||
|
Карты общие (одна копия), прогресс у каждого свой. Управление — только
|
||||||
|
владелец/админ (canEdit). Цель валидируется: класс/ученик должен принадлежать
|
||||||
|
учителю (или роль admin) — нельзя расшарить «в чужой класс». */
|
||||||
|
|
||||||
|
/* ── GET /api/flashcards/decks/:id/shares ── */
|
||||||
|
function listShares(req, res) {
|
||||||
|
const acc = deckAccess(req.params.id, req.user);
|
||||||
|
if (!acc.deck) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const shares = db.prepare(`
|
||||||
|
SELECT a.id, a.type, a.target_id, a.created_at,
|
||||||
|
CASE WHEN a.type = 'class' THEN (SELECT name FROM classes WHERE id = a.target_id)
|
||||||
|
ELSE (SELECT name FROM users WHERE id = a.target_id) END AS target_name
|
||||||
|
FROM flashcard_deck_access a
|
||||||
|
WHERE a.deck_id = ?
|
||||||
|
ORDER BY a.type, target_name
|
||||||
|
`).all(acc.deck.id);
|
||||||
|
res.json({ shares });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* проверка: класс/ученик принадлежит учителю (admin — без ограничений). */
|
||||||
|
function ownsTarget(user, type, targetId) {
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
if (type === 'class')
|
||||||
|
return !!db.prepare(`SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?`).get(targetId, user.id);
|
||||||
|
// type === 'user' — ученик в одном из классов учителя ИЛИ его персональный ученик
|
||||||
|
return !!db.prepare(`
|
||||||
|
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
||||||
|
WHERE cm.user_id = ? AND c.teacher_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT 1 FROM teacher_students WHERE student_id = ? AND teacher_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(targetId, user.id, targetId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/flashcards/decks/:id/share { type, target_id } ── */
|
||||||
|
function addShare(req, res) {
|
||||||
|
const acc = deckAccess(req.params.id, req.user);
|
||||||
|
if (!acc.deck) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const type = req.body.type;
|
||||||
|
const targetId = Number(req.body.target_id) || 0;
|
||||||
|
if (!['class', 'user'].includes(type) || !targetId)
|
||||||
|
return res.status(400).json({ error: 'type (class|user) и target_id обязательны' });
|
||||||
|
if (!ownsTarget(req.user, type, targetId))
|
||||||
|
return res.status(403).json({ error: type === 'class' ? 'Это не ваш класс' : 'Этот ученик не в ваших классах' });
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO flashcard_deck_access (deck_id, type, target_id) VALUES (?,?,?)`)
|
||||||
|
.run(acc.deck.id, type, targetId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/flashcards/decks/:id/share?type=&target_id= ── */
|
||||||
|
function removeShare(req, res) {
|
||||||
|
const acc = deckAccess(req.params.id, req.user);
|
||||||
|
if (!acc.deck) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const type = req.query.type;
|
||||||
|
const targetId = Number(req.query.target_id) || 0;
|
||||||
|
if (!['class', 'user'].includes(type) || !targetId)
|
||||||
|
return res.status(400).json({ error: 'type и target_id обязательны' });
|
||||||
|
db.prepare(`DELETE FROM flashcard_deck_access WHERE deck_id = ? AND type = ? AND target_id = ?`)
|
||||||
|
.run(acc.deck.id, type, targetId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listDecks, createDeck, updateDeck, deleteDeck,
|
listDecks, createDeck, updateDeck, deleteDeck,
|
||||||
getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards,
|
getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards,
|
||||||
getStudySession, submitReview, getStats,
|
getStudySession, submitReview, getStats,
|
||||||
quickAdd, getRandom, uploadImage,
|
quickAdd, getRandom, uploadImage,
|
||||||
|
listShares, addShare, removeShare,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,69 @@
|
|||||||
/* Student-owned personal materials ("Мои материалы").
|
/* Student-owned personal materials ("Мои материалы").
|
||||||
* A user keeps copies of items saved from live lessons; the copies are
|
* A user keeps copies of items saved from live lessons; the copies are
|
||||||
* independent of the session lifecycle. */
|
* independent of the session lifecycle. */
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { emit } = require('../sse');
|
const { emit } = require('../sse');
|
||||||
|
|
||||||
const KINDS = ['board', 'note', 'link', 'image'];
|
const KINDS = ['board', 'note', 'link', 'image'];
|
||||||
|
|
||||||
|
// Personal uploads live here (mirrors fileController MATERIALS_DIR). Used for
|
||||||
|
// reference-counted file cleanup when the material(s) pointing at a file go away.
|
||||||
|
const MATERIALS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'materials');
|
||||||
|
|
||||||
|
// Soft per-user cap on the number of materials. Read at call time so tests can
|
||||||
|
// lower it via env. Byte quota is enforced separately at the upload endpoint.
|
||||||
|
function maxItems() { return Number(process.env.MATERIALS_MAX_ITEMS) || 2000; }
|
||||||
|
|
||||||
|
// Storable URLs are app-relative ("/…", not protocol-relative "//host") or
|
||||||
|
// http(s). Everything else (javascript:, data:, mailto:, …) is rejected: a saved
|
||||||
|
// link is rendered as <a href> on the owner's page AND can be handed out to a
|
||||||
|
// whole class via /share, so a bad scheme would be stored XSS.
|
||||||
|
// Returns the (length-capped) url, '' for empty, or undefined when invalid.
|
||||||
|
function safeUrl(raw) {
|
||||||
|
const u = String(raw == null ? '' : raw).trim();
|
||||||
|
if (!u) return '';
|
||||||
|
if (/^https?:\/\//i.test(u)) return u.slice(0, 2000);
|
||||||
|
if (u[0] === '/' && u[1] !== '/') return u.slice(0, 2000);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size on disk of a local materials file (0 if absent / non-local).
|
||||||
|
function fileBytes(u) {
|
||||||
|
if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0;
|
||||||
|
try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes attributed to a material for quota accounting (server-measured): for
|
||||||
|
// image/board it's the full file + its thumbnail.
|
||||||
|
function measureBytes(kind, url, body, thumbUrl) {
|
||||||
|
if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8');
|
||||||
|
if (kind === 'image' || kind === 'board') return fileBytes(url) + fileBytes(thumbUrl);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference-counted cleanup: unlink the file backing `url` only when NO material
|
||||||
|
// row references it any more — as either its `url` OR its `thumb_url` (share/
|
||||||
|
// annotate can alias one physical file across rows and columns). Call AFTER the
|
||||||
|
// delete/url-update so the freed row no longer counts. Exported for tests.
|
||||||
|
function releaseFileForUrl(url) {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return;
|
||||||
|
if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? OR thumb_url = ? LIMIT 1').get(url, url)) return;
|
||||||
|
const fp = path.join(MATERIALS_DIR, path.basename(url));
|
||||||
|
if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } }
|
||||||
|
}
|
||||||
|
|
||||||
/* GET /api/materials — list the current user's saved materials + their collections */
|
/* GET /api/materials — list the current user's saved materials + their collections */
|
||||||
function list(req, res) {
|
function list(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
|
// Return only a body PREVIEW (first 1000 chars) to keep the payload small for
|
||||||
|
// note-heavy users; the full text is fetched on demand via GET /:id. body_trunc
|
||||||
|
// tells the client a lazy fetch is needed before viewing/editing the note.
|
||||||
const materials = db.prepare(`
|
const materials = db.prepare(`
|
||||||
SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at
|
SELECT id, kind, title, substr(body, 1, 1000) AS body,
|
||||||
|
(CASE WHEN body IS NOT NULL AND length(body) > 1000 THEN 1 ELSE 0 END) AS body_trunc,
|
||||||
|
url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
|
||||||
FROM student_materials
|
FROM student_materials
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
@@ -26,6 +79,19 @@ function list(req, res) {
|
|||||||
res.json({ materials, collections });
|
res.json({ materials, collections });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GET /api/materials/:id — one material with its FULL body (lazy-loaded by the
|
||||||
|
client when a note's preview was truncated). Owner-only. */
|
||||||
|
function getOne(req, res) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
|
||||||
|
FROM student_materials WHERE id = ?
|
||||||
|
`).get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
|
delete row.user_id;
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
|
|
||||||
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
||||||
function ownCollectionId(raw, uid) {
|
function ownCollectionId(raw, uid) {
|
||||||
if (raw === null || raw === '' || raw === undefined) return null;
|
if (raw === null || raw === '' || raw === undefined) return null;
|
||||||
@@ -42,9 +108,21 @@ function create(req, res) {
|
|||||||
const kind = String(b.kind || '');
|
const kind = String(b.kind || '');
|
||||||
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
|
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
|
||||||
|
|
||||||
|
if (db.prepare('SELECT COUNT(*) AS n FROM student_materials WHERE user_id = ?').get(req.user.id).n >= maxItems())
|
||||||
|
return res.status(413).json({ error: 'Достигнут лимит числа материалов' });
|
||||||
|
|
||||||
const title = String(b.title || '').slice(0, 300);
|
const title = String(b.title || '').slice(0, 300);
|
||||||
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
|
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
|
||||||
const url = b.url != null ? String(b.url).slice(0, 2000) : null;
|
let url = null;
|
||||||
|
if (b.url != null && b.url !== '') {
|
||||||
|
url = safeUrl(b.url);
|
||||||
|
if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' });
|
||||||
|
}
|
||||||
|
let thumbUrl = null;
|
||||||
|
if (b.thumbUrl != null && b.thumbUrl !== '') {
|
||||||
|
thumbUrl = safeUrl(b.thumbUrl);
|
||||||
|
if (thumbUrl === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' });
|
||||||
|
}
|
||||||
if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' });
|
if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' });
|
||||||
if ((kind === 'board' || kind === 'image' || kind === 'link') && !url)
|
if ((kind === 'board' || kind === 'image' || kind === 'link') && !url)
|
||||||
return res.status(400).json({ error: 'url required' });
|
return res.status(400).json({ error: 'url required' });
|
||||||
@@ -58,38 +136,56 @@ function create(req, res) {
|
|||||||
const collectionId = ownCollectionId(b.collection_id, req.user.id);
|
const collectionId = ownCollectionId(b.collection_id, req.user.id);
|
||||||
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null;
|
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null;
|
||||||
|
|
||||||
|
const bytes = measureBytes(kind, url, body, thumbUrl);
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags)
|
INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags);
|
`).run(req.user.id, kind, title, body, url, thumbUrl, sourceSessionId, sourceTitle, collectionId, tags, bytes);
|
||||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PATCH /api/materials/:id — rename / edit one of the current user's items.
|
/* PATCH /api/materials/:id — rename / edit one of the current user's items.
|
||||||
Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */
|
Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */
|
||||||
function update(req, res) {
|
function update(req, res) {
|
||||||
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'not found' });
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
const fields = [], args = [];
|
const fields = [], args = [];
|
||||||
if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); }
|
if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); }
|
||||||
if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); }
|
if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); }
|
||||||
if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); }
|
if (b.url !== undefined) {
|
||||||
|
let nu = null;
|
||||||
|
if (b.url != null && b.url !== '') { nu = safeUrl(b.url); if (nu === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); }
|
||||||
|
fields.push('url = ?'); args.push(nu);
|
||||||
|
}
|
||||||
|
if (b.thumbUrl !== undefined) {
|
||||||
|
let nt = null;
|
||||||
|
if (b.thumbUrl != null && b.thumbUrl !== '') { nt = safeUrl(b.thumbUrl); if (nt === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); }
|
||||||
|
fields.push('thumb_url = ?'); args.push(nt);
|
||||||
|
}
|
||||||
if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); }
|
if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); }
|
||||||
if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); }
|
if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); }
|
||||||
if (!fields.length) return res.json({ ok: true });
|
if (!fields.length) return res.json({ ok: true });
|
||||||
args.push(req.params.id);
|
args.push(req.params.id);
|
||||||
db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
||||||
|
// Recompute quota bytes from the persisted row; free the old file(s) if the url
|
||||||
|
// or thumbnail changed (annotate overwrites both) and nothing else references them.
|
||||||
|
const cur = db.prepare('SELECT kind, url, thumb_url, body FROM student_materials WHERE id = ?').get(req.params.id);
|
||||||
|
db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body, cur.thumb_url), req.params.id);
|
||||||
|
if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url);
|
||||||
|
if (b.thumbUrl !== undefined && row.thumb_url && row.thumb_url !== cur.thumb_url) releaseFileForUrl(row.thumb_url);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DELETE /api/materials/:id — remove one of the current user's items */
|
/* DELETE /api/materials/:id — remove one of the current user's items */
|
||||||
function remove(req, res) {
|
function remove(req, res) {
|
||||||
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'not found' });
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id);
|
||||||
|
releaseFileForUrl(row.url); // unlink the full image if no other material aliases it
|
||||||
|
releaseFileForUrl(row.thumb_url); // …and its thumbnail
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +231,7 @@ function deleteCollection(req, res) {
|
|||||||
a student. Each recipient gets an independent COPY (survives later edits/
|
a student. Each recipient gets an independent COPY (survives later edits/
|
||||||
deletes by the teacher). Body: { classId } | { userId }. */
|
deletes by the teacher). Body: { classId } | { userId }. */
|
||||||
function share(req, res) {
|
function share(req, res) {
|
||||||
const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id);
|
const mat = db.prepare('SELECT user_id, kind, title, body, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||||
if (!mat) return res.status(404).json({ error: 'not found' });
|
if (!mat) return res.status(404).json({ error: 'not found' });
|
||||||
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||||
|
|
||||||
@@ -164,12 +260,13 @@ function share(req, res) {
|
|||||||
|
|
||||||
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||||
const srcTitle = 'Раздатка: ' + teacherName;
|
const srcTitle = 'Раздатка: ' + teacherName;
|
||||||
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) VALUES (?,?,?,?,?,NULL,?)`);
|
const bytes = measureBytes(mat.kind, mat.url, mat.body, mat.thumb_url); // each copy counts toward the recipient's quota
|
||||||
|
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,?,NULL,?,?)`);
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const uid of recipients) {
|
for (const uid of recipients) {
|
||||||
if (!uid || uid === req.user.id) continue;
|
if (!uid || uid === req.user.id) continue;
|
||||||
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle);
|
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, mat.thumb_url, srcTitle, bytes);
|
||||||
try {
|
try {
|
||||||
emit(uid, { type: 'notification', notif_type: 'material_shared',
|
emit(uid, { type: 'notification', notif_type: 'material_shared',
|
||||||
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
|
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
|
||||||
@@ -180,4 +277,6 @@ function share(req, res) {
|
|||||||
res.json({ ok: true, sent });
|
res.json({ ok: true, sent });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share };
|
module.exports = { list, getOne, create, update, remove, createCollection, updateCollection, deleteCollection, share,
|
||||||
|
// exported for tests / reuse
|
||||||
|
safeUrl, measureBytes, releaseFileForUrl };
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 071: Custom simulations (Конструктор симуляций / SimForge), Фаза 3.
|
||||||
|
--
|
||||||
|
-- Учитель/админ собирает интерактивную 2D-симуляцию из ДАННЫХ (JSON-спека:
|
||||||
|
-- params[], objects[], physics{}, …) и сохраняет её здесь. Спека хранится как
|
||||||
|
-- TEXT(JSON) в spec_json; её схема/лимиты валидируются на входе сервером
|
||||||
|
-- (validateSpec), БЕЗ исполнения. status='draft' видит только владелец;
|
||||||
|
-- status='published' — публичная (видна всем в каталоге /lab, Фаза 5).
|
||||||
|
--
|
||||||
|
-- owner_id ON DELETE CASCADE — спеки удаляются вместе с автором.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_sims (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
subject TEXT, -- курикулум (напр. 'physics')
|
||||||
|
grade INTEGER, -- класс
|
||||||
|
cat TEXT, -- категория каталога (math|phys|chem|bio|game)
|
||||||
|
spec_json TEXT NOT NULL, -- JSON-спека (данные, не код)
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||||
|
version INTEGER NOT NULL DEFAULT 1, -- ++ на каждом update
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_sims_owner ON custom_sims (owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_sims_status ON custom_sims (status);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 073: Storage accounting for «Мои материалы»
|
||||||
|
--
|
||||||
|
-- Per-material byte size, used to enforce a per-user storage quota and to free
|
||||||
|
-- orphaned files. Populated on create/update:
|
||||||
|
-- image|board → size of the file on disk (server-measured)
|
||||||
|
-- note → text length
|
||||||
|
-- link → 0
|
||||||
|
-- Existing rows default to 0 (the next edit recomputes them; quota is a soft cap).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE student_materials ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- 074_flashcard_learning_steps.sql
|
||||||
|
-- Tier-1 апгрейд интервального повторения: настоящие learning-steps (под-дневные
|
||||||
|
-- интервалы), повторный показ «Снова» в той же сессии и лимит новых карт в день.
|
||||||
|
--
|
||||||
|
-- flashcard_reviews получает состояние карточки:
|
||||||
|
-- state — 'review' (зрелая, день-интервалы SM-2) | 'learning' (новая в шагах)
|
||||||
|
-- | 'relearning' (зрелая после провала, снова в шагах).
|
||||||
|
-- Старые строки = 'review' (они уже были выпущены старым алгоритмом).
|
||||||
|
-- learning_step — индекс текущего шага обучения (0..N).
|
||||||
|
-- lapses — сколько раз зрелая карта проваливалась (для статистики).
|
||||||
|
-- created_at — когда карта впервые введена в оборот (для лимита новых/день).
|
||||||
|
-- Старым строкам ставим '' (date('')=NULL → не считаются «введёнными
|
||||||
|
-- сегодня»); новые строки контроллер заполняет datetime('now').
|
||||||
|
-- ВАЖНО: под-дневные интервалы живут в due_at (TEXT datetime с минутами), поэтому
|
||||||
|
-- interval_days остаётся INTEGER — менять тип не нужно.
|
||||||
|
--
|
||||||
|
-- flashcard_decks.new_per_day — сколько новых карт колоды показывать за день (деф. 20).
|
||||||
|
--
|
||||||
|
-- Примечание: ALTER TABLE ADD COLUMN в SQLite запрещает выражение-default
|
||||||
|
-- (datetime('now')), поэтому created_at = '' и проставляется кодом на INSERT.
|
||||||
|
|
||||||
|
ALTER TABLE flashcard_reviews ADD COLUMN state TEXT NOT NULL DEFAULT 'review';
|
||||||
|
ALTER TABLE flashcard_reviews ADD COLUMN learning_step INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE flashcard_reviews ADD COLUMN lapses INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE flashcard_reviews ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE flashcard_decks ADD COLUMN new_per_day INTEGER NOT NULL DEFAULT 20;
|
||||||
|
|
||||||
|
-- Индексы под горячие пути (getCards / getStudySession / listDecks-подсчёты).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fc_cards_deck ON flashcard_cards(deck_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fc_reviews_card ON flashcard_reviews(card_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 074: Thumbnail URL for image/board materials
|
||||||
|
--
|
||||||
|
-- Server-generated downscaled preview (sharp → webp, ≤480px) shown in the grid;
|
||||||
|
-- the full image is still used for viewing / annotating / download. NULL when no
|
||||||
|
-- thumb exists (generation failed, animated gif, or a non-uploaded url) — the
|
||||||
|
-- client falls back to the full `url`.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE student_materials ADD COLUMN thumb_url TEXT;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 075_flashcard_deck_access.sql
|
||||||
|
-- Общие колоды: учитель назначает свою колоду классу или конкретному ученику.
|
||||||
|
-- Карты остаются общими (одна копия), а прогресс у каждого свой — flashcard_reviews
|
||||||
|
-- уже keyed по UNIQUE(user_id, card_id), поэтому ученик копит собственные интервалы
|
||||||
|
-- на тех же картах. Колода остаётся во владении учителя (правка — только владелец).
|
||||||
|
--
|
||||||
|
-- Структура — зеркало folder_access (000_baseline): type='class' → target_id=class_id,
|
||||||
|
-- type='user' → target_id=user_id. Резолв класса ученика — через class_members.
|
||||||
|
|
||||||
|
CREATE TABLE flashcard_deck_access (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('class', 'user')),
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (deck_id, type, target_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fc_deck_access_target ON flashcard_deck_access(type, target_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fc_deck_access_deck ON flashcard_deck_access(deck_id);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
/* /api/custom-sims — CRUD спек-симуляций «Конструктора симуляций» (Фаза 3).
|
||||||
|
* Read-роуты — auth-only (видимость своих + published проверяет контроллер).
|
||||||
|
* Мутации — inline requireRole('teacher','admin') + per-row ownership в хендлере.
|
||||||
|
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const c = require('../controllers/customSimController');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.get('/', c.list);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||||
|
router.get('/:id', c.get);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||||
|
router.get('/:id/related', c.related);
|
||||||
|
|
||||||
|
router.post('/', requireRole('teacher', 'admin'), c.create);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.put('/:id', requireRole('teacher', 'admin'), c.update);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
|
||||||
|
|
||||||
|
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
|
||||||
|
// requireRole(teacher,admin) + per-row ownership в хендлере.
|
||||||
|
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||||
|
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -5,7 +5,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fc = require('../controllers/flashcardController');
|
const fc = require('../controllers/flashcardController');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
const { requireOwnership } = require('../middleware/ownership');
|
const { requireOwnership } = require('../middleware/ownership');
|
||||||
|
|
||||||
/* ── multer для картинок карточек ───────────────────────────────────────
|
/* ── multer для картинок карточек ───────────────────────────────────────
|
||||||
@@ -43,6 +43,10 @@ router.get ('/decks/:id/cards', fc.getCards);
|
|||||||
router.post ('/decks/:id/cards', fc.addCard);
|
router.post ('/decks/:id/cards', fc.addCard);
|
||||||
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
|
||||||
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
|
||||||
|
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
|
||||||
|
router.get ('/decks/:id/shares', fc.listShares);
|
||||||
|
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
|
||||||
|
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
|
||||||
router.get ('/decks/:id/study', fc.getStudySession);
|
router.get ('/decks/:id/study', fc.getStudySession);
|
||||||
router.put ('/cards/:id', fc.updateCard);
|
router.put ('/cards/:id', fc.updateCard);
|
||||||
router.delete('/cards/:id', fc.deleteCard);
|
router.delete('/cards/:id', fc.deleteCard);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ router.patch('/collections/:id', c.updateCollection);
|
|||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
router.delete('/collections/:id', c.deleteCollection);
|
router.delete('/collections/:id', c.deleteCollection);
|
||||||
|
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.get('/:id', c.getOne);
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
router.patch('/:id', c.update);
|
router.patch('/:id', c.update);
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ app.use('/api/access', accessRoutes);
|
|||||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||||
app.use('/api/lab', labRoutes);
|
app.use('/api/lab', labRoutes);
|
||||||
app.use('/api/materials', require('./routes/materials'));
|
app.use('/api/materials', require('./routes/materials'));
|
||||||
|
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
|
|
||||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: /api/custom-sims — раздача / клон / курикулумная привязка (Фаза 6).
|
||||||
|
* Covers:
|
||||||
|
* share — ученик класса получает ДОЛГОВЕЧНОЕ уведомление, sim авто-публикуется;
|
||||||
|
* раздача не своего класса / чужого draft → 403; неизвестный класс → 404.
|
||||||
|
* clone — новый владелец, status=draft, spec скопирован, title += « (копия)»;
|
||||||
|
* чужой published клонируется ОК; чужой draft → 403.
|
||||||
|
* links — владелец привязывает учебник; чужой draft → 403; published related ОК.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
|
||||||
|
app.use('/api/custom-sims', require('../src/routes/customSims'));
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
const VALID_SPEC = {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Маятник' },
|
||||||
|
viewport: { xmin: -5, xmax: 5, ymin: -5, ymax: 1 },
|
||||||
|
params: [{ name: 'L', label: 'Длина', min: 0.5, max: 3, step: 0.1, value: 1.5 }],
|
||||||
|
objects: [{ id: 'bob', type: 'circle', x: 'L*sin(t)', y: '-L*cos(t)', r: 0.2, color: '#9B5DE5' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* seedClass(teacherId, [studentIds]) → classId. Прямая вставка (seedRow-паттерн). */
|
||||||
|
function seedClass(teacherId, studentIds) {
|
||||||
|
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
|
||||||
|
const r = db.prepare(
|
||||||
|
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
|
||||||
|
).run('Класс ' + code, teacherId, code);
|
||||||
|
const classId = Number(r.lastInsertRowid);
|
||||||
|
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
|
||||||
|
for (const uid of studentIds) ins.run(classId, uid);
|
||||||
|
return classId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSim(token, overrides) {
|
||||||
|
const res = await inject('POST', '/api/custom-sims',
|
||||||
|
Object.assign({ title: 'Маятник', cat: 'phys', spec: VALID_SPEC }, overrides || {}), token);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('/api/custom-sims (Фаза 6: share / clone / links)', () => {
|
||||||
|
let teacher, otherTeacher, student, studentB, admin;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
teacher = await getToken('teacher');
|
||||||
|
otherTeacher = await getToken('teacher');
|
||||||
|
student = await getToken('student');
|
||||||
|
studentB = await getToken('student');
|
||||||
|
admin = await getToken('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── SHARE ──────────────────────────────────────────────────────────── */
|
||||||
|
describe('share', () => {
|
||||||
|
it('teacher shares a DRAFT sim to own class → 200, auto-publish, students notified', async () => {
|
||||||
|
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
|
||||||
|
const c = await createSim(teacher.token); // draft by default
|
||||||
|
assert.equal(c.status, 201);
|
||||||
|
const simId = c.body.id;
|
||||||
|
|
||||||
|
const before = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||||
|
assert.equal(before.status, 'draft', 'created as draft');
|
||||||
|
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
assert.equal(res.body.sent, 2, 'two students notified');
|
||||||
|
assert.equal(res.body.status, 'published', 'reports published');
|
||||||
|
|
||||||
|
// Авто-публикация в БД.
|
||||||
|
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||||
|
assert.equal(after.status, 'published', 'sim auto-published');
|
||||||
|
|
||||||
|
// Долговечное уведомление со ссылкой /lab?sim=custom:<id>.
|
||||||
|
const notif = db.prepare(
|
||||||
|
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'sim_shared' ORDER BY id DESC"
|
||||||
|
).get(student.userId);
|
||||||
|
assert.ok(notif, 'student has a sim_shared notification');
|
||||||
|
assert.equal(notif.link, '/lab?sim=custom:' + simId, 'notification links to the sim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share requires classId (400)', async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, {}, teacher.token);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share to unknown class → 404', async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId: 99999 }, teacher.token);
|
||||||
|
assert.equal(res.status, 404, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("share to a class that isn't yours → 403", async () => {
|
||||||
|
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||||
|
const c = await createSim(teacher.token); // teacher owns the sim
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, teacher.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-owner cannot share someone else's DRAFT (403)", async () => {
|
||||||
|
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||||
|
const c = await createSim(teacher.token); // owned by teacher, draft
|
||||||
|
// otherTeacher tries to share teacher's draft to otherTeacher's own class.
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, otherTeacher.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('student cannot share (role-gated 403)', async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const classId = seedClass(teacher.userId, [student.userId]);
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, student.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── CLONE ──────────────────────────────────────────────────────────── */
|
||||||
|
describe('clone', () => {
|
||||||
|
it('owner clones own sim → new draft owned by caller, spec copied, title += копия', async () => {
|
||||||
|
const c = await createSim(teacher.token, { title: 'Оригинал', subject: 'physics', grade: 9 });
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, teacher.token);
|
||||||
|
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
const newId = res.body.id;
|
||||||
|
assert.notEqual(newId, c.body.id, 'new row');
|
||||||
|
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${newId}`, null, teacher.token);
|
||||||
|
const s = get.body.sim;
|
||||||
|
assert.equal(s.owner_id, teacher.userId, 'caller owns the clone');
|
||||||
|
assert.equal(s.status, 'draft', 'clone is draft');
|
||||||
|
assert.equal(s.version, 1, 'clone version reset to 1');
|
||||||
|
assert.equal(s.title, 'Оригинал (копия)', 'title gets (копия)');
|
||||||
|
assert.equal(s.subject, 'physics');
|
||||||
|
assert.equal(s.grade, 9);
|
||||||
|
assert.equal(s.spec.objects.length, VALID_SPEC.objects.length, 'spec copied');
|
||||||
|
assert.equal(s.spec.objects[0].id, 'bob', 'spec content copied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher clones ANOTHER teacher PUBLISHED sim → 201 (now owned by cloner, draft)', async () => {
|
||||||
|
// teacher creates + publishes.
|
||||||
|
const c = await createSim(teacher.token, { status: 'published' });
|
||||||
|
assert.equal(c.status, 201);
|
||||||
|
const src = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(c.body.id);
|
||||||
|
assert.equal(src.status, 'published');
|
||||||
|
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||||
|
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, otherTeacher.token);
|
||||||
|
assert.equal(get.body.sim.owner_id, otherTeacher.userId);
|
||||||
|
assert.equal(get.body.sim.status, 'draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teacher CANNOT clone another teacher's DRAFT (403)", async () => {
|
||||||
|
const c = await createSim(teacher.token); // draft
|
||||||
|
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clone of unknown id → 404', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims/99999/clone', null, teacher.token);
|
||||||
|
assert.equal(res.status, 404, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── CURRICULUM LINKS (lab_sim_links, sim_id='custom:<id>') ──────────── */
|
||||||
|
describe('links', () => {
|
||||||
|
let bookSlug;
|
||||||
|
before(() => {
|
||||||
|
// Засеять учебник для привязки (textbooks.slug — ref_id для kind=textbook).
|
||||||
|
bookSlug = 'phys-test-' + Math.random().toString(36).slice(2, 7);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO textbooks (slug, title, subject, grade, html_path, is_active) VALUES (?, ?, ?, ?, ?, 1)'
|
||||||
|
).run(bookSlug, 'Физика тест', 'physics', 9, bookSlug + '.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner links own sim to a textbook, related lists it', async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||||
|
{ kind: 'textbook', ref_id: bookSlug }, teacher.token);
|
||||||
|
assert.equal(add.status, 200, `got ${add.status}: ${JSON.stringify(add.body)}`);
|
||||||
|
assert.equal(add.body.link.kind, 'textbook');
|
||||||
|
|
||||||
|
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||||
|
assert.equal(rel.status, 200);
|
||||||
|
assert.equal(rel.body.links.textbook.length, 1, 'one textbook link');
|
||||||
|
assert.equal(rel.body.links.textbook[0].ref_id, bookSlug);
|
||||||
|
|
||||||
|
// Удаление связи.
|
||||||
|
const linkId = add.body.link.id;
|
||||||
|
const del = await inject('DELETE', `/api/custom-sims/${c.body.id}/links/${linkId}`, null, teacher.token);
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
const rel2 = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||||
|
assert.equal(rel2.body.links.textbook.length, 0, 'link removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linking to unknown textbook → 404', async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||||
|
{ kind: 'textbook', ref_id: 'no-such-book' }, teacher.token);
|
||||||
|
assert.equal(add.status, 404, `got ${add.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-owner CANNOT add link to someone else's draft (403)", async () => {
|
||||||
|
const c = await createSim(teacher.token);
|
||||||
|
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||||
|
{ kind: 'textbook', ref_id: bookSlug }, otherTeacher.token);
|
||||||
|
assert.equal(add.status, 403, `got ${add.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('related on a published sim is readable by any user (student)', async () => {
|
||||||
|
const c = await createSim(teacher.token, { status: 'published' });
|
||||||
|
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, student.token);
|
||||||
|
assert.equal(rel.status, 200, `got ${rel.status}`);
|
||||||
|
assert.ok(rel.body.links, 'links object present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("related on someone else's draft → 403 for non-owner", async () => {
|
||||||
|
const c = await createSim(teacher.token); // draft
|
||||||
|
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, otherTeacher.token);
|
||||||
|
assert.equal(rel.status, 403, `got ${rel.status}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: /api/custom-sims — CRUD спек-симуляций (Фаза 3).
|
||||||
|
* Covers: auth, role-gating (POST teacher/admin), CRUD happy-path, ownership
|
||||||
|
* (чужой PUT/DELETE → 403), видимость (own draft / others published), serverная
|
||||||
|
* валидация спеки (кривая/огромная → 400), version-bump на update.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// Mount /api/custom-sims on the shared test app (setup.js не монтирует его).
|
||||||
|
app.use('/api/custom-sims', require('../src/routes/customSims'));
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
// Минимальная валидная спека (формат v1).
|
||||||
|
const VALID_SPEC = {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Бросок' },
|
||||||
|
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
|
||||||
|
params: [{ name: 'v', label: 'Скорость', min: 0, max: 30, step: 0.5, value: 18, unit: 'м/с' }],
|
||||||
|
objects: [
|
||||||
|
{ id: 'p', type: 'point', x: 'v*t', y: '-4.9*t*t', r: 6, color: '#06D6E0' },
|
||||||
|
{ type: 'segment', x1: 0, y1: 0, x2: 'p.x', y2: 'p.y', color: '#fff', width: 2 },
|
||||||
|
],
|
||||||
|
physics: { enabled: true, gravity: { x: 0, y: -9.8 }, restitution: 0.9, dt: 1 / 240 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('/api/custom-sims', () => {
|
||||||
|
let teacherToken, teacherId, otherTeacherToken, studentToken;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const t = await getToken('teacher');
|
||||||
|
teacherToken = t.token; teacherId = t.userId;
|
||||||
|
otherTeacherToken = (await getToken('teacher')).token;
|
||||||
|
studentToken = (await getToken('student')).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/custom-sims requires auth (401 without token)', async () => {
|
||||||
|
const res = await inject('GET', '/api/custom-sims', null, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST is role-gated: student → 403', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: VALID_SPEC }, studentToken);
|
||||||
|
assert.equal(res.status, 403, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let simId;
|
||||||
|
it('teacher can create a sim (201) with valid spec', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims',
|
||||||
|
{ title: 'Бросок тела', subject: 'physics', grade: 9, cat: 'phys', spec: VALID_SPEC }, teacherToken);
|
||||||
|
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
assert.ok(Number.isFinite(res.body.id), 'returns numeric id');
|
||||||
|
simId = res.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /:id returns own sim with parsed spec + metadata + version 1', async () => {
|
||||||
|
const res = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
const s = res.body.sim;
|
||||||
|
assert.equal(s.id, simId);
|
||||||
|
assert.equal(s.owner_id, teacherId);
|
||||||
|
assert.equal(s.title, 'Бросок тела');
|
||||||
|
assert.equal(s.subject, 'physics');
|
||||||
|
assert.equal(s.grade, 9);
|
||||||
|
assert.equal(s.cat, 'phys');
|
||||||
|
assert.equal(s.status, 'draft');
|
||||||
|
assert.equal(s.version, 1);
|
||||||
|
assert.equal(s.spec.specVersion, 1);
|
||||||
|
assert.equal(s.spec.objects.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET list returns own draft', async () => {
|
||||||
|
const res = await inject('GET', '/api/custom-sims', null, teacherToken);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
assert.ok(Array.isArray(res.body.sims));
|
||||||
|
assert.ok(res.body.sims.find(s => s.id === simId), 'own draft present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("other teacher CANNOT see someone's draft in list, and GET draft → 403", async () => {
|
||||||
|
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||||||
|
assert.ok(!list.body.sims.find(s => s.id === simId), 'draft not in other user list');
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||||||
|
assert.equal(get.status, 403, `got ${get.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner PUT updates metadata + spec and bumps version', async () => {
|
||||||
|
const newSpec = { ...VALID_SPEC, meta: { title: 'Изменено' } };
|
||||||
|
const res = await inject('PUT', `/api/custom-sims/${simId}`,
|
||||||
|
{ title: 'Новое имя', status: 'published', spec: newSpec }, teacherToken);
|
||||||
|
assert.equal(res.status, 200, `got ${res.status}`);
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||||
|
assert.equal(get.body.sim.title, 'Новое имя');
|
||||||
|
assert.equal(get.body.sim.status, 'published');
|
||||||
|
assert.equal(get.body.sim.version, 2, 'version bumped');
|
||||||
|
assert.equal(get.body.sim.spec.meta.title, 'Изменено');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('published sim is visible to other users (list + GET)', async () => {
|
||||||
|
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||||||
|
assert.ok(list.body.sims.find(s => s.id === simId), 'published in other user list');
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${simId}`, null, studentToken);
|
||||||
|
assert.equal(get.status, 200, 'student can read published');
|
||||||
|
assert.equal(get.body.sim.id, simId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("other teacher CANNOT PUT/DELETE someone else's sim (403)", async () => {
|
||||||
|
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'хак' }, otherTeacherToken);
|
||||||
|
assert.equal(put.status, 403, `PUT got ${put.status}`);
|
||||||
|
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||||||
|
assert.equal(del.status, 403, `DELETE got ${del.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin can update/delete any sim', async () => {
|
||||||
|
const adminToken = (await getToken('admin')).token;
|
||||||
|
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'admin edit' }, adminToken);
|
||||||
|
assert.equal(put.status, 200, `admin PUT got ${put.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT/GET unknown id → 404', async () => {
|
||||||
|
assert.equal((await inject('GET', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||||||
|
assert.equal((await inject('PUT', '/api/custom-sims/999999', { title: 'x' }, teacherToken)).status, 404);
|
||||||
|
assert.equal((await inject('DELETE', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── validateSpec: отклонение кривых/огромных спек (400) ── */
|
||||||
|
it('rejects missing spec (400)', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { title: 'нет спеки' }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-object spec (400)', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: 'just a string' }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong specVersion (400)', async () => {
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, specVersion: 99 } }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disallowed object type (400)', async () => {
|
||||||
|
const bad = { ...VALID_SPEC, objects: [{ type: 'eval_me', x: 1, y: 1 }] };
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too many objects (400)', async () => {
|
||||||
|
const objs = Array.from({ length: 201 }, () => ({ type: 'point', x: 1, y: 1 }));
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, objects: objs } }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too many params (400)', async () => {
|
||||||
|
const ps = Array.from({ length: 51 }, (_, i) => ({ name: 'p' + i, min: 0, max: 1, value: 0 }));
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, params: ps } }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects over-long expression string (400)', async () => {
|
||||||
|
const bad = { ...VALID_SPEC, objects: [{ type: 'point', x: 'a+'.repeat(300) + '1', y: 0 }] };
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects physics.restitution out of range (400)', async () => {
|
||||||
|
const bad = { ...VALID_SPEC, physics: { enabled: true, restitution: 5 } };
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects body.mass <= 0 (400)', async () => {
|
||||||
|
const bad = { ...VALID_SPEC, objects: [{ type: 'circle', x: 0, y: 0, r: 1, body: { mass: 0 } }] };
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too many springs (400)', async () => {
|
||||||
|
const springs = Array.from({ length: 51 }, () => ({ a: [0, 0], b: [1, 1], k: 40, length: 1 }));
|
||||||
|
const res = await inject('POST', '/api/custom-sims',
|
||||||
|
{ spec: { ...VALID_SPEC, physics: { enabled: true, springs } } }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects huge spec (>200KB) (400)', async () => {
|
||||||
|
const huge = { ...VALID_SPEC, meta: { title: 'x', desc: 'a'.repeat(300000) } };
|
||||||
|
const res = await inject('POST', '/api/custom-sims', { spec: huge }, teacherToken);
|
||||||
|
assert.equal(res.status, 400, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes label/text fields (escapes angle brackets)', async () => {
|
||||||
|
const spec = {
|
||||||
|
...VALID_SPEC,
|
||||||
|
objects: [{ type: 'label', x: 0, y: 0, text: '<img src=x onerror=alert(1)>' }],
|
||||||
|
};
|
||||||
|
const create = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
|
||||||
|
assert.equal(create.status, 201, `got ${create.status}`);
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${create.body.id}`, null, teacherToken);
|
||||||
|
const txt = get.body.sim.spec.objects[0].text;
|
||||||
|
assert.ok(!txt.includes('<img'), 'angle brackets escaped');
|
||||||
|
assert.ok(txt.includes('<img'), 'escaped form present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner can DELETE own sim (then 404)', async () => {
|
||||||
|
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||||
|
assert.equal(del.status, 200, `got ${del.status}`);
|
||||||
|
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||||
|
assert.equal(get.status, 404, 'gone after delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: общие колоды флешкарт (учитель → класс/ученик).
|
||||||
|
* Covers: шаринг классу/ученику, видимость у назначенного (own+published),
|
||||||
|
* изучение и отзыв с личным прогрессом на общих картах, запрет правки чужой
|
||||||
|
* колоды (404), запрет доступа неназначенному (404), ролевой гейт share (403),
|
||||||
|
* запрет шарить в чужой класс (403), снятие доступа.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
app.use('/api/flashcards', require('../src/routes/flashcards'));
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
let _cc = 0;
|
||||||
|
function mkClass(teacherId) {
|
||||||
|
const code = 'T' + (++_cc) + Math.floor(performance.now() % 100000);
|
||||||
|
const r = db.prepare(`INSERT INTO classes (name, teacher_id, invite_code) VALUES (?,?,?)`)
|
||||||
|
.run('Класс ' + _cc, teacherId, code);
|
||||||
|
return r.lastInsertRowid;
|
||||||
|
}
|
||||||
|
function enroll(classId, userId) {
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO class_members (class_id, user_id) VALUES (?,?)`).run(classId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('flashcards — общие колоды (sharing)', () => {
|
||||||
|
let teacher, otherTeacher, stuIn, stuOut;
|
||||||
|
let classId, deckId, cardId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
teacher = await getToken('teacher');
|
||||||
|
otherTeacher = await getToken('teacher');
|
||||||
|
stuIn = await getToken('student');
|
||||||
|
stuOut = await getToken('student');
|
||||||
|
classId = mkClass(teacher.userId);
|
||||||
|
enroll(classId, stuIn.userId);
|
||||||
|
|
||||||
|
// учитель создаёт колоду с карточкой
|
||||||
|
const d = await inject('POST', '/api/flashcards/decks', { title: 'Биология' }, teacher.token);
|
||||||
|
deckId = d.body.id;
|
||||||
|
const c = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'Клетка?', back: 'Единица жизни' }, teacher.token);
|
||||||
|
cardId = c.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('student cannot share (роль-гейт 403)', async () => {
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, stuIn.token);
|
||||||
|
assert.equal(r.status, 403, `got ${r.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher cannot share to a class he does not own (403)', async () => {
|
||||||
|
const foreignClass = mkClass(otherTeacher.userId);
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: foreignClass }, teacher.token);
|
||||||
|
assert.equal(r.status, 403, `got ${r.status}: ${JSON.stringify(r.body)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not-shared: ученик не в классе не видит колоду и не имеет доступа', async () => {
|
||||||
|
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
|
||||||
|
assert.ok(!list.body.decks.some(d => d.id === deckId), 'пока не расшарена — не видна');
|
||||||
|
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/cards`, null, stuIn.token)).status, 404);
|
||||||
|
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher shares deck to class', async () => {
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, teacher.token);
|
||||||
|
assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('назначенный ученик видит колоду как shared (read-only)', async () => {
|
||||||
|
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
|
||||||
|
const d = list.body.decks.find(x => x.id === deckId);
|
||||||
|
assert.ok(d, 'колода видна ученику');
|
||||||
|
assert.equal(d.shared, 1);
|
||||||
|
assert.equal(d.can_edit, 0);
|
||||||
|
assert.equal(d.owner_name, teacher.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученик НЕ в классе по-прежнему не видит', async () => {
|
||||||
|
const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
|
||||||
|
assert.ok(!list.body.decks.some(d => d.id === deckId));
|
||||||
|
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuOut.token)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученик может изучать и ставить отзыв (свой прогресс)', async () => {
|
||||||
|
const s = await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token);
|
||||||
|
assert.equal(s.status, 200);
|
||||||
|
assert.ok(s.body.cards.some(c => c.id === cardId), 'карта в сессии ученика');
|
||||||
|
const rev = await inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality: 4 }, stuIn.token);
|
||||||
|
assert.equal(rev.status, 200, `review: ${rev.status}`);
|
||||||
|
// прогресс ученика — отдельная строка от учителя
|
||||||
|
const row = db.prepare('SELECT COUNT(*) AS n FROM flashcard_reviews WHERE card_id=? AND user_id=?').get(cardId, stuIn.userId);
|
||||||
|
assert.equal(row.n, 1, 'у ученика свой отзыв');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученик НЕ может править общую колоду (404)', async () => {
|
||||||
|
assert.equal((await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'x', back: 'y' }, stuIn.token)).status, 404);
|
||||||
|
assert.equal((await inject('PUT', `/api/flashcards/decks/${deckId}`, { title: 'Взлом' }, stuIn.token)).status, 404);
|
||||||
|
assert.equal((await inject('PUT', `/api/flashcards/cards/${cardId}`, { front: 'Взлом' }, stuIn.token)).status, 404);
|
||||||
|
assert.equal((await inject('DELETE', `/api/flashcards/cards/${cardId}`, null, stuIn.token)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученик не может расшаривать чужую колоду (роль/доступ)', async () => {
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, stuIn.token);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher shares to a specific user (вне класса)', async () => {
|
||||||
|
// делаем stuOut персональным учеником, чтобы прошла валидация цели
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO teacher_students (teacher_id, student_id) VALUES (?,?)`)
|
||||||
|
.run(teacher.userId, stuOut.userId);
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, teacher.token);
|
||||||
|
assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`);
|
||||||
|
const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
|
||||||
|
assert.ok(list.body.decks.some(d => d.id === deckId), 'stuOut теперь видит');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher lists shares (2: class + user)', async () => {
|
||||||
|
const r = await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, teacher.token);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.shares.length, 2);
|
||||||
|
assert.ok(r.body.shares.some(s => s.type === 'class' && s.target_id === classId));
|
||||||
|
assert.ok(r.body.shares.some(s => s.type === 'user' && s.target_id === stuOut.userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ученик не может смотреть список share (403)', async () => {
|
||||||
|
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, stuIn.token)).status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher unshares class → ученик в классе теряет доступ', async () => {
|
||||||
|
const r = await inject('DELETE', `/api/flashcards/decks/${deckId}/share?type=class&target_id=${classId}`, null, teacher.token);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
|
||||||
|
assert.ok(!list.body.decks.some(d => d.id === deckId), 'после снятия — не видит');
|
||||||
|
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404);
|
||||||
|
// прямой share для stuOut остаётся
|
||||||
|
const listOut = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
|
||||||
|
assert.ok(listOut.body.decks.some(d => d.id === deckId), 'stuOut доступ сохранён');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: интервальное повторение флешкарт (Tier-1 апгрейд).
|
||||||
|
* Covers: learning-steps (новая карта проходит шаги в минутах), выпуск в review
|
||||||
|
* через «Знаю»/«Легко», lapse зрелой карты → relearning, флаг graduated для
|
||||||
|
* клиентского re-queue, и лимит новых карт/день в study-сессии.
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// Маршрут флешкарт setup.js не монтирует (как и custom-sims) — монтируем сами.
|
||||||
|
app.use('/api/flashcards', require('../src/routes/flashcards'));
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
/* helpers */
|
||||||
|
async function mkDeck(token, title = 'Колода') {
|
||||||
|
const r = await inject('POST', '/api/flashcards/decks', { title }, token);
|
||||||
|
assert.equal(r.status, 200, `deck create: ${r.status} ${JSON.stringify(r.body)}`);
|
||||||
|
return r.body.id;
|
||||||
|
}
|
||||||
|
async function mkCard(token, deckId, front = 'Q', back = 'A') {
|
||||||
|
const r = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front, back }, token);
|
||||||
|
assert.equal(r.status, 200, `card create: ${r.status}`);
|
||||||
|
return r.body.id;
|
||||||
|
}
|
||||||
|
function review(token, cardId, quality) {
|
||||||
|
return inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('flashcards SRS — learning steps & limits', () => {
|
||||||
|
let token;
|
||||||
|
before(async () => { token = (await getToken('student')).token; });
|
||||||
|
|
||||||
|
it('review validates quality range (0..5)', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
assert.equal((await review(token, card, 7)).status, 400);
|
||||||
|
assert.equal((await review(token, card, -1)).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new card + «Снова» (q0) → learning, due через 1 минуту, не выпущена', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
const r = await review(token, card, 0);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.graduated, false, 'не выпущена');
|
||||||
|
assert.equal(r.body.due_in_sec, 60, '1 минута');
|
||||||
|
assert.equal(r.body.next.state, 'learning');
|
||||||
|
assert.equal(r.body.next.learning_step, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new card + «Знаю» (q4) → второй шаг 10 минут, всё ещё learning', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
const r = await review(token, card, 4);
|
||||||
|
assert.equal(r.body.graduated, false);
|
||||||
|
assert.equal(r.body.due_in_sec, 600, '10 минут');
|
||||||
|
assert.equal(r.body.next.state, 'learning');
|
||||||
|
assert.equal(r.body.next.learning_step, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two «Знаю» подряд выпускают карту в review (interval 1 день)', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
await review(token, card, 4); // шаг 0 → 1
|
||||||
|
const r = await review(token, card, 4); // шаг 1 → выпуск
|
||||||
|
assert.equal(r.body.graduated, true, 'выпущена в review');
|
||||||
|
assert.equal(r.body.next.state, 'review');
|
||||||
|
assert.equal(r.body.interval_days, 1);
|
||||||
|
assert.equal(r.body.due_in_sec, 86400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«Легко» (q5) на новой карте выпускает сразу (interval 4 дня)', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
const r = await review(token, card, 5);
|
||||||
|
assert.equal(r.body.graduated, true);
|
||||||
|
assert.equal(r.body.next.state, 'review');
|
||||||
|
assert.equal(r.body.interval_days, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('зрелая карта + «Снова» → lapse: relearning, lapses=1, due 10 минут', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
await review(token, card, 5); // сразу в review (iv=4)
|
||||||
|
const r = await review(token, card, 0); // провал
|
||||||
|
assert.equal(r.body.graduated, false, 'ушла на переучивание');
|
||||||
|
assert.equal(r.body.next.state, 'relearning');
|
||||||
|
assert.equal(r.body.due_in_sec, 600, 'relearn-шаг 10 минут');
|
||||||
|
// lapses фиксируется в БД
|
||||||
|
const row = db.prepare('SELECT lapses FROM flashcard_reviews WHERE card_id=?').get(card);
|
||||||
|
assert.equal(row.lapses, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('зрелая карта + «Знаю» растёт по дням (≥ предыдущего интервала)', async () => {
|
||||||
|
const deck = await mkDeck(token);
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
await review(token, card, 5); // iv=4, review
|
||||||
|
const r = await review(token, card, 4); // Знаю на зрелой
|
||||||
|
assert.equal(r.body.next.state, 'review');
|
||||||
|
assert.ok(r.body.interval_days > 4, `интервал вырос: ${r.body.interval_days}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('study-сессия уважает лимит новых карт/день (new_per_day)', async () => {
|
||||||
|
const deck = await mkDeck(token, 'Большая');
|
||||||
|
db.prepare('UPDATE flashcard_decks SET new_per_day=? WHERE id=?').run(2, deck);
|
||||||
|
for (let i = 0; i < 5; i++) await mkCard(token, deck, 'Q' + i, 'A' + i);
|
||||||
|
|
||||||
|
const s1 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
|
||||||
|
assert.equal(s1.status, 200);
|
||||||
|
assert.equal(s1.body.cards.length, 2, 'не больше лимита новых');
|
||||||
|
assert.ok(s1.body.cards.every(c => c.state === 'new'), 'все — новые');
|
||||||
|
|
||||||
|
// «вводим» обе карты (review) — бюджет на сегодня исчерпан
|
||||||
|
for (const c of s1.body.cards) await review(token, c.id, 4);
|
||||||
|
const s2 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
|
||||||
|
// введённые карты теперь learning (due через минуты, не сейчас) → сессия пуста
|
||||||
|
assert.equal(s2.body.cards.length, 0, 'бюджет новых исчерпан, learning ещё не due');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('study-сессия возвращает state/learning_step для превью кнопок', async () => {
|
||||||
|
const deck = await mkDeck(token, 'Превью');
|
||||||
|
const card = await mkCard(token, deck);
|
||||||
|
const s = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
|
||||||
|
const c = s.body.cards.find(x => x.id === card);
|
||||||
|
assert.ok(c, 'карта в сессии');
|
||||||
|
assert.equal(c.state, 'new');
|
||||||
|
assert.equal(c.learning_step, 0);
|
||||||
|
assert.equal(c.seen, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: /api/materials — «Мои материалы» (v2 hardening).
|
||||||
|
* Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404),
|
||||||
|
* collections (create / move / delete keeps material), share-копия (роль + owner
|
||||||
|
* + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов,
|
||||||
|
* и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле).
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// setup.js не монтирует /api/materials — монтируем на общий тест-app.
|
||||||
|
app.use('/api/materials', require('../src/routes/materials'));
|
||||||
|
const ctrl = require('../src/controllers/studentMaterialsController');
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
describe('/api/materials', () => {
|
||||||
|
let studentToken, studentId, otherToken, teacherToken, teacherId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const s = await getToken('student'); studentToken = s.token; studentId = s.userId;
|
||||||
|
otherToken = (await getToken('student')).token;
|
||||||
|
const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET requires auth (401 without token)', async () => {
|
||||||
|
const res = await inject('GET', '/api/materials', null, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let noteId;
|
||||||
|
it('student can create a note (201) and list it back', async () => {
|
||||||
|
const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken);
|
||||||
|
assert.equal(c.status, 201, JSON.stringify(c.body));
|
||||||
|
noteId = c.body.id;
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.status, 200);
|
||||||
|
assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts http(s) and app-relative link urls', async () => {
|
||||||
|
const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken);
|
||||||
|
assert.equal(a.status, 201, JSON.stringify(a.body));
|
||||||
|
const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken);
|
||||||
|
assert.equal(b.status, 201, JSON.stringify(b.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => {
|
||||||
|
const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a protocol-relative url (400)', async () => {
|
||||||
|
const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH cannot smuggle a javascript: url (400)', async () => {
|
||||||
|
const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner can rename; others get 403; missing → 404', async () => {
|
||||||
|
const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken);
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => {
|
||||||
|
const big = 'x'.repeat(1500);
|
||||||
|
const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken);
|
||||||
|
const id = c.body.id;
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
const row = l.body.materials.find(m => m.id === id);
|
||||||
|
assert.equal(row.body.length, 1000, 'preview trimmed');
|
||||||
|
assert.equal(row.body_trunc, 1, 'truncation flagged');
|
||||||
|
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
|
||||||
|
assert.equal(one.status, 200);
|
||||||
|
assert.equal(one.body.body.length, 1500, 'full body returned');
|
||||||
|
assert.equal(one.body.user_id, undefined, 'user_id not leaked');
|
||||||
|
const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const missing = await inject('GET', '/api/materials/999999', null, studentToken);
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collections: create, move material in, delete keeps material (uncategorised)', async () => {
|
||||||
|
const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken);
|
||||||
|
assert.equal(col.status, 201, JSON.stringify(col.body));
|
||||||
|
const cid = col.body.id;
|
||||||
|
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken);
|
||||||
|
assert.equal(mv.status, 200);
|
||||||
|
let l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid);
|
||||||
|
assert.equal(l.body.collections.find(c => c.id === cid).count, 1);
|
||||||
|
const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken);
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving into another user\'s collection is ignored (collection_id stays null)', async () => {
|
||||||
|
const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken);
|
||||||
|
const foreignCid = col.body.id;
|
||||||
|
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken);
|
||||||
|
assert.equal(mv.status, 200);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share: student is role-gated (403)', async () => {
|
||||||
|
const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken);
|
||||||
|
assert.equal(res.status, 403, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share: teacher → linked student copies the material; unlinked → 403', async () => {
|
||||||
|
const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken);
|
||||||
|
const tId = tNote.body.id;
|
||||||
|
// not linked yet
|
||||||
|
const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||||
|
assert.equal(denied.status, 403, JSON.stringify(denied.body));
|
||||||
|
// link teacher → student, then share
|
||||||
|
db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId);
|
||||||
|
const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||||
|
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||||
|
assert.equal(ok.body.sent, 1);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the per-user item cap (413)', async () => {
|
||||||
|
const q = await getToken('student');
|
||||||
|
const prev = process.env.MATERIALS_MAX_ITEMS;
|
||||||
|
process.env.MATERIALS_MAX_ITEMS = '3';
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token);
|
||||||
|
assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`);
|
||||||
|
}
|
||||||
|
const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token);
|
||||||
|
assert.equal(over.status, 413, JSON.stringify(over.body));
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete removes the row; owner only', async () => {
|
||||||
|
const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken);
|
||||||
|
const id = m.body.id;
|
||||||
|
const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.ok(!l.body.materials.some(x => x.id === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releaseFileForUrl: unlinks the file only when no material references it', () => {
|
||||||
|
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const fname = 'test_' + Date.now() + '.png';
|
||||||
|
const fpath = path.join(dir, fname);
|
||||||
|
const url = '/uploads/materials/' + fname;
|
||||||
|
fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)');
|
||||||
|
const r1 = ins.run(studentId, 'image', url).lastInsertRowid;
|
||||||
|
const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share)
|
||||||
|
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it');
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1);
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(fs.existsSync(fpath), 'file kept while one row still references it');
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2);
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safeUrl / measureBytes behave as documented', () => {
|
||||||
|
assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c');
|
||||||
|
assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x');
|
||||||
|
assert.equal(ctrl.safeUrl(''), '');
|
||||||
|
assert.equal(ctrl.safeUrl('javascript:x'), undefined);
|
||||||
|
assert.equal(ctrl.safeUrl('//host'), undefined);
|
||||||
|
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
|
||||||
|
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => {
|
||||||
|
const ok = await inject('POST', '/api/materials',
|
||||||
|
{ kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken);
|
||||||
|
assert.equal(ok.status, 201, JSON.stringify(ok.body));
|
||||||
|
const id = ok.body.id;
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp');
|
||||||
|
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
|
||||||
|
assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp');
|
||||||
|
const bad = await inject('POST', '/api/materials',
|
||||||
|
{ kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken);
|
||||||
|
assert.equal(bad.status, 400, JSON.stringify(bad.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => {
|
||||||
|
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const fname = 'th_' + Date.now() + '.webp';
|
||||||
|
const fpath = path.join(dir, fname);
|
||||||
|
const url = '/uploads/materials/' + fname;
|
||||||
|
fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46]));
|
||||||
|
const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid;
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url');
|
||||||
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid);
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE removes the material\'s full image AND thumbnail files', async () => {
|
||||||
|
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const base = 'del_' + Date.now();
|
||||||
|
const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp');
|
||||||
|
fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46]));
|
||||||
|
const c = await inject('POST', '/api/materials',
|
||||||
|
{ kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken);
|
||||||
|
assert.equal(c.status, 201);
|
||||||
|
const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken);
|
||||||
|
assert.equal(d.status, 200);
|
||||||
|
assert.ok(!fs.existsSync(fFull), 'full image unlinked');
|
||||||
|
assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked');
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-5
@@ -7056,14 +7056,32 @@
|
|||||||
const CAT_LABELS = { math:'Математика', phys:'Физика', chem:'Химия', bio:'Биология', game:'Игра' };
|
const CAT_LABELS = { math:'Математика', phys:'Физика', chem:'Химия', bio:'Биология', game:'Игра' };
|
||||||
|
|
||||||
let _simPickerCat = 'all'; // active filter in picker
|
let _simPickerCat = 'all'; // active filter in picker
|
||||||
|
// Конструктор симуляций (Фаза 7): свои + published custom-симуляции для доски.
|
||||||
|
let _crCustomSims = null; // [{ id, cat, title, _custom:true }] — кэш списка
|
||||||
|
|
||||||
function crOpenSimPicker() {
|
async function _crLoadCustomSims() {
|
||||||
|
if (_crCustomSims) return _crCustomSims;
|
||||||
|
try {
|
||||||
|
const data = await LS.customSimsList();
|
||||||
|
const rows = (data && data.sims) || [];
|
||||||
|
_crCustomSims = rows.map(s => ({
|
||||||
|
id: 'custom:' + s.id,
|
||||||
|
cat: s.cat || 'phys',
|
||||||
|
title: s.title || ('Симуляция #' + s.id),
|
||||||
|
_custom: true,
|
||||||
|
}));
|
||||||
|
} catch (e) { _crCustomSims = []; }
|
||||||
|
return _crCustomSims;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crOpenSimPicker() {
|
||||||
if (_simActive) {
|
if (_simActive) {
|
||||||
// If sim already open — clicking "Симуляция" closes it (teacher action)
|
// If sim already open — clicking "Симуляция" closes it (teacher action)
|
||||||
crTeacherCloseSim();
|
crTeacherCloseSim();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_simPickerCat = 'all';
|
_simPickerCat = 'all';
|
||||||
|
await _crLoadCustomSims();
|
||||||
_crRenderSimGrid('all');
|
_crRenderSimGrid('all');
|
||||||
const overlay = document.getElementById('cr-sim-picker-overlay');
|
const overlay = document.getElementById('cr-sim-picker-overlay');
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
@@ -7084,11 +7102,14 @@
|
|||||||
|
|
||||||
function _crRenderSimGrid(cat) {
|
function _crRenderSimGrid(cat) {
|
||||||
const grid = document.getElementById('cr-sim-picker-grid');
|
const grid = document.getElementById('cr-sim-picker-grid');
|
||||||
const sims = cat === 'all' ? CR_SIMS : CR_SIMS.filter(s => s.cat === cat);
|
// Конструктор симуляций (Фаза 7): встроенные + свои/published custom-sims.
|
||||||
|
const all = CR_SIMS.concat(_crCustomSims || []);
|
||||||
|
const sims = cat === 'all' ? all : all.filter(s => s.cat === cat);
|
||||||
|
const esc = v => String(v == null ? '' : v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
grid.innerHTML = sims.map(s => `
|
grid.innerHTML = sims.map(s => `
|
||||||
<div class="cr-sim-picker-card" onclick="crPickSim('${s.id}','${s.title.replace(/'/g,'\\\'')}')" title="${s.title}">
|
<div class="cr-sim-picker-card" onclick="crPickSim('${String(s.id).replace(/'/g,"\\'")}','${esc(s.title).replace(/'/g,"\\'")}')" title="${esc(s.title)}">
|
||||||
<span class="cr-sim-picker-card-cat ${s.cat}">${CAT_LABELS[s.cat] || s.cat}</span>
|
<span class="cr-sim-picker-card-cat ${s.cat}">${s._custom ? 'Моя' : (CAT_LABELS[s.cat] || s.cat)}</span>
|
||||||
<span class="cr-sim-picker-card-title">${s.title}</span>
|
<span class="cr-sim-picker-card-title">${esc(s.title)}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-36
@@ -375,6 +375,46 @@
|
|||||||
.study-face { padding: 20px 14px; }
|
.study-face { padding: 20px 14px; }
|
||||||
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
|
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── shared decks (назначенные учителем) ── */
|
||||||
|
.deck-badge.shared { background: rgba(6,214,224,.14); color: #0891b2; max-width: 100%; }
|
||||||
|
.deck-badge.shared .ic { width: 11px; height: 11px; }
|
||||||
|
.deck-card.shared { border-color: rgba(6,214,224,.4); }
|
||||||
|
|
||||||
|
/* ── read-only режим списка карточек (общая колода) ── */
|
||||||
|
#card-list.readonly .card-drag,
|
||||||
|
#card-list.readonly .card-actions,
|
||||||
|
#card-list.readonly .fx-mini,
|
||||||
|
#card-list.readonly .card-img-add,
|
||||||
|
#card-list.readonly .card-img-remove { display: none !important; }
|
||||||
|
#card-list.readonly .card-display { cursor: default; }
|
||||||
|
#card-list.readonly .card-display:hover { background: transparent; }
|
||||||
|
|
||||||
|
/* ── share modal ── */
|
||||||
|
.share-sub { font-size: .82rem; color: var(--text-3); margin: -8px 0 16px; line-height: 1.5; }
|
||||||
|
.share-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.share-tab { flex: 1; padding: 9px; border: 1.5px solid var(--border); border-radius: 10px; background: #fff;
|
||||||
|
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
|
||||||
|
color: var(--text-2); transition: .15s; }
|
||||||
|
.share-tab.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
|
||||||
|
.share-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
margin-bottom: 6px; padding-right: 4px; }
|
||||||
|
.share-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
|
||||||
|
border: 1.5px solid var(--border); border-radius: 12px; background: var(--surface-2);
|
||||||
|
cursor: pointer; transition: .15s; }
|
||||||
|
.share-row:hover { border-color: var(--violet); }
|
||||||
|
.share-row.on { border-color: var(--violet); background: rgba(155,93,229,.07); }
|
||||||
|
.share-row-name { flex: 1; font-size: .88rem; font-weight: 600; color: var(--text); min-width: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.share-row-sub { font-size: .72rem; color: var(--text-3); font-weight: 500; }
|
||||||
|
.share-toggle { width: 40px; height: 22px; border-radius: 99px; background: var(--border); position: relative;
|
||||||
|
flex-shrink: 0; transition: background .18s; }
|
||||||
|
.share-row.on .share-toggle { background: var(--violet); }
|
||||||
|
.share-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
|
||||||
|
border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||||||
|
.share-row.on .share-toggle::after { transform: translateX(18px); }
|
||||||
|
.share-empty { text-align: center; padding: 28px 12px; color: var(--text-3); font-size: .84rem; }
|
||||||
|
.app-layout.dark .share-tab { background: #1A1D27; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -405,7 +445,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
|
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
|
||||||
<button class="fc-btn fc-btn-ghost" id="cards-ai-btn" onclick="openAiGenModal()">Сгенерировать ИИ</button>
|
<button class="fc-btn fc-btn-ghost" id="cards-ai-btn" onclick="openAiGenModal()">Сгенерировать ИИ</button>
|
||||||
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
|
<button class="fc-btn fc-btn-ghost" id="cards-bulk-btn" onclick="openBulkModal()">Добавить список</button>
|
||||||
|
<button class="fc-btn fc-btn-ghost" id="cards-share-btn" style="display:none" onclick="openShareModal()">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>Поделиться</button>
|
||||||
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-search-bar" id="card-search-bar" style="display:none">
|
<div class="card-search-bar" id="card-search-bar" style="display:none">
|
||||||
@@ -415,7 +457,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-list" id="card-list"></div>
|
<div class="card-list" id="card-list"></div>
|
||||||
<!-- Add card row -->
|
<!-- Add card row -->
|
||||||
<div class="card-add-bar" style="margin-bottom:14px">
|
<div class="card-add-bar" id="card-add-row" style="margin-bottom:14px">
|
||||||
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…"
|
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…"
|
||||||
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
|
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
|
||||||
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
|
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
|
||||||
@@ -424,7 +466,7 @@
|
|||||||
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
|
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="new-card-imgs"></div>
|
<div id="new-card-imgs"></div>
|
||||||
<div style="display:flex;gap:10px;align-items:center">
|
<div id="deck-manage-row" style="display:flex;gap:10px;align-items:center">
|
||||||
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
|
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
|
||||||
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
|
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,6 +630,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Share Deck Modal ── -->
|
||||||
|
<div class="fc-modal" id="modal-share">
|
||||||
|
<div class="fc-modal-bg" onclick="closeModal('modal-share')"></div>
|
||||||
|
<div class="fc-modal-box" style="max-width:520px">
|
||||||
|
<div class="fc-modal-title">Поделиться колодой</div>
|
||||||
|
<p class="share-sub">Назначьте колоду классу или отдельным ученикам. Карточки общие, а прогресс у каждого ученика — свой.</p>
|
||||||
|
<div class="share-tabs">
|
||||||
|
<button class="share-tab active" id="share-tab-class" onclick="shareSetTab('class')">Классы</button>
|
||||||
|
<button class="share-tab" id="share-tab-user" onclick="shareSetTab('user')">Ученики</button>
|
||||||
|
</div>
|
||||||
|
<div class="share-list" id="share-list"></div>
|
||||||
|
<div class="fc-modal-actions">
|
||||||
|
<button class="fc-btn fc-btn-primary" onclick="closeModal('modal-share')">Готово</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/imggen.js"></script>
|
<script src="/js/imggen.js"></script>
|
||||||
@@ -613,11 +672,20 @@ let _editingDeckId = null;
|
|||||||
let _deckColor = '#9B5DE5';
|
let _deckColor = '#9B5DE5';
|
||||||
let _cardFilter = '';
|
let _cardFilter = '';
|
||||||
let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке
|
let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке
|
||||||
|
let _user = null;
|
||||||
|
let _isTeacher = false;
|
||||||
|
let _curDeckReadonly = false; // общая колода (не владелец) — редактирование скрыто
|
||||||
|
// модалка шаринга
|
||||||
|
let _shareData = { shares: [], classes: [], students: [] };
|
||||||
|
let _shareTab = 'class';
|
||||||
|
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
/* ── auth ── */
|
/* ── auth ── */
|
||||||
const { user } = LS.initPage();
|
const { user } = LS.initPage();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
_user = user;
|
||||||
|
_isTeacher = (user.role === 'teacher' || user.role === 'admin');
|
||||||
const avatarEl = document.getElementById('nav-avatar');
|
const avatarEl = document.getElementById('nav-avatar');
|
||||||
const nameEl = document.getElementById('nav-user');
|
const nameEl = document.getElementById('nav-user');
|
||||||
LS.renderNavAvatar(avatarEl, user);
|
LS.renderNavAvatar(avatarEl, user);
|
||||||
@@ -720,7 +788,11 @@ function renderDecks() {
|
|||||||
const dueHtml = due > 0
|
const dueHtml = due > 0
|
||||||
? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${due} к повторению</span>`
|
? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${due} к повторению</span>`
|
||||||
: `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`;
|
: `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`;
|
||||||
return `<div class="deck-card" style="--dc-shadow:${shadow}">
|
// Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки.
|
||||||
|
const sharedHtml = d.shared
|
||||||
|
? `<span class="deck-badge shared" title="Колода от учителя"><svg class="ic" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${esc(d.owner_name || 'учитель')}</span>`
|
||||||
|
: '';
|
||||||
|
return `<div class="deck-card${d.shared ? ' shared' : ''}" style="--dc-shadow:${shadow}">
|
||||||
<div class="deck-head" style="background:${color}" onclick="openDeck(${d.id})">
|
<div class="deck-head" style="background:${color}" onclick="openDeck(${d.id})">
|
||||||
<div class="deck-head-letter">${letter}</div>
|
<div class="deck-head-letter">${letter}</div>
|
||||||
<span class="deck-head-count">${d.card_count} карт.</span>
|
<span class="deck-head-count">${d.card_count} карт.</span>
|
||||||
@@ -728,7 +800,7 @@ function renderDecks() {
|
|||||||
<div class="deck-body" onclick="openDeck(${d.id})">
|
<div class="deck-body" onclick="openDeck(${d.id})">
|
||||||
<div class="deck-name">${esc(d.title)}</div>
|
<div class="deck-name">${esc(d.title)}</div>
|
||||||
${d.description ? `<div class="deck-desc">${esc(d.description)}</div>` : ''}
|
${d.description ? `<div class="deck-desc">${esc(d.description)}</div>` : ''}
|
||||||
<div class="deck-meta">${dueHtml}</div>
|
<div class="deck-meta">${dueHtml}${sharedHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="deck-actions">
|
<div class="deck-actions">
|
||||||
<button class="deck-btn-study" onclick="openDeckStudy(${d.id})" ${d.card_count===0?'disabled':''}>
|
<button class="deck-btn-study" onclick="openDeckStudy(${d.id})" ${d.card_count===0?'disabled':''}>
|
||||||
@@ -738,8 +810,10 @@ function renderDecks() {
|
|||||||
? `<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Изучать`
|
? `<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Изучать`
|
||||||
: 'Нет карточек'}
|
: 'Нет карточек'}
|
||||||
</button>
|
</button>
|
||||||
<button class="deck-btn-edit" onclick="openDeck(${d.id})">
|
<button class="deck-btn-edit" onclick="openDeck(${d.id})" title="${d.shared ? 'Открыть' : 'Редактировать'}">
|
||||||
<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
${d.shared
|
||||||
|
? `<svg class="ic" viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>`
|
||||||
|
: `<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -753,11 +827,16 @@ function renderDecks() {
|
|||||||
async function openDeck(id) {
|
async function openDeck(id) {
|
||||||
_curDeck = _decks.find(d => d.id === id);
|
_curDeck = _decks.find(d => d.id === id);
|
||||||
if (!_curDeck) return;
|
if (!_curDeck) return;
|
||||||
|
// Общая колода (назначена мне) — только просмотр и изучение, без правки.
|
||||||
|
_curDeckReadonly = (_curDeck.shared === 1) || (_curDeck.can_edit === 0);
|
||||||
document.getElementById('cards-deck-title').textContent = _curDeck.title;
|
document.getElementById('cards-deck-title').textContent = _curDeck.title;
|
||||||
|
applyCardsPermissions();
|
||||||
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
||||||
|
if (data && data.can_edit === false) _curDeckReadonly = true; // страховка по серверу
|
||||||
_cards = data.cards || [];
|
_cards = data.cards || [];
|
||||||
_cardFilter = '';
|
_cardFilter = '';
|
||||||
const si = document.getElementById('card-search'); if (si) si.value = '';
|
const si = document.getElementById('card-search'); if (si) si.value = '';
|
||||||
|
applyCardsPermissions();
|
||||||
renderCardList();
|
renderCardList();
|
||||||
document.getElementById('view-decks').style.display = 'none';
|
document.getElementById('view-decks').style.display = 'none';
|
||||||
document.getElementById('view-cards').style.display = 'block';
|
document.getElementById('view-cards').style.display = 'block';
|
||||||
@@ -770,6 +849,19 @@ function openDeckStudy(id) {
|
|||||||
startStudyForDeck(id);
|
startStudyForDeck(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Показ/скрытие редактирующих элементов в зависимости от прав на колоду.
|
||||||
|
readonly (общая, не владелец) → прячем добавление/ИИ/список/правку колоды;
|
||||||
|
кнопка «Поделиться» — только владельцу-учителю/админу. */
|
||||||
|
function applyCardsPermissions() {
|
||||||
|
const ed = !_curDeckReadonly;
|
||||||
|
['cards-ai-btn', 'cards-bulk-btn', 'card-add-row', 'deck-manage-row'].forEach(id => {
|
||||||
|
const el = document.getElementById(id); if (el) el.style.display = ed ? '' : 'none';
|
||||||
|
});
|
||||||
|
const imgs = document.getElementById('new-card-imgs'); if (imgs) imgs.style.display = ed ? '' : 'none';
|
||||||
|
const shareBtn = document.getElementById('cards-share-btn');
|
||||||
|
if (shareBtn) shareBtn.style.display = (ed && _isTeacher) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function showDecks() {
|
function showDecks() {
|
||||||
document.getElementById('view-decks').style.display = 'block';
|
document.getElementById('view-decks').style.display = 'block';
|
||||||
document.getElementById('view-cards').style.display = 'none';
|
document.getElementById('view-cards').style.display = 'none';
|
||||||
@@ -856,9 +948,12 @@ function renderCardList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
|
// read-only (общая колода) → CSS прячет ручки/удаление/правку, drag не вешаем
|
||||||
|
list.classList.toggle('readonly', _curDeckReadonly);
|
||||||
|
|
||||||
// Отрисовать карточки (KaTeX). Правка — по клику (textarea), как в Anki.
|
// Отрисовать карточки (KaTeX). Правка — по клику (textarea), как в Anki.
|
||||||
list.querySelectorAll('.card-display').forEach(fcRenderDisplay);
|
list.querySelectorAll('.card-display').forEach(fcRenderDisplay);
|
||||||
if (!q) bindCardDrag();
|
if (!q && !_curDeckReadonly) bindCardDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Показать отрисованный текст карточки (или плейсхолдер, если пусто). */
|
/* Показать отрисованный текст карточки (или плейсхолдер, если пусто). */
|
||||||
@@ -871,6 +966,7 @@ function fcRenderDisplay(disp) {
|
|||||||
}
|
}
|
||||||
/* Клик по отрисованной карточке → редактирование (textarea с сырым LaTeX). */
|
/* Клик по отрисованной карточке → редактирование (textarea с сырым LaTeX). */
|
||||||
function fcStartEdit(disp) {
|
function fcStartEdit(disp) {
|
||||||
|
if (_curDeckReadonly) return; // общая колода — только чтение
|
||||||
const side = disp.closest('.card-side');
|
const side = disp.closest('.card-side');
|
||||||
const ta = side && side.querySelector('.card-textarea');
|
const ta = side && side.querySelector('.card-textarea');
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
@@ -1435,9 +1531,15 @@ function fxInsert() {
|
|||||||
closeModal('modal-formula');
|
closeModal('modal-formula');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════ Study mode ════ */
|
/* ════ Study mode ════
|
||||||
|
_studyCards — ДИНАМИЧЕСКАЯ очередь, не фиксированный список: карта, отвеченная
|
||||||
|
«Снова»/недоученная (server: graduated=false), возвращается в очередь через
|
||||||
|
FC_RQ_GAP карт и показывается снова в этой же сессии. _studyDone — сколько карт
|
||||||
|
реально выпущено (ушли из очереди). */
|
||||||
|
const FC_RQ_GAP = 3;
|
||||||
let _studyCards = [];
|
let _studyCards = [];
|
||||||
let _studyIdx = 0;
|
let _studyIdx = 0;
|
||||||
|
let _studyDone = 0;
|
||||||
let _studyFlipped = false;
|
let _studyFlipped = false;
|
||||||
let _sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
let _sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
||||||
|
|
||||||
@@ -1456,6 +1558,7 @@ async function startStudyForDeck(deckId) {
|
|||||||
}
|
}
|
||||||
_studyCards = data.cards;
|
_studyCards = data.cards;
|
||||||
_studyIdx = 0;
|
_studyIdx = 0;
|
||||||
|
_studyDone = 0;
|
||||||
_studyFlipped = false;
|
_studyFlipped = false;
|
||||||
_sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
_sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
||||||
document.getElementById('study-deck-title').textContent = _curDeck.title;
|
document.getElementById('study-deck-title').textContent = _curDeck.title;
|
||||||
@@ -1513,10 +1616,11 @@ function setStudyImg(id, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStudyProgress() {
|
function updateStudyProgress() {
|
||||||
const total = _studyCards.length;
|
const remaining = _studyCards.length - _studyIdx; // ещё в очереди (вкл. текущую)
|
||||||
const done = _studyIdx;
|
const total = _studyDone + remaining; // растёт при re-queue недоученных
|
||||||
document.getElementById('study-prog').style.width = (done / total * 100) + '%';
|
const pct = total ? (_studyDone / total * 100) : 0;
|
||||||
document.getElementById('study-counter').textContent = `${done + 1} / ${total}`;
|
document.getElementById('study-prog').style.width = pct + '%';
|
||||||
|
document.getElementById('study-counter').textContent = `${Math.min(_studyDone + 1, total)} / ${total}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function flipCard() {
|
function flipCard() {
|
||||||
@@ -1535,15 +1639,35 @@ async function answer(quality) {
|
|||||||
else if (quality === 3) _sessionStats.hard++;
|
else if (quality === 3) _sessionStats.hard++;
|
||||||
else if (quality === 4) _sessionStats.good++;
|
else if (quality === 4) _sessionStats.good++;
|
||||||
else if (quality === 5) _sessionStats.easy++;
|
else if (quality === 5) _sessionStats.easy++;
|
||||||
// send review
|
// send review — ответ несёт следующее расписание и флаг graduated
|
||||||
await LS.api(`/api/flashcards/cards/${card.id}/review`, {
|
let resp = null;
|
||||||
method: 'POST', body: JSON.stringify({ quality })
|
try {
|
||||||
}).catch(()=>{});
|
resp = await LS.api(`/api/flashcards/cards/${card.id}/review`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ quality })
|
||||||
|
});
|
||||||
|
} catch (e) { /* офлайн — оценим re-queue эвристикой ниже */ }
|
||||||
|
// обновить локальное расписание карты, чтобы повторное превью было верным
|
||||||
|
if (resp && resp.next) {
|
||||||
|
card.state = resp.next.state;
|
||||||
|
card.learning_step = resp.next.learning_step;
|
||||||
|
card.ease_factor = resp.next.ease_factor;
|
||||||
|
card.interval_days = resp.next.interval_days;
|
||||||
|
card.repetitions = resp.next.repetitions;
|
||||||
|
card.seen = 1;
|
||||||
|
}
|
||||||
|
// карта не выпущена (всё ещё learning/relearning) → вернуть в очередь этой сессии
|
||||||
|
const requeue = resp ? !resp.graduated : (quality < 3);
|
||||||
// animate swipe
|
// animate swipe
|
||||||
const el = document.getElementById('study-card');
|
const el = document.getElementById('study-card');
|
||||||
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
|
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_studyIdx++;
|
_studyCards.splice(_studyIdx, 1); // вынуть текущую
|
||||||
|
if (requeue) {
|
||||||
|
const pos = Math.min(_studyIdx + FC_RQ_GAP, _studyCards.length);
|
||||||
|
_studyCards.splice(pos, 0, card); // показать снова позже
|
||||||
|
} else {
|
||||||
|
_studyDone++;
|
||||||
|
}
|
||||||
if (_studyIdx >= _studyCards.length) finishStudy();
|
if (_studyIdx >= _studyCards.length) finishStudy();
|
||||||
else showStudyCard();
|
else showStudyCard();
|
||||||
}, 380);
|
}, 380);
|
||||||
@@ -1570,31 +1694,48 @@ function finishStudy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── превью следующего интервала для кнопок Снова/Трудно/Знаю/Легко ──
|
/* ── превью следующего интервала для кнопок Снова/Трудно/Знаю/Легко ──
|
||||||
ВАЖНО: точная копия логики интервалов серверного sm2() (flashcardController.js),
|
ВАЖНО: зеркало интервальной части серверного schedule() (flashcardController.js),
|
||||||
иначе превью врёт. Anki-стиль: на новой карте Легко=4д выделяется, на зрелых
|
иначе превью врёт. learning/relearning → минуты (шаги), review → дни SM-2.
|
||||||
Трудно ×1.2 / Знаю ×ef / Легко ×ef×1.3. */
|
Константы держим в синхроне с контроллером. */
|
||||||
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
|
const FC_LEARN_STEPS = [1, 10], FC_RELEARN_STEPS = [10];
|
||||||
function fcNextInterval(card, q) {
|
const FC_GRAD_IV = 1, FC_EASY_IV = 4, FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
|
||||||
const ef = card.ease_factor || 2.5;
|
|
||||||
const iv = card.interval_days || 1;
|
/* → { kind: 'min'|'day', n } */
|
||||||
const rep = card.repetitions || 0;
|
function fcPreview(card, q) {
|
||||||
if (q < 3) return 1;
|
const state = card.state || (card.seen ? 'review' : 'new');
|
||||||
if (rep === 0) return q === 5 ? 4 : 1;
|
const step = card.learning_step || 0;
|
||||||
if (rep === 1) return q === 3 ? 3 : q === 4 ? 6 : Math.round(6 * FC_EASY_BONUS);
|
const ef = card.ease_factor || 2.5;
|
||||||
if (q === 3) return Math.max(iv + 1, Math.round(iv * FC_HARD_MULT));
|
const iv = card.interval_days || 0;
|
||||||
if (q === 4) return Math.round(iv * ef);
|
const learning = (state === 'new' || state === 'learning' || state === 'relearning');
|
||||||
return Math.round(iv * ef * FC_EASY_BONUS);
|
if (learning) {
|
||||||
|
const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS;
|
||||||
|
if (q === 5) return { kind: 'day', n: FC_EASY_IV };
|
||||||
|
if (q < 3) return { kind: 'min', n: steps[0] };
|
||||||
|
if (q === 3) return { kind: 'min', n: steps[Math.min(step, steps.length - 1)] };
|
||||||
|
const ns = step + 1; // q === 4 (Знаю)
|
||||||
|
if (ns >= steps.length)
|
||||||
|
return { kind: 'day', n: (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV };
|
||||||
|
return { kind: 'min', n: steps[ns] };
|
||||||
|
}
|
||||||
|
if (q < 3) return { kind: 'min', n: FC_RELEARN_STEPS[0] };
|
||||||
|
if (q === 3) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)) };
|
||||||
|
if (q === 4) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef)) };
|
||||||
|
return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS)) };
|
||||||
}
|
}
|
||||||
function fcDaysLabel(n) {
|
function fcDaysLabel(n) {
|
||||||
if (n <= 1) return '1 день';
|
if (n <= 1) return '1 день';
|
||||||
if (n < 5) return n + ' дня';
|
if (n < 5) return n + ' дня';
|
||||||
return n + ' дн.';
|
return n + ' дн.';
|
||||||
}
|
}
|
||||||
|
function fcSchedLabel(p) {
|
||||||
|
if (p.kind === 'min') return p.n < 60 ? p.n + ' мин' : Math.round(p.n / 60) + ' ч';
|
||||||
|
return fcDaysLabel(p.n);
|
||||||
|
}
|
||||||
function updateSQDays(card) {
|
function updateSQDays(card) {
|
||||||
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
|
document.getElementById('sq-days-0').textContent = fcSchedLabel(fcPreview(card, 0));
|
||||||
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
|
document.getElementById('sq-days-3').textContent = fcSchedLabel(fcPreview(card, 3));
|
||||||
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
|
document.getElementById('sq-days-4').textContent = fcSchedLabel(fcPreview(card, 4));
|
||||||
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
|
document.getElementById('sq-days-5').textContent = fcSchedLabel(fcPreview(card, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── touch/mouse swipe ── */
|
/* ── touch/mouse swipe ── */
|
||||||
@@ -1703,6 +1844,80 @@ async function confirmDeleteDeck() {
|
|||||||
|
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||||
|
|
||||||
|
/* ════ Поделиться колодой (учитель → класс/ученик) ════
|
||||||
|
Карты общие, прогресс у каждого ученика свой. Тоггл сразу шлёт add/remove
|
||||||
|
share (оптимистично, с откатом при ошибке). */
|
||||||
|
async function openShareModal() {
|
||||||
|
if (!_curDeck || !_isTeacher || _curDeckReadonly) return;
|
||||||
|
document.getElementById('modal-share').classList.add('open');
|
||||||
|
document.getElementById('share-list').innerHTML =
|
||||||
|
'<div class="share-empty">Загрузка…</div>';
|
||||||
|
try {
|
||||||
|
const [sh, cls, st] = await Promise.all([
|
||||||
|
LS.api(`/api/flashcards/decks/${_curDeck.id}/shares`).catch(() => ({ shares: [] })),
|
||||||
|
LS.getClasses().catch(() => []),
|
||||||
|
LS.getStudents().catch(() => []),
|
||||||
|
]);
|
||||||
|
_shareData.shares = (sh && sh.shares) || [];
|
||||||
|
_shareData.classes = Array.isArray(cls) ? cls : (cls && cls.classes) || [];
|
||||||
|
_shareData.students = Array.isArray(st) ? st : (st && st.students) || [];
|
||||||
|
_shareSet = new Set(_shareData.shares.map(s => `${s.type}:${s.target_id}`));
|
||||||
|
} catch (e) {
|
||||||
|
_shareData = { shares: [], classes: [], students: [] };
|
||||||
|
_shareSet = new Set();
|
||||||
|
}
|
||||||
|
shareSetTab(_shareTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareSetTab(tab) {
|
||||||
|
_shareTab = tab;
|
||||||
|
document.getElementById('share-tab-class').classList.toggle('active', tab === 'class');
|
||||||
|
document.getElementById('share-tab-user').classList.toggle('active', tab === 'user');
|
||||||
|
renderShareList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShareList() {
|
||||||
|
const box = document.getElementById('share-list');
|
||||||
|
const items = _shareTab === 'class' ? _shareData.classes : _shareData.students;
|
||||||
|
if (!items.length) {
|
||||||
|
box.innerHTML = `<div class="share-empty">${_shareTab === 'class'
|
||||||
|
? 'У вас пока нет классов' : 'У вас пока нет учеников'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = items.map(it => {
|
||||||
|
const on = _shareSet.has(`${_shareTab}:${it.id}`);
|
||||||
|
const sub = _shareTab === 'class'
|
||||||
|
? (it.member_count != null ? `${it.member_count} учеников` : '')
|
||||||
|
: (it.email || '');
|
||||||
|
return `<div class="share-row${on ? ' on' : ''}" data-id="${it.id}" onclick="toggleShare(${it.id}, this)">
|
||||||
|
<div class="share-row-name">${esc(it.name)}${sub ? `<span class="share-row-sub" style="display:block">${esc(sub)}</span>` : ''}</div>
|
||||||
|
<div class="share-toggle"></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleShare(id, row) {
|
||||||
|
const key = `${_shareTab}:${id}`;
|
||||||
|
const wasOn = _shareSet.has(key);
|
||||||
|
// оптимистично
|
||||||
|
if (wasOn) { _shareSet.delete(key); row.classList.remove('on'); }
|
||||||
|
else { _shareSet.add(key); row.classList.add('on'); }
|
||||||
|
try {
|
||||||
|
if (wasOn) {
|
||||||
|
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share?type=${_shareTab}&target_id=${id}`, { method: 'DELETE' });
|
||||||
|
} else {
|
||||||
|
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ type: _shareTab, target_id: id })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// откат
|
||||||
|
if (wasOn) { _shareSet.add(key); row.classList.add('on'); }
|
||||||
|
else { _shareSet.delete(key); row.classList.remove('on'); }
|
||||||
|
LS.toast('Не удалось изменить доступ: ' + (e && e.message || 'ошибка'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/mobile.js"></script>
|
<script src="/js/mobile.js"></script>
|
||||||
|
|||||||
@@ -20,14 +20,14 @@
|
|||||||
async function uploadBlob(blob, name) {
|
async function uploadBlob(blob, name) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', blob, name);
|
fd.append('file', blob, name);
|
||||||
const up = await LS.uploadMaterialFile(fd);
|
return await LS.uploadMaterialFile(fd); // { url, thumbUrl }
|
||||||
return up.url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persist(meta, kind, url) {
|
async function persist(meta, kind, url, thumbUrl) {
|
||||||
await LS.saveMaterial({
|
await LS.saveMaterial({
|
||||||
kind: kind,
|
kind: kind,
|
||||||
url: url,
|
url: url,
|
||||||
|
thumbUrl: thumbUrl || null,
|
||||||
title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
|
title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
|
||||||
sourceSessionId: meta && meta.sourceSessionId,
|
sourceSessionId: meta && meta.sourceSessionId,
|
||||||
sourceTitle: meta && meta.sourceTitle,
|
sourceTitle: meta && meta.sourceTitle,
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
wb.exportBlob(async function (blob) {
|
wb.exportBlob(async function (blob) {
|
||||||
try {
|
try {
|
||||||
if (!blob) throw new Error('Не удалось снять доску');
|
if (!blob) throw new Error('Не удалось снять доску');
|
||||||
const url = await uploadBlob(blob, 'board.png');
|
const up = await uploadBlob(blob, 'board.png');
|
||||||
await persist(meta, 'board', url);
|
await persist(meta, 'board', up.url, up.thumbUrl);
|
||||||
LS.toast('Страница сохранена в «Мои материалы»', 'success');
|
LS.toast('Страница сохранена в «Мои материалы»', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LS.toast(e.message || 'Ошибка сохранения', 'error');
|
LS.toast(e.message || 'Ошибка сохранения', 'error');
|
||||||
@@ -147,8 +147,8 @@
|
|||||||
off.getContext('2d').drawImage(img, Math.round(rect.x * sx), Math.round(rect.y * sy), cw, ch, 0, 0, cw, ch);
|
off.getContext('2d').drawImage(img, Math.round(rect.x * sx), Math.round(rect.y * sy), cw, ch, 0, 0, cw, ch);
|
||||||
const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); });
|
const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); });
|
||||||
if (!cblob) throw new Error('Не удалось обрезать область');
|
if (!cblob) throw new Error('Не удалось обрезать область');
|
||||||
const cropUrl = await uploadBlob(cblob, 'board-region.png');
|
const up = await uploadBlob(cblob, 'board-region.png');
|
||||||
await persist(meta, 'image', cropUrl);
|
await persist(meta, 'image', up.url, up.thumbUrl);
|
||||||
close();
|
close();
|
||||||
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
|
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
/* LabMeasure — измерительные инструменты поверх любой симуляции (Фаза 2).
|
||||||
|
* Линейка (длина в px + ≈ метрах при PX_PER_M) и угломер (угол при вершине).
|
||||||
|
* SVG-оверлей на весь экран с pointer-events:none, чтобы симуляция оставалась
|
||||||
|
* интерактивной; перехватывают события только перетаскиваемые ручки.
|
||||||
|
* Самодостаточно: свой DOM/CSS, не трогает симуляции. Точка входа — LabMeasure.toggle(). */
|
||||||
|
(function (global) {
|
||||||
|
var NS = 'http://www.w3.org/2000/svg';
|
||||||
|
var PXM = (global.LabPalette && LabPalette.PX_PER_M) || 100;
|
||||||
|
var svg = null, bar = null, mode = null, drag = null;
|
||||||
|
var ruler = null, angle = null;
|
||||||
|
|
||||||
|
function el(tag, attrs) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); return e; }
|
||||||
|
function center() { return { x: global.innerWidth / 2, y: global.innerHeight / 2 }; }
|
||||||
|
|
||||||
|
function ensureStyle() {
|
||||||
|
if (document.getElementById('lm-style')) return;
|
||||||
|
var s = document.createElement('style'); s.id = 'lm-style';
|
||||||
|
s.textContent = [
|
||||||
|
'#lm-svg{position:fixed;inset:0;z-index:60;pointer-events:none;display:none;}',
|
||||||
|
'#lm-svg.on{display:block;}',
|
||||||
|
'#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}',
|
||||||
|
'#lm-svg .lm-hit:active{cursor:grabbing;}',
|
||||||
|
'#lm-bar{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:61;display:none;gap:6px;padding:6px;border-radius:12px;',
|
||||||
|
'background:var(--surface,rgba(255,255,255,.9));backdrop-filter:var(--blur,blur(20px));border:1px solid var(--border,rgba(15,23,42,.1));box-shadow:0 8px 28px rgba(15,23,42,.18);}',
|
||||||
|
'#lm-bar.on{display:flex;}',
|
||||||
|
'.lm-tb{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:none;border-radius:8px;background:transparent;',
|
||||||
|
'font:700 .8rem Manrope,sans-serif;color:var(--text-2,#475569);cursor:pointer;}',
|
||||||
|
'.lm-tb:hover{background:rgba(155,93,229,.08);color:var(--violet,#9B5DE5);}',
|
||||||
|
'.lm-tb.on{background:var(--violet,#9B5DE5);color:#fff;}',
|
||||||
|
'.lm-tb .ic{width:15px;height:15px;}',
|
||||||
|
].join('');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure() {
|
||||||
|
if (svg) return;
|
||||||
|
ensureStyle();
|
||||||
|
svg = el('svg', { id: 'lm-svg' });
|
||||||
|
document.body.appendChild(svg);
|
||||||
|
bar = document.createElement('div'); bar.id = 'lm-bar';
|
||||||
|
bar.innerHTML =
|
||||||
|
'<button class="lm-tb" data-t="ruler"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0l-4.6-4.6a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z"/><path d="m7.5 10.5 2 2M10.5 7.5l2 2M13.5 4.5l2 2M4.5 13.5l2 2"/></svg>Линейка</button>' +
|
||||||
|
'<button class="lm-tb" data-t="angle"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M3 21 21 3"/><path d="M3 21a18 18 0 0 0 4-7"/></svg>Угол</button>' +
|
||||||
|
'<button class="lm-tb" data-t="off"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Скрыть</button>';
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
bar.addEventListener('click', function (e) { var b = e.target.closest('.lm-tb'); if (b) setTool(b.dataset.t); });
|
||||||
|
|
||||||
|
// drag delegation
|
||||||
|
svg.addEventListener('pointerdown', function (e) {
|
||||||
|
var h = e.target.getAttribute && e.target.getAttribute('data-h');
|
||||||
|
if (!h) return;
|
||||||
|
drag = h; e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); e.preventDefault();
|
||||||
|
});
|
||||||
|
svg.addEventListener('pointermove', function (e) {
|
||||||
|
if (!drag) return;
|
||||||
|
var p = (mode === 'ruler') ? ruler : angle;
|
||||||
|
p[drag + 'x'] = e.clientX; p[drag + 'y'] = e.clientY;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
function end() { drag = null; }
|
||||||
|
svg.addEventListener('pointerup', end);
|
||||||
|
svg.addEventListener('pointercancel', end);
|
||||||
|
global.addEventListener('resize', function () { if (mode) render(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTool(t) {
|
||||||
|
ensure();
|
||||||
|
if (t === 'off') { mode = null; svg.classList.remove('on'); paintBar(); render(); return; }
|
||||||
|
mode = t;
|
||||||
|
var c = center();
|
||||||
|
if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y };
|
||||||
|
if (t === 'angle' && !angle) angle = { vx: c.x, vy: c.y + 70, ax: c.x - 120, ay: c.y - 40, bx: c.x + 120, by: c.y - 40 };
|
||||||
|
svg.classList.add('on');
|
||||||
|
paintBar(); render();
|
||||||
|
}
|
||||||
|
function paintBar() { if (!bar) return; bar.querySelectorAll('.lm-tb').forEach(function (b) { b.classList.toggle('on', b.dataset.t === mode); }); }
|
||||||
|
|
||||||
|
function lineLabel(x, y, text) {
|
||||||
|
var g = el('g', {});
|
||||||
|
var pad = 5, w = text.length * 7.2 + pad * 2, h = 20;
|
||||||
|
g.appendChild(el('rect', { x: x - w / 2, y: y - h / 2, width: w, height: h, rx: 6, fill: 'rgba(13,13,26,.85)' }));
|
||||||
|
var t = el('text', { x: x, y: y + 4, 'text-anchor': 'middle', fill: '#fff', 'font-size': 12, 'font-family': 'Manrope,sans-serif', 'font-weight': 700 });
|
||||||
|
t.textContent = text; g.appendChild(t);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function handle(hx, hy, name) { return el('circle', { cx: hx, cy: hy, r: 9, class: 'lm-hit', fill: '#9B5DE5', stroke: '#fff', 'stroke-width': 2, 'data-h': name }); }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!svg) return;
|
||||||
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||||
|
if (!mode) return;
|
||||||
|
if (mode === 'ruler') {
|
||||||
|
var r = ruler, dx = r.bx - r.ax, dy = r.by - r.ay;
|
||||||
|
var dpx = Math.hypot(dx, dy), deg = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
|
||||||
|
if (deg > 90) deg = 180 - deg;
|
||||||
|
svg.appendChild(el('line', { x1: r.ax, y1: r.ay, x2: r.bx, y2: r.by, stroke: '#9B5DE5', 'stroke-width': 2.5 }));
|
||||||
|
svg.appendChild(handle(r.ax, r.ay, 'a'));
|
||||||
|
svg.appendChild(handle(r.bx, r.by, 'b'));
|
||||||
|
var lab = Math.round(dpx) + ' px · ' + (dpx / PXM).toFixed(2) + ' м · ' + deg.toFixed(1) + '°';
|
||||||
|
svg.appendChild(lineLabel((r.ax + r.bx) / 2, (r.ay + r.by) / 2 - 18, lab));
|
||||||
|
} else if (mode === 'angle') {
|
||||||
|
var a = angle;
|
||||||
|
var a1 = Math.atan2(a.ay - a.vy, a.ax - a.vx), a2 = Math.atan2(a.by - a.vy, a.bx - a.vx);
|
||||||
|
var deg2 = Math.abs((a1 - a2) * 180 / Math.PI); if (deg2 > 180) deg2 = 360 - deg2;
|
||||||
|
svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.ax, y2: a.ay, stroke: '#06D6E0', 'stroke-width': 2.5 }));
|
||||||
|
svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.bx, y2: a.by, stroke: '#06D6E0', 'stroke-width': 2.5 }));
|
||||||
|
// дуга угла
|
||||||
|
var rr = 36, s = el('path', { d: 'M ' + (a.vx + rr * Math.cos(a1)) + ' ' + (a.vy + rr * Math.sin(a1)) +
|
||||||
|
' A ' + rr + ' ' + rr + ' 0 ' + (deg2 > 180 ? 1 : 0) + ' ' + ((a2 - a1 + 2 * Math.PI) % (2 * Math.PI) < Math.PI ? 1 : 0) + ' ' +
|
||||||
|
(a.vx + rr * Math.cos(a2)) + ' ' + (a.vy + rr * Math.sin(a2)), fill: 'none', stroke: '#06D6E0', 'stroke-width': 2, opacity: .6 });
|
||||||
|
svg.appendChild(s);
|
||||||
|
svg.appendChild(handle(a.ax, a.ay, 'a'));
|
||||||
|
svg.appendChild(handle(a.bx, a.by, 'b'));
|
||||||
|
svg.appendChild(handle(a.vx, a.vy, 'v'));
|
||||||
|
svg.appendChild(lineLabel(a.vx, a.vy + 26, deg2.toFixed(1) + '°'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.LabMeasure = {
|
||||||
|
toggle: function () { ensure(); if (bar.classList.contains('on')) { this.hide(); } else { bar.classList.add('on'); if (!mode) setTool('ruler'); } },
|
||||||
|
hide: function () { if (bar) bar.classList.remove('on'); if (svg) svg.classList.remove('on'); mode = null; paintBar(); render(); },
|
||||||
|
setTool: setTool,
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
registerSpecSim — адаптер: JSON-спека -> манифест LabRegistry (Фаза 0).
|
||||||
|
|
||||||
|
Строит манифест и регистрирует его в window.LabRegistry, чтобы спек-симуляция
|
||||||
|
открывалась тем же путём, что и ~40 рукописных симуляций (через openSim ->
|
||||||
|
реестр). Никаких правок чужих манифестов — только register().
|
||||||
|
|
||||||
|
Контракт LabRegistry-манифеста (см. _registry.js): { id, cat, title, desc,
|
||||||
|
preview, theory?, open(ctx), stop(), destroy() }.
|
||||||
|
|
||||||
|
Особенности интеграции с /lab:
|
||||||
|
- openSim() прячет все ИЗВЕСТНЫЕ ему тела (ALL_SIM_BODIES) и зовёт _pauseAllSims()
|
||||||
|
-> LabRegistry.stopActive() (наш stop спрячет наш хост). Спек-хосты openSim не
|
||||||
|
знает, поэтому при switch именно stop() прошлой активной спек-симуляции прячет её.
|
||||||
|
- Каждая спек-симуляция получает собственный хост-div внутри #lab-sim, создаётся
|
||||||
|
лениво при первом open и переиспользуется.
|
||||||
|
- Заголовок топбара ставим как делают рукописные _openX (sim-topbar-title).
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
|
||||||
|
var HOST_PREFIX = 'sim-spec-host-';
|
||||||
|
|
||||||
|
// Найти контейнер для тел симуляций (#lab-sim) — туда вставляем хост.
|
||||||
|
function simContainer() {
|
||||||
|
return document.getElementById('lab-sim') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лениво создать/получить хост-элемент для данного id.
|
||||||
|
function ensureHost(id) {
|
||||||
|
var hid = HOST_PREFIX + id;
|
||||||
|
var el = document.getElementById(hid);
|
||||||
|
if (el) return el;
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = hid;
|
||||||
|
el.className = 'sim-spec-host';
|
||||||
|
// занимает то же место, что .sim-body-wrap у рукописных симуляций
|
||||||
|
el.style.cssText = 'flex:1;min-height:0;display:none';
|
||||||
|
var cont = simContainer();
|
||||||
|
// вставить перед панелью теории, если она есть, иначе в конец
|
||||||
|
var theory = document.getElementById('theory-panel');
|
||||||
|
if (theory && theory.parentNode === cont) cont.insertBefore(el, theory);
|
||||||
|
else cont.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спрятать все спек-хосты (на случай переключения с одной спеки на другую,
|
||||||
|
// когда openSim не знает наших хостов).
|
||||||
|
function hideAllSpecHosts() {
|
||||||
|
var nodes = document.querySelectorAll('.sim-spec-host');
|
||||||
|
for (var i = 0; i < nodes.length; i++) nodes[i].style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(spec) {
|
||||||
|
var t = document.getElementById('sim-topbar-title');
|
||||||
|
if (t) t.textContent = (spec.meta && spec.meta.title) || spec.title || 'Симуляция';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* preview из спеки: если задана строка/функция — использовать; иначе
|
||||||
|
сгенерировать простой SVG-плейсхолдер с названием. */
|
||||||
|
function buildPreview(spec) {
|
||||||
|
if (spec.preview) return spec.preview;
|
||||||
|
var title = (spec.meta && spec.meta.title) || spec.title || 'Симуляция';
|
||||||
|
var bg = (spec.viewport && spec.viewport.bg) || '#0D0D1A';
|
||||||
|
return function () {
|
||||||
|
return '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<rect width="300" height="140" fill="' + _esc(bg) + '"/>' +
|
||||||
|
'<line x1="20" y1="120" x2="280" y2="120" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||||
|
'<line x1="30" y1="20" x2="30" y2="130" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||||
|
'<path d="M30 120 Q120 30 270 110" fill="none" stroke="#06D6E0" stroke-width="2.5"/>' +
|
||||||
|
'<circle cx="150" cy="64" r="5" fill="#9B5DE5"/>' +
|
||||||
|
'<text x="150" y="135" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="10" font-family="Manrope,sans-serif">' +
|
||||||
|
_esc(title) + '</text></svg>';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Главная функция ── */
|
||||||
|
function registerSpecSim(spec) {
|
||||||
|
if (!global.LabRegistry) {
|
||||||
|
if (global.console) console.warn('[registerSpecSim] LabRegistry недоступен');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!spec || !spec.id) {
|
||||||
|
if (global.console) console.warn('[registerSpecSim] спека без id');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var id = spec.id;
|
||||||
|
var _inst = null; // активный SimEngine-инстанс этой симуляции
|
||||||
|
|
||||||
|
var manifest = {
|
||||||
|
id: id,
|
||||||
|
cat: spec.cat || 'phys',
|
||||||
|
title: (spec.meta && spec.meta.title) || spec.title || id,
|
||||||
|
desc: (spec.meta && spec.meta.desc) || spec.desc || '',
|
||||||
|
preview: buildPreview(spec),
|
||||||
|
theory: spec.theory || null,
|
||||||
|
subject: spec.subject,
|
||||||
|
grade: spec.grade,
|
||||||
|
topics: spec.topics,
|
||||||
|
_spec: spec, // храним исходную спеку (билдеру/доске пригодится)
|
||||||
|
|
||||||
|
open: function (ctx) {
|
||||||
|
hideAllSpecHosts();
|
||||||
|
var host = ensureHost(id);
|
||||||
|
host.style.display = 'flex';
|
||||||
|
setTitle(spec);
|
||||||
|
// пере-смонтировать заново на каждый open (чистое состояние)
|
||||||
|
if (_inst) { try { _inst.destroy(); } catch (e) {} _inst = null; }
|
||||||
|
host.innerHTML = '';
|
||||||
|
if (global.SimEngine) {
|
||||||
|
_inst = global.SimEngine.mount(host, spec);
|
||||||
|
manifest._instance = _inst;
|
||||||
|
} else if (global.console) {
|
||||||
|
console.warn('[registerSpecSim] SimEngine недоступен для', id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: function () {
|
||||||
|
if (_inst) { try { _inst.pause(); } catch (e) {} }
|
||||||
|
var host = document.getElementById(HOST_PREFIX + id);
|
||||||
|
if (host) host.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function () {
|
||||||
|
if (_inst) { try { _inst.destroy(); } catch (e) {} _inst = null; }
|
||||||
|
manifest._instance = null;
|
||||||
|
var host = document.getElementById(HOST_PREFIX + id);
|
||||||
|
if (host) host.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
// доступ к живому инстансу (доска онлайн-урока, билдер — Фазы 4/7)
|
||||||
|
instance: function () { return _inst; }
|
||||||
|
};
|
||||||
|
|
||||||
|
return global.LabRegistry.register(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.registerSpecSim = registerSpecSim;
|
||||||
|
global.SimAdapter = {
|
||||||
|
register: registerSpecSim,
|
||||||
|
ensureHost: ensureHost,
|
||||||
|
hideAllSpecHosts: hideAllSpecHosts
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
_sim_demo — рукописная демо-спека «Бросок тела» (projectile) для проверки
|
||||||
|
рантайма Фазы 0. Регистрируется как id 'customdemo' ТОЛЬКО за флагом — не
|
||||||
|
светится ученикам в каталоге (карточка не добавляется в SIMS).
|
||||||
|
|
||||||
|
Включение для проверки (любой из вариантов):
|
||||||
|
- URL: /lab?simdemo=1 (или ?sim=customdemo прямой deep-link)
|
||||||
|
- глоб: window.LAB_SHOW_SPEC_DEMO = true (до загрузки labs-скриптов)
|
||||||
|
- localStorage.setItem('lab-spec-demo','1')
|
||||||
|
|
||||||
|
Проверка: открыть /lab?simdemo=1 -> карточка появится; либо открыть
|
||||||
|
/lab?sim=customdemo напрямую. Слайдеры угла/скорости меняют траекторию,
|
||||||
|
play/pause/reset работают. Это ВРЕМЕННЫЙ раздел (удалить после Фазы 4).
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
function demoEnabled() {
|
||||||
|
try {
|
||||||
|
var qs = (global.location && global.location.search) || '';
|
||||||
|
if (/[?&]simdemo=1\b/.test(qs)) return true;
|
||||||
|
if (/[?&]sim=customdemo\b/.test(qs)) return true;
|
||||||
|
if (global.LAB_SHOW_SPEC_DEMO === true) return true;
|
||||||
|
if (global.localStorage && global.localStorage.getItem('lab-spec-demo') === '1') return true;
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спека v1+ (Фаза 1): бросок тела. g фиксирован 10 -> y = y0 + v*sin(θ)*t - 5*t^2.
|
||||||
|
// Старт (x0,y0) — перетаскиваемая ручка (drag). plot — статическая параболическая
|
||||||
|
// траектория y(x); readout — дальность и макс. высота. Вектор v0 — origin+dx/dy.
|
||||||
|
var PROJECTILE_DEMO = {
|
||||||
|
id: 'customdemo',
|
||||||
|
cat: 'phys',
|
||||||
|
meta: { title: 'Демо: бросок тела', desc: 'Спек-симуляция (Фаза 1): слайдеры, drag-старт, график, readout.' },
|
||||||
|
viewport: { xmin: 0, xmax: 60, ymin: 0, ymax: 30, grid: true, axes: true, bg: '#0D0D1A' },
|
||||||
|
time: { autoplay: false, loop: true, duration: 8, speed: 1 },
|
||||||
|
params: [
|
||||||
|
{ name: 'theta', label: 'Угол θ', min: 0, max: 90, step: 1, value: 45, unit: '°' },
|
||||||
|
{ name: 'v', label: 'Скорость v', min: 0, max: 30, step: 0.5, value: 20, unit: 'м/с' },
|
||||||
|
{ name: 'x0', label: 'Старт X', min: 0, max: 20, step: 0.5, value: 2, unit: 'м' },
|
||||||
|
{ name: 'y0', label: 'Старт Y', min: 0, max: 25, step: 0.5, value: 0, unit: 'м' }
|
||||||
|
],
|
||||||
|
objects: [
|
||||||
|
// снаряд: x = x0 + v*cos(θ)*t, y = y0 + v*sin(θ)*t - 5 t^2 (но не ниже 0)
|
||||||
|
{
|
||||||
|
id: 'ball', type: 'point',
|
||||||
|
x: 'x0 + v*cos(theta*pi/180)*t',
|
||||||
|
y: 'max(0, y0 + v*sin(theta*pi/180)*t - 5*t^2)',
|
||||||
|
r: 7, color: '#06D6E0', trail: true, trailColor: '#9B5DE5'
|
||||||
|
},
|
||||||
|
// график траектории y(x): парабола броска, var=x на [x0, x0+дальность].
|
||||||
|
// y(x) = y0 + tan(θ)(x-x0) - g(x-x0)^2/(2 v^2 cos^2θ), g=10.
|
||||||
|
{
|
||||||
|
type: 'plot', color: '#FFD166', width: 1.6,
|
||||||
|
var: 'x', range: ['x0', 'x0 + v*v*sin(2*theta*pi/180)/10 + 0.001'],
|
||||||
|
samples: 200,
|
||||||
|
expr: 'max(0, y0 + tan(theta*pi/180)*(x-x0) - 10*(x-x0)^2/(2*v*v*cos(theta*pi/180)^2))'
|
||||||
|
},
|
||||||
|
// перетаскиваемая ручка старта (drag по обеим осям -> x0/y0)
|
||||||
|
{
|
||||||
|
id: 'start', type: 'point',
|
||||||
|
x: 'x0', y: 'y0', r: 8, color: '#EF476F',
|
||||||
|
drag: { axis: 'xy', param: 'x0', paramY: 'y0', min: 0, max: 25 }
|
||||||
|
},
|
||||||
|
// вектор начальной скорости из старта (origin + dx/dy)
|
||||||
|
{
|
||||||
|
type: 'vector',
|
||||||
|
origin: ['x0', 'y0'],
|
||||||
|
dx: 'cos(theta*pi/180)*v*0.4',
|
||||||
|
dy: 'sin(theta*pi/180)*v*0.4',
|
||||||
|
color: '#FFD166', width: 3
|
||||||
|
},
|
||||||
|
// земля
|
||||||
|
{ type: 'segment', x1: 0, y1: 0, x2: 60, y2: 0, color: 'rgba(255,255,255,0.35)', width: 2 },
|
||||||
|
// подпись над снарядом
|
||||||
|
{
|
||||||
|
type: 'label', latex: true,
|
||||||
|
x: 'ball.x', y: 'ball.y + 2.5',
|
||||||
|
text: 'v_0', color: '#06D6E0', size: 15
|
||||||
|
},
|
||||||
|
// readout: дальность полёта R = v^2 sin(2θ)/g (для y0=0) и макс. высота H
|
||||||
|
{
|
||||||
|
type: 'readout', label: 'R', unit: 'м', precision: 1, color: '#FFD166',
|
||||||
|
expr: 'x0 + v*v*sin(2*theta*pi/180)/10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'readout', label: 'H', unit: 'м', precision: 1, color: '#06D6E0',
|
||||||
|
expr: 'y0 + (v*sin(theta*pi/180))^2/20'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Фаза 2: физ-демо за флагом ── */
|
||||||
|
|
||||||
|
// Маятник на пружине: груз (тело) подвешен пружиной к неподвижному якорю.
|
||||||
|
// Гравитация тянет вниз, пружина возвращает -> колебания. Груз можно тащить.
|
||||||
|
var PENDULUM_DEMO = {
|
||||||
|
id: 'customphys',
|
||||||
|
cat: 'phys',
|
||||||
|
meta: { title: 'Демо: пружинный маятник', desc: 'Спек-физика (Фаза 2): тело + пружина + гравитация, drag тела.' },
|
||||||
|
viewport: { xmin: -6, xmax: 6, ymin: -10, ymax: 2, grid: true, axes: true, bg: '#0D0D1A' },
|
||||||
|
time: { autoplay: true, loop: false, speed: 1 },
|
||||||
|
params: [
|
||||||
|
{ name: 'k', label: 'Жёсткость k', min: 5, max: 120, step: 1, value: 40, unit: 'Н/м' },
|
||||||
|
{ name: 'm', label: 'Масса m', min: 0.5, max: 5, step: 0.1, value: 1, unit: 'кг' },
|
||||||
|
{ name: 'L', label: 'Длина L', min: 2, max: 8, step: 0.1, value: 5, unit: 'м' }
|
||||||
|
],
|
||||||
|
physics: {
|
||||||
|
enabled: true,
|
||||||
|
gravity: { x: 0, y: -9.8 },
|
||||||
|
friction: 0.15,
|
||||||
|
restitution: 0.7,
|
||||||
|
walls: [{ side: 'bottom' }],
|
||||||
|
springs: [{ a: [0, 0], b: 'bob', k: 'k', length: 'L', damping: 0.4 }]
|
||||||
|
},
|
||||||
|
objects: [
|
||||||
|
// якорь подвеса
|
||||||
|
{ type: 'circle', x: 0, y: 0, r: 0.18, color: '#FFD166', fill: '#FFD166' },
|
||||||
|
// груз — физическое тело, стартует сбоку (выведено из равновесия), след включён
|
||||||
|
{
|
||||||
|
id: 'bob', type: 'circle', r: 0.6, color: '#06D6E0', fill: 'rgba(6,214,224,0.25)',
|
||||||
|
x: '3', y: '-4', trail: true, trailColor: '#9B5DE5',
|
||||||
|
body: { mass: 'm', vx: 0, vy: 0 }
|
||||||
|
},
|
||||||
|
{ type: 'label', latex: true, x: 'bob.x', y: 'bob.y - 1.1', text: 'm', color: '#06D6E0', size: 14 },
|
||||||
|
// живые показания скорости
|
||||||
|
{ type: 'readout', label: 'v_y', unit: 'м/с', precision: 2, color: '#FFD166', expr: 'bob.vy' },
|
||||||
|
{ type: 'readout', label: 'y', unit: 'м', precision: 2, color: '#06D6E0', expr: 'bob.y' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: масса груза задаётся выражением 'm' (param). При reset тело пересобирается
|
||||||
|
// с актуальной массой/нач.условиями; пружина length='L', k='k' пересчитываются тоже.
|
||||||
|
|
||||||
|
// Упругие шары: 3 тела в коробке из стен, разные начальные скорости, упругие
|
||||||
|
// столкновения друг с другом и со стенами. Гравитация мягкая.
|
||||||
|
var BALLS_DEMO = {
|
||||||
|
id: 'customballs',
|
||||||
|
cat: 'phys',
|
||||||
|
meta: { title: 'Демо: упругие шары', desc: 'Спек-физика (Фаза 2): столкновения круг-круг и круг-стена.' },
|
||||||
|
viewport: { xmin: 0, xmax: 12, ymin: 0, ymax: 9, grid: true, axes: false, bg: '#0D0D1A' },
|
||||||
|
time: { autoplay: true, loop: false, speed: 1 },
|
||||||
|
params: [
|
||||||
|
{ name: 'g', label: 'Гравитация', min: 0, max: 12, step: 0.5, value: 4, unit: 'м/с²' },
|
||||||
|
// NB: имя 'e' зарезервировано (число Эйлера в SimExpr) — используем 'el' для упругости.
|
||||||
|
{ name: 'el', label: 'Упругость', min: 0.5, max: 1, step: 0.02, value: 0.96 }
|
||||||
|
],
|
||||||
|
physics: {
|
||||||
|
enabled: true,
|
||||||
|
gravity: { x: 0, y: '-g' }, // gravity.y — выражение от param g (вычисляется на reset)
|
||||||
|
friction: 0,
|
||||||
|
restitution: 'el', // упругость от param el
|
||||||
|
walls: [{ side: 'bottom' }, { side: 'top' }, { side: 'left' }, { side: 'right' }]
|
||||||
|
},
|
||||||
|
objects: [
|
||||||
|
{ id: 'b1', type: 'circle', r: 0.7, color: '#06D6E0', fill: 'rgba(6,214,224,0.3)',
|
||||||
|
x: 2, y: 4.5, body: { mass: 1, vx: 6, vy: 2.4 }, trail: true, trailColor: '#06D6E0' },
|
||||||
|
{ id: 'b2', type: 'circle', r: 1.0, color: '#9B5DE5', fill: 'rgba(155,93,229,0.3)',
|
||||||
|
x: 8, y: 6, body: { mass: 2, vx: -4, vy: -3 }, trail: true, trailColor: '#9B5DE5' },
|
||||||
|
{ id: 'b3', type: 'circle', r: 0.5, color: '#FFD166', fill: 'rgba(255,209,102,0.3)',
|
||||||
|
x: 6, y: 2, body: { mass: 0.6, vx: 3, vy: 5 }, trail: true, trailColor: '#FFD166' },
|
||||||
|
{ type: 'readout', label: 'b2.vx', precision: 2, color: '#9B5DE5', expr: 'b2.vx' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var DEMOS = [PROJECTILE_DEMO, PENDULUM_DEMO, BALLS_DEMO];
|
||||||
|
|
||||||
|
function tryRegister() {
|
||||||
|
if (!demoEnabled()) return;
|
||||||
|
if (typeof global.registerSpecSim !== 'function') {
|
||||||
|
if (global.console) console.warn('[sim-demo] registerSpecSim недоступен');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < DEMOS.length; i++) global.registerSpecSim(DEMOS[i]);
|
||||||
|
|
||||||
|
// Если каталог уже отрисован, добавить карточки демо вручную (минимально-
|
||||||
|
// инвазивно: только когда флаг включён; не трогаем SIMS/каталожный рендер).
|
||||||
|
addDemoCardsIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDemoCardsIfNeeded() {
|
||||||
|
var grid = document.getElementById('sim-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
for (var i = 0; i < DEMOS.length; i++) {
|
||||||
|
var id = DEMOS[i].id;
|
||||||
|
if (document.getElementById('sim-card-' + id)) continue;
|
||||||
|
var m = global.LabRegistry && global.LabRegistry.get(id);
|
||||||
|
if (!m) continue;
|
||||||
|
var preview = global.LabRegistry.resolvePreview(m);
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.id = 'sim-card-' + id;
|
||||||
|
card.className = 'sim-card';
|
||||||
|
card.setAttribute('onclick', "openSim('" + id + "')");
|
||||||
|
card.innerHTML = preview +
|
||||||
|
'<div class="sim-body">' +
|
||||||
|
'<span class="sim-cat ' + (m.cat || 'phys') + '">демо</span>' +
|
||||||
|
'<div class="sim-title">' + esc(m.title) + '</div>' +
|
||||||
|
'<div class="sim-desc">' + esc(m.desc || '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зарегистрировать после готовности DOM/реестра. _register-all.js грузится
|
||||||
|
// последним (defer); этот файл — после него, поэтому LabRegistry уже есть.
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', tryRegister);
|
||||||
|
} else {
|
||||||
|
tryRegister();
|
||||||
|
}
|
||||||
|
|
||||||
|
// экспонируем для ручной проверки из консоли
|
||||||
|
global.LAB_SPEC_DEMO = PROJECTILE_DEMO;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
SimExpr — безопасный движок выражений для конструктора симуляций (Фаза 0).
|
||||||
|
|
||||||
|
Спека симуляции — это ДАННЫЕ, которые шарятся между людьми, поэтому код
|
||||||
|
выражений НИКОГДА не исполняется через eval/new Function. Здесь — собственный
|
||||||
|
конвейер: токенайзер → AST → evaluate(ast, env). Логика расширяет парсер
|
||||||
|
y=f(x) из graph.js (тот же подход к токенам и неявному умножению), но:
|
||||||
|
- окружение многопеременное: любой идентификатор берётся из env (params, t,
|
||||||
|
значения объектов), а не только x;
|
||||||
|
- добавлены сравнения (< <= > >= == !=), логика (&& ||), тернарник ?:,
|
||||||
|
функции min/max/mod/log(base,x) и константы pi/e;
|
||||||
|
- результат компиляции — AST + замыкание fn(env), считается детерминированно.
|
||||||
|
|
||||||
|
Ошибки времени выполнения (деление на 0, NaN, неизвестный идентификатор) НЕ
|
||||||
|
кидаются из fn(env): они дают 0 (или флаг через evalSafe), чтобы один кривой
|
||||||
|
кадр не ронял весь рантайм. Ошибки КОМПИЛЯЦИИ (синтаксис) возвращаются строкой
|
||||||
|
в compile(src).error и не бросаются.
|
||||||
|
|
||||||
|
API:
|
||||||
|
SimExpr.compile(src) -> { ast, fn, error }
|
||||||
|
fn(env) -> number (никогда не бросает; при сбое -> 0)
|
||||||
|
SimExpr.evaluate(ast, env) -> number (никогда не бросает; при сбое -> 0)
|
||||||
|
SimExpr.evalSafe(ast, env) -> { value, error } (для отладки/билдера)
|
||||||
|
SimExpr.FUNCTIONS -> Set имён whitelisted-функций (для подсветки/билдера)
|
||||||
|
SimExpr.CONSTANTS -> Set имён констант (pi, e)
|
||||||
|
|
||||||
|
env — простой объект { имя: number }. Имена объектов спеки удобно передавать
|
||||||
|
как "obj.x"/"obj.y" — для этого допускается точка в идентификаторе.
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function (global) {
|
||||||
|
|
||||||
|
/* ── whitelist функций (имя -> арность: 1, 2 или -1 для переменной) ── */
|
||||||
|
// -1 => принимает >=1 аргумент (min/max). log: 1 арг = ln по основанию e? нет —
|
||||||
|
// log(x) трактуем как десятичный (как в graph.js log===log10), log(b,x)=log_b(x).
|
||||||
|
var FN_ARITY = {
|
||||||
|
sin: 1, cos: 1, tan: 1, tg: 1, ctg: 1, cot: 1,
|
||||||
|
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
|
||||||
|
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
|
||||||
|
floor: 1, ceil: 1, round: 1, sign: 1,
|
||||||
|
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
|
||||||
|
// там, где можно дать NaN -> наружу станет 0).
|
||||||
|
var FN_IMPL = {
|
||||||
|
sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan,
|
||||||
|
ctg: function (x) { return 1 / Math.tan(x); },
|
||||||
|
cot: function (x) { return 1 / Math.tan(x); },
|
||||||
|
asin: Math.asin, acos: Math.acos, atan: Math.atan,
|
||||||
|
arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
|
||||||
|
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
|
||||||
|
ln: Math.log,
|
||||||
|
log: function (a, b) {
|
||||||
|
// log(x) -> log10(x); log(base, x) -> log_base(x)
|
||||||
|
if (b === undefined) return Math.log(a) / Math.LN10;
|
||||||
|
return Math.log(b) / Math.log(a);
|
||||||
|
},
|
||||||
|
log2: Math.log2, log10: Math.log10,
|
||||||
|
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
|
||||||
|
min: Math.min, max: Math.max,
|
||||||
|
mod: function (a, b) { return b === 0 ? 0 : a % b; },
|
||||||
|
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot
|
||||||
|
};
|
||||||
|
|
||||||
|
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
|
||||||
|
|
||||||
|
/* ════════════════════ TOKENIZER ════════════════════ */
|
||||||
|
|
||||||
|
function tokenize(src) {
|
||||||
|
var out = [];
|
||||||
|
var i = 0, n = src.length;
|
||||||
|
while (i < n) {
|
||||||
|
var ch = src[i];
|
||||||
|
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
|
||||||
|
|
||||||
|
/* число */
|
||||||
|
if ((ch >= '0' && ch <= '9') || (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
||||||
|
var j = i;
|
||||||
|
while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||||
|
if (j < n && src[j] === '.') { j++; while (j < n && src[j] >= '0' && src[j] <= '9') j++; }
|
||||||
|
if (j < n && (src[j] === 'e' || src[j] === 'E')) {
|
||||||
|
var k = j + 1;
|
||||||
|
if (k < n && (src[k] === '+' || src[k] === '-')) k++;
|
||||||
|
if (k < n && src[k] >= '0' && src[k] <= '9') {
|
||||||
|
j = k; while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({ t: 'num', v: parseFloat(src.slice(i, j)) });
|
||||||
|
i = j; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* идентификатор (буквы/цифры/_/.) — точка допускает obj.x */
|
||||||
|
if (isIdentStart(ch)) {
|
||||||
|
var p = i + 1;
|
||||||
|
while (p < n && isIdentPart(src[p])) p++;
|
||||||
|
out.push({ t: 'id', v: src.slice(i, p) });
|
||||||
|
i = p; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* двухсимвольные операторы */
|
||||||
|
var two = src.substr(i, 2);
|
||||||
|
if (two === '<=' || two === '>=' || two === '==' || two === '!=' ||
|
||||||
|
two === '&&' || two === '||') {
|
||||||
|
out.push({ t: 'op', v: two }); i += 2; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* односимвольные операторы / скобки / запятая / ?: */
|
||||||
|
if ('+-*/^%()<>?:,'.indexOf(ch) !== -1) {
|
||||||
|
out.push({ t: 'op', v: ch }); i++; continue;
|
||||||
|
}
|
||||||
|
// одиночный '=' трактуем как сравнение (часто пишут a=b по привычке)
|
||||||
|
if (ch === '=') { out.push({ t: 'op', v: '==' }); i++; continue; }
|
||||||
|
// одиночный '!' — логическое НЕ
|
||||||
|
if (ch === '!') { out.push({ t: 'op', v: '!' }); i++; continue; }
|
||||||
|
|
||||||
|
throw new Error('Неизвестный символ: «' + ch + '»');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentStart(ch) {
|
||||||
|
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
|
||||||
|
}
|
||||||
|
function isIdentPart(ch) {
|
||||||
|
return isIdentStart(ch) || (ch >= '0' && ch <= '9') || ch === '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вставка неявного умножения: 2x -> 2*x, 2(.. -> 2*(.., )( -> )*(, )x -> )*x.
|
||||||
|
Функция перед '(' умножение НЕ получает. */
|
||||||
|
function insertImplicitMul(tokens) {
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < tokens.length; i++) {
|
||||||
|
out.push(tokens[i]);
|
||||||
|
var cur = tokens[i], nxt = tokens[i + 1];
|
||||||
|
if (!nxt) continue;
|
||||||
|
var curIsFn = cur.t === 'id' && Object.prototype.hasOwnProperty.call(FN_ARITY, cur.v);
|
||||||
|
var curEnds = cur.t === 'num' ||
|
||||||
|
(cur.t === 'id' && !curIsFn) ||
|
||||||
|
(cur.t === 'op' && cur.v === ')');
|
||||||
|
var nxtStarts = nxt.t === 'num' ||
|
||||||
|
nxt.t === 'id' ||
|
||||||
|
(nxt.t === 'op' && nxt.v === '(');
|
||||||
|
// не вставляем '*' если cur — функция (тогда дальше идёт её '(')
|
||||||
|
if (curEnds && nxtStarts) out.push({ t: 'op', v: '*' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════ PARSER (рекурсивный спуск -> AST) ════════════════════
|
||||||
|
|
||||||
|
Грамматика (по убыванию приоритета связывания):
|
||||||
|
ternary := logicOr ('?' ternary ':' ternary)?
|
||||||
|
logicOr := logicAnd ('||' logicAnd)*
|
||||||
|
logicAnd := compare ('&&' compare)*
|
||||||
|
compare := addSub (('<'|'<='|'>'|'>='|'=='|'!=') addSub)?
|
||||||
|
addSub := mulDiv (('+'|'-') mulDiv)*
|
||||||
|
mulDiv := power (('*'|'/'|'%') power)*
|
||||||
|
power := unary ('^' power)? // правоассоциативно
|
||||||
|
unary := ('-'|'+'|'!') unary | primary
|
||||||
|
primary := num | ident | const | fn(args...) | '(' ternary ')'
|
||||||
|
|
||||||
|
Узлы AST:
|
||||||
|
{ k:'num', v }
|
||||||
|
{ k:'var', name }
|
||||||
|
{ k:'const', v }
|
||||||
|
{ k:'bin', op, a, b }
|
||||||
|
{ k:'un', op, a }
|
||||||
|
{ k:'cmp', op, a, b }
|
||||||
|
{ k:'logic', op, a, b }
|
||||||
|
{ k:'not', a }
|
||||||
|
{ k:'cond', c, a, b }
|
||||||
|
{ k:'call', name, args:[...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parse(tokens) {
|
||||||
|
var pos = 0;
|
||||||
|
function peek() { return tokens[pos]; }
|
||||||
|
function next() { return tokens[pos++]; }
|
||||||
|
function expect(v) {
|
||||||
|
var t = peek();
|
||||||
|
if (!t || t.v !== v) throw new Error('Ожидалось «' + v + '»');
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
function isOp(v) { var t = peek(); return t && t.t === 'op' && t.v === v; }
|
||||||
|
|
||||||
|
function ternary() {
|
||||||
|
var c = logicOr();
|
||||||
|
if (isOp('?')) {
|
||||||
|
next();
|
||||||
|
var a = ternary();
|
||||||
|
expect(':');
|
||||||
|
var b = ternary();
|
||||||
|
return { k: 'cond', c: c, a: a, b: b };
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
function logicOr() {
|
||||||
|
var l = logicAnd();
|
||||||
|
while (isOp('||')) { next(); l = { k: 'logic', op: '||', a: l, b: logicAnd() }; }
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function logicAnd() {
|
||||||
|
var l = compare();
|
||||||
|
while (isOp('&&')) { next(); l = { k: 'logic', op: '&&', a: l, b: compare() }; }
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function compare() {
|
||||||
|
var l = addSub();
|
||||||
|
var t = peek();
|
||||||
|
if (t && t.t === 'op' && (t.v === '<' || t.v === '<=' || t.v === '>' ||
|
||||||
|
t.v === '>=' || t.v === '==' || t.v === '!=')) {
|
||||||
|
var op = next().v;
|
||||||
|
return { k: 'cmp', op: op, a: l, b: addSub() };
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function addSub() {
|
||||||
|
var l = mulDiv();
|
||||||
|
while (isOp('+') || isOp('-')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: mulDiv() }; }
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function mulDiv() {
|
||||||
|
var l = power();
|
||||||
|
while (isOp('*') || isOp('/') || isOp('%')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: power() }; }
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function power() {
|
||||||
|
var base = unary();
|
||||||
|
if (isOp('^')) { next(); return { k: 'bin', op: '^', a: base, b: power() }; }
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
function unary() {
|
||||||
|
if (isOp('-')) { next(); return { k: 'un', op: '-', a: unary() }; }
|
||||||
|
if (isOp('+')) { next(); return unary(); }
|
||||||
|
if (isOp('!')) { next(); return { k: 'not', a: unary() }; }
|
||||||
|
return primary();
|
||||||
|
}
|
||||||
|
function primary() {
|
||||||
|
var t = peek();
|
||||||
|
if (!t) throw new Error('Неожиданный конец выражения');
|
||||||
|
|
||||||
|
if (t.t === 'num') { next(); return { k: 'num', v: t.v }; }
|
||||||
|
|
||||||
|
if (t.t === 'id') {
|
||||||
|
next();
|
||||||
|
var name = t.v;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(FN_ARITY, name)) {
|
||||||
|
expect('(');
|
||||||
|
var args = [];
|
||||||
|
if (!isOp(')')) {
|
||||||
|
args.push(ternary());
|
||||||
|
while (isOp(',')) { next(); args.push(ternary()); }
|
||||||
|
}
|
||||||
|
expect(')');
|
||||||
|
checkArity(name, args.length);
|
||||||
|
return { k: 'call', name: name, args: args };
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(CONSTANTS, name)) {
|
||||||
|
return { k: 'const', v: CONSTANTS[name] };
|
||||||
|
}
|
||||||
|
// переменная окружения
|
||||||
|
return { k: 'var', name: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.t === 'op' && t.v === '(') {
|
||||||
|
next();
|
||||||
|
var e = ternary();
|
||||||
|
expect(')');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Неожиданный токен: «' + t.v + '»');
|
||||||
|
}
|
||||||
|
|
||||||
|
var ast = ternary();
|
||||||
|
if (pos !== tokens.length) throw new Error('Лишние токены после выражения');
|
||||||
|
return ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkArity(name, got) {
|
||||||
|
var ar = FN_ARITY[name];
|
||||||
|
if (ar === -1) { // >=1
|
||||||
|
if (got < 1) throw new Error('Функции «' + name + '» нужен хотя бы 1 аргумент');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ar === -2) { // 1..2 (log)
|
||||||
|
if (got < 1 || got > 2) throw new Error('Функция «' + name + '» принимает 1 или 2 аргумента');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (got !== ar) throw new Error('Функция «' + name + '» принимает ' + ar + ' арг., дано ' + got);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════ EVALUATE ════════════════════
|
||||||
|
Чистый интерпретатор AST по окружению env. НЕ бросает наружу (см. evaluate).
|
||||||
|
Внутренний _ev может вернуть NaN/Infinity — нормализуется в evaluate. */
|
||||||
|
|
||||||
|
function _ev(node, env) {
|
||||||
|
switch (node.k) {
|
||||||
|
case 'num': return node.v;
|
||||||
|
case 'const': return node.v;
|
||||||
|
case 'var': {
|
||||||
|
var val = env ? env[node.name] : undefined;
|
||||||
|
return typeof val === 'number' ? val : 0; // неизвестная переменная -> 0
|
||||||
|
}
|
||||||
|
case 'un': // только '-'
|
||||||
|
return -_ev(node.a, env);
|
||||||
|
case 'not':
|
||||||
|
return _ev(node.a, env) ? 0 : 1;
|
||||||
|
case 'bin': {
|
||||||
|
var a = _ev(node.a, env), b = _ev(node.b, env);
|
||||||
|
switch (node.op) {
|
||||||
|
case '+': return a + b;
|
||||||
|
case '-': return a - b;
|
||||||
|
case '*': return a * b;
|
||||||
|
case '/': return b === 0 ? 0 : a / b; // деление на 0 -> 0
|
||||||
|
case '%': return b === 0 ? 0 : a % b;
|
||||||
|
case '^': return Math.pow(a, b);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'cmp': {
|
||||||
|
var x = _ev(node.a, env), y = _ev(node.b, env);
|
||||||
|
switch (node.op) {
|
||||||
|
case '<': return x < y ? 1 : 0;
|
||||||
|
case '<=': return x <= y ? 1 : 0;
|
||||||
|
case '>': return x > y ? 1 : 0;
|
||||||
|
case '>=': return x >= y ? 1 : 0;
|
||||||
|
case '==': return x === y ? 1 : 0;
|
||||||
|
case '!=': return x !== y ? 1 : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case 'logic': {
|
||||||
|
if (node.op === '&&') return (_ev(node.a, env) && _ev(node.b, env)) ? 1 : 0;
|
||||||
|
return (_ev(node.a, env) || _ev(node.b, env)) ? 1 : 0;
|
||||||
|
}
|
||||||
|
case 'cond':
|
||||||
|
return _ev(node.c, env) ? _ev(node.a, env) : _ev(node.b, env);
|
||||||
|
case 'call': {
|
||||||
|
var fn = FN_IMPL[node.name];
|
||||||
|
var args = node.args;
|
||||||
|
if (args.length === 1) return fn(_ev(args[0], env));
|
||||||
|
if (args.length === 2) return fn(_ev(args[0], env), _ev(args[1], env));
|
||||||
|
// переменное число (min/max/hypot)
|
||||||
|
var vals = new Array(args.length);
|
||||||
|
for (var i = 0; i < args.length; i++) vals[i] = _ev(args[i], env);
|
||||||
|
return fn.apply(null, vals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Никогда не бросает; NaN/Infinity -> 0.
|
||||||
|
function evaluate(ast, env) {
|
||||||
|
if (!ast) return 0;
|
||||||
|
var v;
|
||||||
|
try { v = _ev(ast, env); } catch (e) { return 0; }
|
||||||
|
return (typeof v === 'number' && isFinite(v)) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для отладки/билдера: возвращает значение + флаг (NaN/Infinity -> error).
|
||||||
|
function evalSafe(ast, env) {
|
||||||
|
if (!ast) return { value: 0, error: 'нет выражения' };
|
||||||
|
var v;
|
||||||
|
try { v = _ev(ast, env); } catch (e) { return { value: 0, error: String(e.message || e) }; }
|
||||||
|
if (typeof v !== 'number' || !isFinite(v)) return { value: 0, error: 'не число (NaN/∞)' };
|
||||||
|
return { value: v, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════ COMPILE ════════════════════
|
||||||
|
Возвращает { ast, fn, error }. Синтаксическая ошибка -> error:строка, fn:()=>0. */
|
||||||
|
|
||||||
|
function compile(src) {
|
||||||
|
if (src == null) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||||
|
var raw = String(src).trim();
|
||||||
|
// допускаем ведущее "y=" / "name=" (привычка), срезаем как в graph.js
|
||||||
|
raw = raw.replace(/^\s*[a-zA-Z_][a-zA-Z_0-9.]*\s*=(?![=])\s*/, '');
|
||||||
|
if (!raw) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||||
|
var ast;
|
||||||
|
try {
|
||||||
|
var toks = insertImplicitMul(tokenize(raw));
|
||||||
|
ast = parse(toks);
|
||||||
|
} catch (e) {
|
||||||
|
return { ast: null, fn: function () { return 0; }, error: String(e.message || e) };
|
||||||
|
}
|
||||||
|
var fn = function (env) { return evaluate(ast, env); };
|
||||||
|
return { ast: ast, fn: fn, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Хелпер: значение — число вернуть как есть; строка — скомпилировать и вернуть
|
||||||
|
{ fn(env), error, constant }. Используется движком для свойств-привязок. */
|
||||||
|
function compileValue(value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
var c = value;
|
||||||
|
return { fn: function () { return c; }, error: null, constant: true, ast: { k: 'num', v: c } };
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
var r = compile(value);
|
||||||
|
return { fn: r.fn, error: r.error, constant: false, ast: r.ast };
|
||||||
|
}
|
||||||
|
// прочие типы -> 0
|
||||||
|
return { fn: function () { return 0; }, error: null, constant: true, ast: { k: 'num', v: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
var FUNCTIONS = {}; // имя -> true (как «множество» без ES6 Set ради совместимости)
|
||||||
|
Object.keys(FN_ARITY).forEach(function (k) { FUNCTIONS[k] = true; });
|
||||||
|
var CONSTSET = {};
|
||||||
|
Object.keys(CONSTANTS).forEach(function (k) { CONSTSET[k] = true; });
|
||||||
|
|
||||||
|
global.SimExpr = {
|
||||||
|
compile: compile,
|
||||||
|
compileValue: compileValue,
|
||||||
|
evaluate: evaluate,
|
||||||
|
evalSafe: evalSafe,
|
||||||
|
tokenize: tokenize, // экспортируем для тестов/билдера
|
||||||
|
parse: function (src) { // удобный helper: строка -> AST (бросает при ошибке)
|
||||||
|
return parse(insertImplicitMul(tokenize(String(src).trim())));
|
||||||
|
},
|
||||||
|
FUNCTIONS: FUNCTIONS,
|
||||||
|
CONSTANTS: CONSTSET
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -32,7 +32,9 @@
|
|||||||
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
||||||
if (s.id) _seen[s.id] = 1;
|
if (s.id) _seen[s.id] = 1;
|
||||||
});
|
});
|
||||||
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
|
// Конструктор симуляций (Фаза 5): custom-sims рендерятся отдельной секцией
|
||||||
|
// «Мои симуляции» (см. LabCustom) — исключаем их из основной сетки встроенных.
|
||||||
|
_reg.forEach(m => { if (!_seen[m.id] && !m._custom) _merged.push(m); });
|
||||||
|
|
||||||
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
||||||
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
||||||
@@ -46,6 +48,10 @@
|
|||||||
</div>
|
</div>
|
||||||
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
|
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
// Конструктор симуляций (Фаза 5): дорисовать секцию «Мои симуляции».
|
||||||
|
if (window.LabCustom && typeof window.LabCustom.renderSection === 'function') {
|
||||||
|
try { window.LabCustom.renderSection(_catFilter); } catch (e) {}
|
||||||
|
}
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,8 +336,39 @@ const SIMS = [
|
|||||||
// Map simId → { getState, applyState } registered by openSim handlers
|
// Map simId → { getState, applyState } registered by openSim handlers
|
||||||
const _simStateRegistry = {};
|
const _simStateRegistry = {};
|
||||||
|
|
||||||
|
/* ── Локальный персист параметров симуляции (Фаза 2) ──────────────────
|
||||||
|
Поверх того же getState/applyState: в обычном (не embed) режиме сохраняем
|
||||||
|
состояние активной симуляции в localStorage и восстанавливаем при открытии.
|
||||||
|
В embed/онлайн-уроке состоянием управляет учитель — персист отключён. */
|
||||||
|
const _LAB_STATE_KEY = 'lab-sim-state-v1';
|
||||||
|
function _loadSavedStates() { try { return JSON.parse(localStorage.getItem(_LAB_STATE_KEY) || '{}') || {}; } catch (e) { return {}; } }
|
||||||
|
function _saveSavedStates(m) { try { localStorage.setItem(_LAB_STATE_KEY, JSON.stringify(m)); } catch (e) {} }
|
||||||
|
let _persistSimId = null, _persistInterval = null, _lastPersisted = null;
|
||||||
|
function _stopPersist() { if (_persistInterval) { clearInterval(_persistInterval); _persistInterval = null; } _persistSimId = null; _lastPersisted = null; }
|
||||||
|
function _persistNow() {
|
||||||
|
if (!_persistSimId) return;
|
||||||
|
const reg = _simStateRegistry[_persistSimId];
|
||||||
|
if (!reg || !reg.getState) return;
|
||||||
|
try {
|
||||||
|
const s = reg.getState();
|
||||||
|
if (s == null) return;
|
||||||
|
const json = JSON.stringify(s);
|
||||||
|
if (json === _lastPersisted || json.length > 8000) return;
|
||||||
|
_lastPersisted = json;
|
||||||
|
const m = _loadSavedStates(); m[_persistSimId] = s; _saveSavedStates(m);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function _startPersist(simId) { _stopPersist(); _persistSimId = simId; _persistInterval = setInterval(_persistNow, 2000); }
|
||||||
|
window.addEventListener('pagehide', _persistNow);
|
||||||
|
|
||||||
function _registerSimState(simId, getState, applyState) {
|
function _registerSimState(simId, getState, applyState) {
|
||||||
_simStateRegistry[simId] = { getState, applyState };
|
_simStateRegistry[simId] = { getState, applyState };
|
||||||
|
if (_embedMode) return; // в embed состоянием управляет учитель
|
||||||
|
// восстановить сохранённые параметры (после инициализации тела) + запустить персист
|
||||||
|
setTimeout(function () {
|
||||||
|
try { const saved = _loadSavedStates()[simId]; if (saved != null && applyState) applyState(saved); } catch (e) {}
|
||||||
|
_startPersist(simId);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lastEmittedState = null;
|
let _lastEmittedState = null;
|
||||||
@@ -358,6 +395,45 @@ const SIMS = [
|
|||||||
_lastEmittedState = null;
|
_lastEmittedState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Конструктор симуляций (Фаза 7): подключить custom-sim (SimEngine-инстанс через
|
||||||
|
адаптерный манифест real.instance()) к тому же мосту sim_state/apply_sim_state,
|
||||||
|
что и встроенные. Состояние = { params, running } — параметры слайдеров +
|
||||||
|
признак воспроизведения. applyState проигрывает их у ученика через setParam/
|
||||||
|
play/pause (время жёстко не синхронится — параметры и play/pause достаточны).
|
||||||
|
Регистрируем под ключом _autoSim ('custom:<dbid>'), т.к. обработчик
|
||||||
|
apply_sim_state у ученика берёт _simStateRegistry[_autoSim]. */
|
||||||
|
function _bridgeCustomSimState(real) {
|
||||||
|
if (!_embedMode || !real || typeof real.instance !== 'function') return;
|
||||||
|
var key = _autoSim;
|
||||||
|
if (!key || _simStateRegistry[key]) return; // уже подключено
|
||||||
|
function getState() {
|
||||||
|
var inst = real.instance();
|
||||||
|
if (!inst || !inst.params) return null;
|
||||||
|
var p = {};
|
||||||
|
for (var k in inst.params) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(inst.params, k)) {
|
||||||
|
var v = inst.params[k];
|
||||||
|
if (typeof v === 'number' && isFinite(v)) p[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { params: p, running: !!(inst.isRunning && inst.isRunning()) };
|
||||||
|
}
|
||||||
|
function applyState(st) {
|
||||||
|
var inst = real.instance();
|
||||||
|
if (!inst || !st) return;
|
||||||
|
if (st.params) {
|
||||||
|
for (var k in st.params) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(st.params, k)) inst.setParam(k, st.params[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var run = !!st.running, isRun = !!(inst.isRunning && inst.isRunning());
|
||||||
|
if (run && !isRun && inst.play) inst.play();
|
||||||
|
else if (!run && isRun && inst.pause) inst.pause();
|
||||||
|
}
|
||||||
|
_registerSimState(key, getState, applyState);
|
||||||
|
_startStateEmit(key);
|
||||||
|
}
|
||||||
|
|
||||||
// Receive apply_sim_state from parent (students)
|
// Receive apply_sim_state from parent (students)
|
||||||
window.addEventListener('message', e => {
|
window.addEventListener('message', e => {
|
||||||
if (!_embedMode) return;
|
if (!_embedMode) return;
|
||||||
@@ -381,7 +457,14 @@ const SIMS = [
|
|||||||
document.getElementById('lab-sim').classList.add('open');
|
document.getElementById('lab-sim').classList.add('open');
|
||||||
document.querySelector('.sim-topbar').style.display = 'none';
|
document.querySelector('.sim-topbar').style.display = 'none';
|
||||||
// defer until all external scripts are loaded
|
// defer until all external scripts are loaded
|
||||||
window.addEventListener('load', () => openSim(_autoSim));
|
// Конструктор симуляций (Фаза 5): custom-симуляции требуют предзагрузки спеки.
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
if (/^custom:/i.test(_autoSim) && window.LabCustom && window.LabCustom.init) {
|
||||||
|
window.LabCustom.init().then(function () { openSim(_autoSim); });
|
||||||
|
} else {
|
||||||
|
openSim(_autoSim);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
/* init — fetch sim settings + permissions in parallel, then render */
|
/* init — fetch sim settings + permissions in parallel, then render */
|
||||||
@@ -424,7 +507,14 @@ const SIMS = [
|
|||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
renderSims();
|
renderSims();
|
||||||
if (_autoSim) openSim(_autoSim);
|
// Конструктор симуляций (Фаза 5): подтянуть custom-sims (свои + published),
|
||||||
|
// зарегистрировать ленивые манифесты и дорисовать секцию «Мои симуляции».
|
||||||
|
// Если deep-link ведёт на custom-симуляцию — открыть её ПОСЛЕ загрузки списка.
|
||||||
|
var _customAuto = _autoSim && /^custom:/i.test(_autoSim);
|
||||||
|
var _customReady = (window.LabCustom && window.LabCustom.init)
|
||||||
|
? window.LabCustom.init() : Promise.resolve();
|
||||||
|
if (_autoSim && !_customAuto) openSim(_autoSim);
|
||||||
|
else if (_customAuto) _customReady.then(function () { openSim(_autoSim); });
|
||||||
// hash-router: activate sim from URL fragment after catalogue renders
|
// hash-router: activate sim from URL fragment after catalogue renders
|
||||||
else _activateFromHash();
|
else _activateFromHash();
|
||||||
}
|
}
|
||||||
@@ -529,3 +619,378 @@ const SIMS = [
|
|||||||
_origCloseSim();
|
_origCloseSim();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
LabCustom — каталог пользовательских симуляций (Конструктор симуляций, Фаза 5)
|
||||||
|
|
||||||
|
Подтягивает сохранённые custom-sims (свои любого статуса + чужие published)
|
||||||
|
через LS.customSimsList(), регистрирует ЛЕНИВЫЕ манифесты в LabRegistry и
|
||||||
|
рисует отдельную секцию «Мои симуляции» в #lab-home (карточки переиспользуют
|
||||||
|
стили .sim-card). Спека (тяжёлый JSON) тянется лениво при ПЕРВОМ открытии
|
||||||
|
(LS.customSimGet -> registerSpecSim из Ф0-адаптера), а не на старте /lab.
|
||||||
|
|
||||||
|
id-неймспейс: deep-link/клик — 'custom:<dbid>'; в LabRegistry — 'customsim_<dbid>'
|
||||||
|
(реестр обрезает часть после ':' в get/has, поэтому двоеточие там недопустимо).
|
||||||
|
openSim() переводит одно в другое через LabCustom.resolveId (хук в lab-init.js).
|
||||||
|
|
||||||
|
Самодостаточно: создаёт контейнер секции динамически, без правок lab.html/CSS —
|
||||||
|
меньше риск конфликта с параллельными сессиями. Падение загрузки (нет сети/404)
|
||||||
|
не ломает каталог встроенных — секция просто не появляется (try/catch).
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
(function () {
|
||||||
|
var REG_PREFIX = 'customsim_';
|
||||||
|
var _meta = {}; // dbid -> мета-запись из списка (без spec)
|
||||||
|
var _order = []; // dbid в порядке выдачи списка
|
||||||
|
var _specCache = {}; // dbid -> распарсенная spec (кэш ленивой загрузки)
|
||||||
|
var _specPromise = {}; // dbid -> Promise загрузки spec (дедуп)
|
||||||
|
var _initPromise = null;
|
||||||
|
|
||||||
|
function _uid() { try { return (typeof user !== 'undefined' && user) ? user.id : null; } catch (e) { return null; } }
|
||||||
|
function _regId(dbid) { return REG_PREFIX + dbid; }
|
||||||
|
function _dbIdOf(id) {
|
||||||
|
if (id == null) return null;
|
||||||
|
var s = String(id);
|
||||||
|
if (s.indexOf('custom:') === 0) return s.slice(7).split(':')[0];
|
||||||
|
if (s.indexOf(REG_PREFIX) === 0) return s.slice(REG_PREFIX.length);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function _isOwner(m) {
|
||||||
|
var uid = _uid();
|
||||||
|
return m && uid != null && String(m.owner_id) === String(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// deep-link/клик 'custom:<dbid>' -> реестровый id; встроенные id не трогаем.
|
||||||
|
function resolveId(id) {
|
||||||
|
var dbid = _dbIdOf(id);
|
||||||
|
return dbid != null ? _regId(dbid) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лениво получить spec симуляции (кэш + дедуп параллельных запросов).
|
||||||
|
function ensureSpec(dbid) {
|
||||||
|
if (_specCache[dbid]) return Promise.resolve(_specCache[dbid]);
|
||||||
|
if (_specPromise[dbid]) return _specPromise[dbid];
|
||||||
|
if (!window.LS || !LS.customSimGet) return Promise.resolve(null);
|
||||||
|
_specPromise[dbid] = LS.customSimGet(dbid).then(function (data) {
|
||||||
|
var sim = data && data.sim;
|
||||||
|
var spec = sim && sim.spec;
|
||||||
|
if (spec) { _specCache[dbid] = spec; }
|
||||||
|
delete _specPromise[dbid];
|
||||||
|
return spec || null;
|
||||||
|
}).catch(function (e) {
|
||||||
|
delete _specPromise[dbid];
|
||||||
|
if (window.console) console.warn('[LabCustom] не удалось загрузить спеку', dbid, e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return _specPromise[dbid];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зарегистрировать ЛЕНИВЫЙ манифест-заглушку для одной custom-sim.
|
||||||
|
// При первом open() — подтянуть spec и заменить заглушку реальным манифестом
|
||||||
|
// (registerSpecSim из Ф0-адаптера строит полноценный SimEngine-манифест).
|
||||||
|
function _registerLazy(m) {
|
||||||
|
if (!window.LabRegistry) return;
|
||||||
|
var dbid = m.id;
|
||||||
|
var rid = _regId(dbid);
|
||||||
|
var manifest = {
|
||||||
|
id: rid,
|
||||||
|
cat: m.cat || 'phys',
|
||||||
|
title: m.title || ('Симуляция #' + dbid),
|
||||||
|
desc: m.description || '',
|
||||||
|
subject: m.subject,
|
||||||
|
grade: m.grade,
|
||||||
|
_custom: true, // секция рисует их отдельно (см. renderSims)
|
||||||
|
_customId: dbid,
|
||||||
|
open: function (ctx) {
|
||||||
|
return ensureSpec(dbid).then(function (spec) {
|
||||||
|
if (!spec) {
|
||||||
|
if (window.console) console.warn('[LabCustom] спека пуста для', dbid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spec.id = rid; // реестровый id без двоеточия
|
||||||
|
if (!spec.cat) spec.cat = m.cat || 'phys';
|
||||||
|
if (!spec.subject && m.subject) spec.subject = m.subject;
|
||||||
|
if (!spec.grade && m.grade != null) spec.grade = m.grade;
|
||||||
|
var real = window.registerSpecSim
|
||||||
|
? window.registerSpecSim(spec) // заменит заглушку на месте (тот же id)
|
||||||
|
: null;
|
||||||
|
if (real) {
|
||||||
|
real._custom = true;
|
||||||
|
real._customId = dbid;
|
||||||
|
if (window.LabRegistry) window.LabRegistry.setActive(real);
|
||||||
|
var _r = real.open(ctx);
|
||||||
|
// Конструктор симуляций (Фаза 7): синхрон параметров/play на доске
|
||||||
|
// онлайн-урока. В embed подключаем custom-sim к общему мосту
|
||||||
|
// sim_state/apply_sim_state — тем же каналом, что и встроенные.
|
||||||
|
// Ключ — исходный _autoSim ('custom:<dbid>'), т.к. apply_sim_state
|
||||||
|
// у ученика берёт _simStateRegistry[_autoSim].
|
||||||
|
try { _bridgeCustomSimState(real); } catch (e) {}
|
||||||
|
return _r;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.LabRegistry.register(manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _catLabel(cat) {
|
||||||
|
if (cat === 'math') return '∑ Математика';
|
||||||
|
if (cat === 'chem') return '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия';
|
||||||
|
if (cat === 'bio') return '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/></svg> Биология';
|
||||||
|
if (cat === 'game') return '<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры';
|
||||||
|
return (typeof LS !== 'undefined' && LS.icon ? LS.icon('zap', 14) : '') + ' Физика';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureSectionHost() {
|
||||||
|
var host = document.getElementById('custom-sim-section');
|
||||||
|
if (host) return host;
|
||||||
|
var home = document.getElementById('lab-home');
|
||||||
|
var grid = document.getElementById('sim-grid');
|
||||||
|
if (!home) return null;
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'custom-sim-section';
|
||||||
|
host.style.cssText = 'margin-top:34px';
|
||||||
|
if (grid && grid.parentNode === home) home.appendChild(host);
|
||||||
|
else home.appendChild(host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
var _EDIT_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>';
|
||||||
|
var _DEL_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||||
|
var _SHARE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||||
|
var _CLONE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||||
|
var _PUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||||
|
var _UNPUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M2 2l20 20"/><path d="M12 2a15.3 15.3 0 0 1 4 10c0 1.3-.2 2.6-.5 3.8M6.5 6.5A15.3 15.3 0 0 0 12 22a15.3 15.3 0 0 0 3.3-5"/><path d="M2 12h7m6 0h7"/></svg>';
|
||||||
|
|
||||||
|
function _isTeacherUser() {
|
||||||
|
try { return typeof user !== 'undefined' && user && (user.role === 'teacher' || user.role === 'admin'); }
|
||||||
|
catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function _btn(act, id, html, extra, title) {
|
||||||
|
return '<button type="button" data-act="' + act + '" data-id="' + _esc(id) + '" ' +
|
||||||
|
(title ? 'title="' + _esc(title) + '" ' : '') +
|
||||||
|
'style="display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;' + (extra || '') + '">' +
|
||||||
|
html + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cardHtml(m) {
|
||||||
|
var owner = _isOwner(m);
|
||||||
|
var published = m.status === 'published';
|
||||||
|
var rid = _regId(m.id);
|
||||||
|
var badges = '';
|
||||||
|
if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(155,93,229,.16);color:var(--violet);border:1px solid rgba(155,93,229,.34)">Моя</span>';
|
||||||
|
if (published) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(52,211,153,.14);color:#34d399;border:1px solid rgba(52,211,153,.32)">Опубликована</span>';
|
||||||
|
else if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.06);color:var(--text-3);border:1px solid rgba(255,255,255,.14)">Черновик</span>';
|
||||||
|
var actions = '';
|
||||||
|
if (owner) {
|
||||||
|
var STYLE_PRI = 'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)';
|
||||||
|
var STYLE_GHOST = 'background:rgba(255,255,255,.05);color:var(--text-2);border:1px solid rgba(255,255,255,.16)';
|
||||||
|
var STYLE_DEL = 'background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)';
|
||||||
|
var pubBtn = published
|
||||||
|
? _btn('unpublish', m.id, _UNPUB_ICON, STYLE_GHOST, 'Снять с публикации')
|
||||||
|
: _btn('publish', m.id, _PUB_ICON, STYLE_GHOST, 'Опубликовать');
|
||||||
|
actions =
|
||||||
|
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||||
|
_btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) +
|
||||||
|
_btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="display:flex;gap:8px;margin-top:8px">' +
|
||||||
|
_btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') +
|
||||||
|
pubBtn +
|
||||||
|
'</div>';
|
||||||
|
} else if (published && _isTeacherUser()) {
|
||||||
|
actions =
|
||||||
|
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||||
|
_btn('clone', m.id, _CLONE_ICON + 'Клонировать к себе',
|
||||||
|
'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
var preview = '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<rect width="300" height="140" fill="#0D0D1A"/>' +
|
||||||
|
'<line x1="20" y1="120" x2="280" y2="120" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||||
|
'<line x1="30" y1="20" x2="30" y2="130" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||||
|
'<path d="M30 120 Q120 30 270 110" fill="none" stroke="#06D6E0" stroke-width="2.5"/>' +
|
||||||
|
'<circle cx="150" cy="64" r="5" fill="#9B5DE5"/></svg>';
|
||||||
|
return '' +
|
||||||
|
'<div class="sim-card" data-open="' + _esc('custom:' + m.id) + '">' +
|
||||||
|
preview +
|
||||||
|
'<div class="sim-body">' +
|
||||||
|
'<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' +
|
||||||
|
'<span class="sim-cat ' + _esc(m.cat || 'phys') + '">' + _catLabel(m.cat) + '</span>' + badges +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="sim-title">' + _esc(m.title || ('Симуляция #' + m.id)) + '</div>' +
|
||||||
|
'<div class="sim-desc">' + _esc(m.description || 'Пользовательская симуляция') +
|
||||||
|
(m.grade != null && m.grade !== '' ? ' · ' + _esc(m.grade) + ' класс' : '') + '</div>' +
|
||||||
|
actions +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Видимые в данной вкладке записи (фильтр категорий применяем и к custom).
|
||||||
|
function _visible(catFilter) {
|
||||||
|
return _order
|
||||||
|
.map(function (id) { return _meta[id]; })
|
||||||
|
.filter(function (m) { return m && (catFilter === 'all' || (m.cat || 'phys') === catFilter); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(catFilter) {
|
||||||
|
var host = _ensureSectionHost();
|
||||||
|
if (!host) return;
|
||||||
|
var list = _visible(catFilter || _catFilter);
|
||||||
|
if (!list.length) { host.innerHTML = ''; host.style.display = 'none'; return; }
|
||||||
|
host.style.display = '';
|
||||||
|
var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' +
|
||||||
|
'<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;stroke:var(--violet)"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' +
|
||||||
|
'<span style="font-family:\'Unbounded\',sans-serif;font-size:1rem;font-weight:800">Мои симуляции</span>' +
|
||||||
|
'<span style="font-size:.78rem;color:var(--text-3)">собранные в конструкторе</span></div>';
|
||||||
|
host.innerHTML = head + '<div class="sim-grid">' + list.map(_cardHtml).join('') + '</div>';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делегированные клики по секции: открыть / редактировать / удалить.
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var host = document.getElementById('custom-sim-section');
|
||||||
|
if (!host || !host.contains(ev.target)) return;
|
||||||
|
var actBtn = ev.target.closest ? ev.target.closest('[data-act]') : null;
|
||||||
|
if (actBtn && host.contains(actBtn)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
var act = actBtn.getAttribute('data-act');
|
||||||
|
var id = actBtn.getAttribute('data-id');
|
||||||
|
if (act === 'edit') {
|
||||||
|
location.href = '/sim-builder?id=' + encodeURIComponent(id);
|
||||||
|
} else if (act === 'del') {
|
||||||
|
del(id);
|
||||||
|
} else if (act === 'share') {
|
||||||
|
shareToClass(id);
|
||||||
|
} else if (act === 'clone') {
|
||||||
|
clone(id);
|
||||||
|
} else if (act === 'publish') {
|
||||||
|
setStatus(id, 'published');
|
||||||
|
} else if (act === 'unpublish') {
|
||||||
|
setStatus(id, 'draft');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var card = ev.target.closest ? ev.target.closest('[data-open]') : null;
|
||||||
|
if (card && host.contains(card)) {
|
||||||
|
var openId = card.getAttribute('data-open');
|
||||||
|
if (openId) openSim(openId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function del(dbid) {
|
||||||
|
var m = _meta[dbid];
|
||||||
|
var name = (m && m.title) || ('симуляцию #' + dbid);
|
||||||
|
if (!window.confirm('Удалить «' + name + '»? Это действие необратимо.')) return;
|
||||||
|
if (!window.LS || !LS.customSimDelete) return;
|
||||||
|
LS.customSimDelete(dbid).then(function () {
|
||||||
|
delete _meta[dbid];
|
||||||
|
delete _specCache[dbid];
|
||||||
|
_order = _order.filter(function (x) { return String(x) !== String(dbid); });
|
||||||
|
renderSection(_catFilter);
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (window.LS && LS.toast) LS.toast('Не удалось удалить симуляцию', 'error');
|
||||||
|
else if (window.console) console.warn('[LabCustom] delete failed', dbid, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Опубликовать / снять с публикации (владельцу). PUT status.
|
||||||
|
function setStatus(dbid, status) {
|
||||||
|
if (!window.LS || !LS.customSimUpdate) return;
|
||||||
|
LS.customSimUpdate(dbid, { status: status }).then(function () {
|
||||||
|
if (_meta[dbid]) _meta[dbid].status = status;
|
||||||
|
renderSection(_catFilter);
|
||||||
|
if (LS.toast) LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (LS.toast) LS.toast((e && e.message) || 'Не удалось изменить статус', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клонировать чужую (published) симуляцию к себе как черновик и открыть в билдере.
|
||||||
|
function clone(dbid) {
|
||||||
|
if (!window.LS || !LS.customSimClone) return;
|
||||||
|
LS.customSimClone(dbid).then(function (res) {
|
||||||
|
var newId = res && res.id;
|
||||||
|
if (newId) {
|
||||||
|
if (LS.toast) LS.toast('Скопировано в ваши черновики', 'success');
|
||||||
|
location.href = '/sim-builder?id=' + encodeURIComponent(newId);
|
||||||
|
}
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (LS.toast) LS.toast((e && e.message) || 'Не удалось клонировать', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует
|
||||||
|
// и шлёт уведомление ученикам со ссылкой /lab?sim=custom:<id>).
|
||||||
|
function shareToClass(dbid) {
|
||||||
|
if (!window.LS || !LS.customSimShare || !LS.getClasses || !LS.modal) return;
|
||||||
|
LS.getClasses().then(function (classes) {
|
||||||
|
if (!Array.isArray(classes) || !classes.length) {
|
||||||
|
if (LS.toast) LS.toast('Нет классов для раздачи', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var opts = classes.map(function (c) {
|
||||||
|
return '<option value="' + _esc(c.id) + '">' + _esc(c.name) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
|
||||||
|
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
|
||||||
|
'<select id="cs-share-class" style="width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:9px;font:inherit;background:var(--surface);color:var(--text)">' + opts + '</select>' +
|
||||||
|
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
|
||||||
|
'</div>';
|
||||||
|
var m = LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
|
||||||
|
{ label: 'Отмена', onClick: function () { m.close(); } },
|
||||||
|
{ label: 'Раздать', primary: true, onClick: function () {
|
||||||
|
var sel = m.body.querySelector('#cs-share-class');
|
||||||
|
var classId = sel ? Number(sel.value) : NaN;
|
||||||
|
LS.customSimShare(dbid, { classId: classId }).then(function (r) {
|
||||||
|
m.close();
|
||||||
|
if (_meta[dbid]) _meta[dbid].status = 'published';
|
||||||
|
renderSection(_catFilter);
|
||||||
|
if (LS.toast) LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (LS.toast) LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
|
||||||
|
});
|
||||||
|
} }
|
||||||
|
] });
|
||||||
|
}).catch(function () {
|
||||||
|
if (LS.toast) LS.toast('Не удалось загрузить классы', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузить список custom-sims, зарегистрировать ленивые манифесты, нарисовать секцию.
|
||||||
|
function init() {
|
||||||
|
if (_initPromise) return _initPromise;
|
||||||
|
if (!window.LS || !LS.customSimsList) { _initPromise = Promise.resolve(); return _initPromise; }
|
||||||
|
_initPromise = LS.customSimsList().then(function (data) {
|
||||||
|
var sims = (data && data.sims) || [];
|
||||||
|
_order = [];
|
||||||
|
sims.forEach(function (s) {
|
||||||
|
if (s == null || s.id == null) return;
|
||||||
|
_meta[s.id] = s;
|
||||||
|
_order.push(s.id);
|
||||||
|
_registerLazy(s);
|
||||||
|
});
|
||||||
|
renderSection(_catFilter);
|
||||||
|
}).catch(function (e) {
|
||||||
|
// мягко: нет сети/прав — секция просто не появится, встроенные работают
|
||||||
|
if (window.console) console.warn('[LabCustom] список custom-sims недоступен', e);
|
||||||
|
});
|
||||||
|
return _initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.LabCustom = {
|
||||||
|
init: init,
|
||||||
|
resolveId: resolveId,
|
||||||
|
renderSection: renderSection,
|
||||||
|
ensureSpec: ensureSpec,
|
||||||
|
del: del,
|
||||||
|
share: shareToClass,
|
||||||
|
clone: clone,
|
||||||
|
setStatus: setStatus
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
@@ -105,6 +105,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSim(id) {
|
function openSim(id) {
|
||||||
|
// Конструктор симуляций (Фаза 5): custom-sims регистрируются в LabRegistry под
|
||||||
|
// id без двоеточия (LabRegistry.get/has обрезают часть после ':'). Хук resolveId
|
||||||
|
// переводит deep-link/клик 'custom:<dbid>' в реестровый id и лениво подтягивает
|
||||||
|
// спеку при первом открытии. Для встроенных симуляций id не меняется.
|
||||||
|
if (window.LabCustom && typeof window.LabCustom.resolveId === 'function') {
|
||||||
|
id = window.LabCustom.resolveId(id) || id;
|
||||||
|
}
|
||||||
if (_disabledSimIds.has(id.split(':')[0])) return;
|
if (_disabledSimIds.has(id.split(':')[0])) return;
|
||||||
document.getElementById('lab-home').style.display = 'none';
|
document.getElementById('lab-home').style.display = 'none';
|
||||||
document.getElementById('lab-sim').classList.add('open');
|
document.getElementById('lab-sim').classList.add('open');
|
||||||
|
|||||||
@@ -39,14 +39,16 @@
|
|||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
let url = o.url;
|
let url = o.url;
|
||||||
|
let thumbUrl = o.thumbUrl || null;
|
||||||
if (o.blob) {
|
if (o.blob) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', o.blob, o.name || 'image.png');
|
fd.append('file', o.blob, o.name || 'image.png');
|
||||||
const up = await LS.uploadMaterialFile(fd);
|
const up = await LS.uploadMaterialFile(fd);
|
||||||
url = up.url;
|
url = up.url;
|
||||||
|
thumbUrl = up.thumbUrl || null;
|
||||||
}
|
}
|
||||||
if (!url) throw new Error('Нет изображения');
|
if (!url) throw new Error('Нет изображения');
|
||||||
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null });
|
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, thumbUrl: thumbUrl, sourceTitle: o.sourceTitle || null });
|
||||||
ok();
|
ok();
|
||||||
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
|
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,7 @@
|
|||||||
kind: 'image',
|
kind: 'image',
|
||||||
title: input.value.trim() || sectionTitle(),
|
title: input.value.trim() || sectionTitle(),
|
||||||
url: up.url,
|
url: up.url,
|
||||||
|
thumbUrl: up.thumbUrl || null,
|
||||||
sourceTitle: chapterTitle()
|
sourceTitle: chapterTitle()
|
||||||
});
|
});
|
||||||
toast('Сохранено в «Мои материалы»', 'success');
|
toast('Сохранено в «Мои материалы»', 'success');
|
||||||
|
|||||||
@@ -347,6 +347,21 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- save screenshot to «Мои материалы» -->
|
||||||
|
<button class="zoom-btn" id="lab-save-btn" onclick="labSaveToMaterials(this)" title="Сохранить кадр в «Мои материалы»">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- download PNG -->
|
||||||
|
<button class="zoom-btn" id="lab-png-btn" onclick="labDownloadPng()" title="Скачать кадр (PNG)">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- measurement tools (ruler / angle) -->
|
||||||
|
<button class="zoom-btn" id="lab-measure-btn" onclick="window.LabMeasure&&LabMeasure.toggle()" title="Измерения: линейка и угломер">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0l-4.6-4.6a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z"/><path d="m7.5 10.5 2 2M10.5 7.5l2 2M13.5 4.5l2 2M4.5 13.5l2 2"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- sound toggle -->
|
<!-- sound toggle -->
|
||||||
<button class="zoom-btn" id="labfx-sound-btn" onclick="(function(){var e=window.LabFX&&window.LabFX.sound;if(!e)return;e.setEnabled(!e.isEnabled());document.getElementById('labfx-sound-btn').setAttribute('aria-pressed',e.isEnabled());document.getElementById('labfx-sound-icon-on').style.display=e.isEnabled()?'':'none';document.getElementById('labfx-sound-icon-off').style.display=e.isEnabled()?'none':'';})()" title="Звук симуляций" style="position:relative" aria-pressed="true">
|
<button class="zoom-btn" id="labfx-sound-btn" onclick="(function(){var e=window.LabFX&&window.LabFX.sound;if(!e)return;e.setEnabled(!e.isEnabled());document.getElementById('labfx-sound-btn').setAttribute('aria-pressed',e.isEnabled());document.getElementById('labfx-sound-icon-on').style.display=e.isEnabled()?'':'none';document.getElementById('labfx-sound-icon-off').style.display=e.isEnabled()?'none':'';})()" title="Звук симуляций" style="position:relative" aria-pressed="true">
|
||||||
<!-- speaker on -->
|
<!-- speaker on -->
|
||||||
@@ -412,6 +427,7 @@
|
|||||||
</div><!-- /.app-layout -->
|
</div><!-- /.app-layout -->
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/material-save.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<!-- ════════════════════════════════════════════════════════════════════════
|
<!-- ════════════════════════════════════════════════════════════════════════
|
||||||
Контент-движок, Фаза 3 — ЛЕНИВАЯ ЗАГРУЗКА КОДА СИМУЛЯЦИЙ.
|
Контент-движок, Фаза 3 — ЛЕНИВАЯ ЗАГРУЗКА КОДА СИМУЛЯЦИЙ.
|
||||||
@@ -432,7 +448,13 @@
|
|||||||
<script src="/js/labs/_fx_motion.js"></script>
|
<script src="/js/labs/_fx_motion.js"></script>
|
||||||
<script src="/js/labs/_fx_sound.js"></script>
|
<script src="/js/labs/_fx_sound.js"></script>
|
||||||
<script src="/js/labs/_graph_panel.js"></script>
|
<script src="/js/labs/_graph_panel.js"></script>
|
||||||
|
<!-- Конструктор симуляций (Фаза 0): движок выражений + рантайм + адаптер LabRegistry.
|
||||||
|
Лёгкие модули каркаса (~30 КБ), грузятся eager как _registry/_loader. -->
|
||||||
|
<script src="/js/labs/_sim_expr.js"></script>
|
||||||
|
<script src="/js/labs/_sim_engine.js"></script>
|
||||||
|
<script src="/js/labs/_sim_adapter.js"></script>
|
||||||
<script src="/js/labs/_tasks.js"></script>
|
<script src="/js/labs/_tasks.js"></script>
|
||||||
|
<script src="/js/labs/_measure.js"></script>
|
||||||
<script src="/js/labs/_phys_visuals.js"></script>
|
<script src="/js/labs/_phys_visuals.js"></script>
|
||||||
<script src="/js/labs/_chem_visuals.js"></script>
|
<script src="/js/labs/_chem_visuals.js"></script>
|
||||||
<script src="/js/labs/graph.js"></script>
|
<script src="/js/labs/graph.js"></script>
|
||||||
@@ -443,6 +465,9 @@
|
|||||||
<script src="/js/lab-previews.js"></script>
|
<script src="/js/lab-previews.js"></script>
|
||||||
<script src="/js/labs/lab-glue.js"></script>
|
<script src="/js/labs/lab-glue.js"></script>
|
||||||
<script src="/js/labs/_register-all.js"></script>
|
<script src="/js/labs/_register-all.js"></script>
|
||||||
|
<!-- Конструктор симуляций (Фаза 0): демо-спека за флагом (?simdemo=1). Грузится
|
||||||
|
после _register-all, чтобы LabRegistry/registerSpecSim уже существовали. -->
|
||||||
|
<script src="/js/labs/_sim_demo.js"></script>
|
||||||
<script>
|
<script>
|
||||||
/* Sync sound toggle button icon with localStorage state on load */
|
/* Sync sound toggle button icon with localStorage state on load */
|
||||||
(function() {
|
(function() {
|
||||||
@@ -468,6 +493,46 @@
|
|||||||
off.style.display = eco ? 'none' : '';
|
off.style.display = eco ? 'none' : '';
|
||||||
btn.setAttribute('aria-pressed', eco ? 'true' : 'false');
|
btn.setAttribute('aria-pressed', eco ? 'true' : 'false');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ── Снимок симуляции: «В мои материалы» / «Скачать PNG» (Фаза 2) ──
|
||||||
|
Берём самый крупный видимый canvas в области симуляции. Для 3D (WebGL)
|
||||||
|
кадр может выйти пустым без preserveDrawingBuffer — допустимо для v1. */
|
||||||
|
function _labSimTitle() {
|
||||||
|
var t = document.getElementById('sim-topbar-title');
|
||||||
|
return t ? (t.textContent || '').trim() : '';
|
||||||
|
}
|
||||||
|
function _labActiveCanvas() {
|
||||||
|
var best = null, bestArea = 0;
|
||||||
|
document.querySelectorAll('canvas').forEach(function (c) {
|
||||||
|
if (c.offsetParent === null) return; // скрытый
|
||||||
|
var r = c.getBoundingClientRect();
|
||||||
|
if (r.width < 60 || r.height < 60) return; // мелкие (иконки/спарклайны)
|
||||||
|
var area = r.width * r.height;
|
||||||
|
if (area > bestArea) { bestArea = area; best = c; }
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function labDownloadPng() {
|
||||||
|
var c = _labActiveCanvas();
|
||||||
|
if (!c) { if (window.LS && LS.toast) LS.toast('Нет изображения', 'warn'); return; }
|
||||||
|
try {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = c.toDataURL('image/png');
|
||||||
|
a.download = (_labSimTitle() || 'simulation') + '.png';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
} catch (e) { if (window.LS && LS.toast) LS.toast('Не удалось сохранить кадр', 'error'); }
|
||||||
|
}
|
||||||
|
function labSaveToMaterials(btn) {
|
||||||
|
var c = _labActiveCanvas();
|
||||||
|
if (!c) { if (window.LS && LS.toast) LS.toast('Нет изображения', 'warn'); return; }
|
||||||
|
if (!window.MaterialSave) { if (window.LS && LS.toast) LS.toast('Модуль сохранения не загружен', 'error'); return; }
|
||||||
|
try {
|
||||||
|
c.toBlob(function (blob) {
|
||||||
|
if (!blob) { if (window.LS && LS.toast) LS.toast('Не удалось снять кадр', 'error'); return; }
|
||||||
|
MaterialSave.image({ blob: blob, title: _labSimTitle() || 'Симуляция', name: 'sim.png', sourceTitle: 'Лаборатория' }, btn);
|
||||||
|
}, 'image/png');
|
||||||
|
} catch (e) { if (window.LS && LS.toast) LS.toast('Не удалось снять кадр', 'error'); }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+226
-31
@@ -70,6 +70,27 @@
|
|||||||
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
|
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
|
||||||
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
|
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
|
||||||
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
|
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
|
||||||
|
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||||
|
.mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; }
|
||||||
|
.mm-tag:hover { background: rgba(6,182,212,0.24); }
|
||||||
|
.mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); }
|
||||||
|
.mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); }
|
||||||
|
.mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); }
|
||||||
|
.mm-tagpill-x { display: inline-flex; cursor: pointer; }
|
||||||
|
.mm-tagpill-x svg { width: 13px; height: 13px; }
|
||||||
|
.mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; }
|
||||||
|
.mm-card:hover .mm-check, .mm-check:checked { opacity: 1; }
|
||||||
|
.mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); }
|
||||||
|
.mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); }
|
||||||
|
.mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; }
|
||||||
|
.mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
|
.mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
|
||||||
|
.mm-swatch.on { border-color: var(--text); }
|
||||||
|
.mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); }
|
||||||
|
.mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
||||||
|
.mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); }
|
||||||
|
.mm-more { display: flex; justify-content: center; padding: 8px 0 2px; }
|
||||||
|
@media (max-width: 768px) { .mm-check { opacity: .85; } }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mm-body { flex-direction: column; }
|
.mm-body { flex-direction: column; }
|
||||||
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
|
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
|
||||||
@@ -109,7 +130,15 @@
|
|||||||
<option value="note">Заметки</option>
|
<option value="note">Заметки</option>
|
||||||
<option value="link">Ссылки</option>
|
<option value="link">Ссылки</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="mm-kind" id="mm-sort" onchange="onSort(this.value)" title="Сортировка">
|
||||||
|
<option value="new">Сначала новые</option>
|
||||||
|
<option value="old">Сначала старые</option>
|
||||||
|
<option value="title">По названию</option>
|
||||||
|
<option value="kind">По типу</option>
|
||||||
|
</select>
|
||||||
|
<span id="mm-tagfilter"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mm-bulk" class="mm-bulk" style="display:none"></div>
|
||||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +178,9 @@
|
|||||||
}
|
}
|
||||||
return tmp.innerHTML;
|
return tmp.innerHTML;
|
||||||
}
|
}
|
||||||
|
/* Live formula preview for the note editor (renders $…$ as you type). */
|
||||||
|
function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); }
|
||||||
|
window.mmPreview = mmPreview;
|
||||||
function fmtDate(s) {
|
function fmtDate(s) {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
|
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
|
||||||
@@ -170,9 +202,41 @@
|
|||||||
if (u.startsWith('/lab')) return 'Лаборатория';
|
if (u.startsWith('/lab')) return 'Лаборатория';
|
||||||
return 'Ссылка';
|
return 'Ссылка';
|
||||||
}
|
}
|
||||||
|
function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); }
|
||||||
|
/* Only trust folder colors that look like a hex value (guards inline-style injection). */
|
||||||
|
function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; }
|
||||||
|
|
||||||
|
/* Meta line: source title links back to the originating lesson when known. */
|
||||||
|
function metaHtml(m) {
|
||||||
|
const date = fmtDate(m.created_at);
|
||||||
|
let src = '';
|
||||||
|
if (m.source_title) {
|
||||||
|
src = m.source_session_id
|
||||||
|
? `<a class="mm-src" href="/my-lessons?session=${Number(m.source_session_id)}" title="Открыть исходный урок">${esc(m.source_title)}</a>`
|
||||||
|
: esc(m.source_title);
|
||||||
|
src += ' · ';
|
||||||
|
}
|
||||||
|
return src + esc(date);
|
||||||
|
}
|
||||||
|
/* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */
|
||||||
|
function tagsHtml(m) {
|
||||||
|
const tg = parseTags(m.tags);
|
||||||
|
if (!tg.length) return '';
|
||||||
|
return `<div class="mm-tags">${tg.map(t => `<span class="mm-tag" data-t="${esc(t)}" onclick="filterTag(this.dataset.t)">${esc(t)}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
/* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */
|
||||||
|
async function ensureFullBody(m) {
|
||||||
|
if (!m || !m.body_trunc) return m;
|
||||||
|
try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
let _mats = [];
|
let _mats = [];
|
||||||
let _cols = [];
|
let _cols = [];
|
||||||
const _filter = { col: 'all', kind: 'all', q: '' };
|
const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' };
|
||||||
|
const _sel = new Set(); // ids selected for bulk actions
|
||||||
|
const PAGE_SIZE = 60; // cards rendered to the DOM at once ("Показать ещё" adds more)
|
||||||
|
let _shown = PAGE_SIZE;
|
||||||
|
|
||||||
/* ── Move-to-collection select ── */
|
/* ── Move-to-collection select ── */
|
||||||
function moveSelect(m) {
|
function moveSelect(m) {
|
||||||
@@ -183,7 +247,10 @@
|
|||||||
|
|
||||||
function card(m) {
|
function card(m) {
|
||||||
const kind = KIND_LABEL[m.kind] || m.kind;
|
const kind = KIND_LABEL[m.kind] || m.kind;
|
||||||
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
const meta = metaHtml(m);
|
||||||
|
const tags = tagsHtml(m);
|
||||||
|
const selCls = _sel.has(m.id) ? ' mm-selected' : '';
|
||||||
|
const cb = `<input type="checkbox" class="mm-check" ${_sel.has(m.id) ? 'checked' : ''} onclick="toggleSel(event,${m.id})" title="Выбрать" />`;
|
||||||
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
|
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
|
||||||
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
|
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
|
||||||
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
||||||
@@ -196,12 +263,12 @@
|
|||||||
const mv = moveSelect(m);
|
const mv = moveSelect(m);
|
||||||
|
|
||||||
if (m.kind === 'board' || m.kind === 'image') {
|
if (m.kind === 'board' || m.kind === 'image') {
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||||
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy" draggable="false"/></a>
|
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.thumb_url || m.url)}" alt="" loading="lazy" decoding="async" draggable="false"/></a>
|
||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
${chip}
|
${chip}
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
||||||
@@ -213,7 +280,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (m.kind === 'link') {
|
if (m.kind === 'link') {
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||||
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
|
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
|
||||||
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
|
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
|
||||||
<span class="mm-card-link-meta">
|
<span class="mm-card-link-meta">
|
||||||
@@ -224,7 +291,7 @@
|
|||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
${chip}
|
${chip}
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
||||||
@@ -235,29 +302,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// note
|
// note
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||||
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
|
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
|
||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
${chip}
|
${chip}
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
|
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder rail (вертикальный список папок слева) ── */
|
/* ── Folder rail (вертикальный список папок слева) ── */
|
||||||
function railItem(key, label, count, editId, droppable) {
|
function railItem(key, label, count, editId, droppable, color) {
|
||||||
const active = _filter.col === key ? ' active' : '';
|
const active = _filter.col === key ? ' active' : '';
|
||||||
const ed = editId
|
const ed = editId
|
||||||
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
|
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
|
||||||
: '';
|
: '';
|
||||||
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
|
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
|
||||||
|
const tint = safeColor(color);
|
||||||
|
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
|
||||||
const drop = droppable
|
const drop = droppable
|
||||||
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
|
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
|
||||||
: '';
|
: '';
|
||||||
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
|
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
|
||||||
<i data-lucide="${ic}"></i>
|
<i data-lucide="${ic}"${icStyle}></i>
|
||||||
<span class="mm-rail-label">${esc(label)}</span>
|
<span class="mm-rail-label">${esc(label)}</span>
|
||||||
<span class="mm-rail-count">${count}</span>${ed}
|
<span class="mm-rail-count">${count}</span>${ed}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -266,7 +335,7 @@
|
|||||||
const bar = document.getElementById('mm-cols');
|
const bar = document.getElementById('mm-cols');
|
||||||
const noneCount = _mats.filter(m => !m.collection_id).length;
|
const noneCount = _mats.filter(m => !m.collection_id).length;
|
||||||
let html = railItem('all', 'Все', _mats.length, null, false);
|
let html = railItem('all', 'Все', _mats.length, null, false);
|
||||||
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); });
|
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true, c.color); });
|
||||||
html += railItem('none', 'Без папки', noneCount, null, true);
|
html += railItem('none', 'Без папки', noneCount, null, true);
|
||||||
bar.innerHTML = html;
|
bar.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -299,17 +368,28 @@
|
|||||||
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
|
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
|
||||||
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
|
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
|
||||||
|
|
||||||
|
function sortRows(rows) {
|
||||||
|
const s = _filter.sort || 'new';
|
||||||
|
if (s === 'new') return rows; // server already returns newest-first
|
||||||
|
const a = rows.slice();
|
||||||
|
if (s === 'old') a.reverse();
|
||||||
|
else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru'));
|
||||||
|
else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || ''));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
function filtered() {
|
function filtered() {
|
||||||
return _mats.filter(m => {
|
const rows = _mats.filter(m => {
|
||||||
if (_filter.col === 'none' && m.collection_id) return false;
|
if (_filter.col === 'none' && m.collection_id) return false;
|
||||||
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
||||||
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
||||||
|
if (_filter.tag && !parseTags(m.tags).includes(_filter.tag)) return false;
|
||||||
if (_filter.q) {
|
if (_filter.q) {
|
||||||
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||||
if (!hay.includes(_filter.q)) return false;
|
if (!hay.includes(_filter.q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
return sortRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
@@ -323,11 +403,20 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = filtered();
|
const rows = filtered();
|
||||||
grid.innerHTML = rows.length
|
if (!rows.length) {
|
||||||
? rows.map(card).join('')
|
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||||
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
lucide.createIcons(); renderBulk(); return;
|
||||||
|
}
|
||||||
|
let html = rows.slice(0, _shown).map(card).join('');
|
||||||
|
if (rows.length > _shown) {
|
||||||
|
html += `<div class="mm-more" style="grid-column:1/-1"><button class="mm-btn" onclick="showMore()"><i data-lucide="chevron-down"></i> Показать ещё (${rows.length - _shown})</button></div>`;
|
||||||
|
}
|
||||||
|
grid.innerHTML = html;
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
renderBulk();
|
||||||
}
|
}
|
||||||
|
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
|
||||||
|
window.showMore = showMore;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -336,16 +425,73 @@
|
|||||||
_cols = data.collections || [];
|
_cols = data.collections || [];
|
||||||
renderCols();
|
renderCols();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
renderTagFilter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
|
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filters ── */
|
/* ── Filters ── */
|
||||||
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); }
|
||||||
function onKind(v) { _filter.kind = v; renderGrid(); }
|
function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); }
|
||||||
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
|
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); }
|
||||||
|
function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); }
|
||||||
|
function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
|
||||||
|
function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
|
||||||
|
function renderTagFilter() {
|
||||||
|
const el = document.getElementById('mm-tagfilter');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = _filter.tag
|
||||||
|
? `<span class="mm-tagpill">#${esc(_filter.tag)} <span class="mm-tagpill-x" onclick="clearTag()" title="Сбросить фильтр по тегу"><i data-lucide="x"></i></span></span>`
|
||||||
|
: '';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
||||||
|
window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag;
|
||||||
|
|
||||||
|
/* ── Multi-select + bulk actions (reuse per-item endpoints) ── */
|
||||||
|
function renderBulk() {
|
||||||
|
const bar = document.getElementById('mm-bulk');
|
||||||
|
if (!bar) return;
|
||||||
|
const n = _sel.size;
|
||||||
|
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||||
|
const opts = ['<option value="__none">Без папки</option>']
|
||||||
|
.concat(_cols.map(c => `<option value="${c.id}">${esc(c.name)}</option>`)).join('');
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
bar.innerHTML = `<span class="mm-bulk-count">Выбрано: ${n}</span>
|
||||||
|
<select class="mm-move" onchange="bulkMove(this.value)" title="Переместить выбранные"><option value="">Переместить в…</option>${opts}</select>
|
||||||
|
<button class="mm-btn danger" onclick="bulkDelete()"><i data-lucide="trash-2"></i> Удалить</button>
|
||||||
|
<button class="mm-btn" onclick="clearSel()"><i data-lucide="x"></i> Снять</button>`;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
function toggleSel(e, id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const cb = e.target;
|
||||||
|
if (cb.checked) _sel.add(id); else _sel.delete(id);
|
||||||
|
const cardEl = cb.closest('.mm-card');
|
||||||
|
if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked);
|
||||||
|
renderBulk();
|
||||||
|
}
|
||||||
|
function clearSel() { _sel.clear(); renderGrid(); }
|
||||||
|
async function bulkMove(v) {
|
||||||
|
if (v === '') return;
|
||||||
|
const cid = v === '__none' ? null : Number(v);
|
||||||
|
const ids = [..._sel];
|
||||||
|
try {
|
||||||
|
for (const id of ids) await LS.updateMaterial(id, { collection_id: cid });
|
||||||
|
_sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success');
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
async function bulkDelete() {
|
||||||
|
const ids = [..._sel];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return;
|
||||||
|
try {
|
||||||
|
for (const id of ids) await LS.deleteMaterial(id);
|
||||||
|
_sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success');
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete;
|
||||||
|
|
||||||
/* ── Material actions ── */
|
/* ── Material actions ── */
|
||||||
async function moveMaterial(id, cid) {
|
async function moveMaterial(id, cid) {
|
||||||
@@ -365,46 +511,56 @@
|
|||||||
function createNote() {
|
function createNote() {
|
||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки… (поддерживается $формула$)" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-nt-prev')"></textarea>
|
||||||
|
<div id="mm-nt-prev" class="mm-preview"></div>
|
||||||
|
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Создать', primary: true, onClick: async () => {
|
{ label: 'Создать', primary: true, onClick: async () => {
|
||||||
const title = m.body.querySelector('#mm-nt-title').value.trim();
|
const title = m.body.querySelector('#mm-nt-title').value.trim();
|
||||||
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
||||||
|
const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null;
|
||||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||||
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
||||||
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
|
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
}
|
}
|
||||||
window.createNote = createNote;
|
window.createNote = createNote;
|
||||||
|
|
||||||
function editMaterial(id) {
|
async function editMaterial(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return;
|
if (!mt) return;
|
||||||
const isNote = mt.kind === 'note';
|
const isNote = mt.kind === 'note';
|
||||||
|
if (isNote) await ensureFullBody(mt);
|
||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
||||||
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-ed-prev')">${esc(mt.body || '')}</textarea><div id="mm-ed-prev" class="mm-preview"></div>` : ''}
|
||||||
|
<input id="mm-ed-tags" value="${esc(mt.tags || '')}" placeholder="Теги через запятую" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
const data = {
|
||||||
|
title: m.body.querySelector('#mm-ed-title').value.trim(),
|
||||||
|
tags: m.body.querySelector('#mm-ed-tags').value.trim() || null,
|
||||||
|
};
|
||||||
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
||||||
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
|
if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); }
|
||||||
}
|
}
|
||||||
window.editMaterial = editMaterial;
|
window.editMaterial = editMaterial;
|
||||||
|
|
||||||
/* ── Просмотр материала в модалке (лайтбокс) ── */
|
/* ── Просмотр материала в модалке (лайтбокс) ── */
|
||||||
function openViewer(id) {
|
async function openViewer(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return false;
|
if (!mt) return false;
|
||||||
|
if (mt.kind === 'note') await ensureFullBody(mt);
|
||||||
const kind = KIND_LABEL[mt.kind] || mt.kind;
|
const kind = KIND_LABEL[mt.kind] || mt.kind;
|
||||||
let body;
|
let body;
|
||||||
if (mt.kind === 'image' || mt.kind === 'board') {
|
if (mt.kind === 'image' || mt.kind === 'board') {
|
||||||
@@ -431,24 +587,61 @@
|
|||||||
window.openViewer = openViewer;
|
window.openViewer = openViewer;
|
||||||
|
|
||||||
/* ── Collection CRUD ── */
|
/* ── Collection CRUD ── */
|
||||||
|
const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899'];
|
||||||
|
function colorPalette(sel) {
|
||||||
|
sel = safeColor(sel);
|
||||||
|
return `<div style="font-size:.78rem;color:var(--text-3)">Цвет</div>
|
||||||
|
<div class="mm-swatches">
|
||||||
|
<span class="mm-swatch mm-swatch-none${!sel ? ' on' : ''}" data-c="" onclick="pickSwatch(this)" title="Без цвета"></span>
|
||||||
|
${COL_PALETTE.map(c => `<span class="mm-swatch${sel === c ? ' on' : ''}" data-c="${c}" style="background:${c}" onclick="pickSwatch(this)"></span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); }
|
||||||
|
function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; }
|
||||||
|
window.pickSwatch = pickSwatch;
|
||||||
|
|
||||||
function createCollection() {
|
function createCollection() {
|
||||||
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />
|
||||||
|
${colorPalette(null)}
|
||||||
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Создать', primary: true, onClick: async () => {
|
{ label: 'Создать', primary: true, onClick: async () => {
|
||||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
|
try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
}
|
}
|
||||||
window.createCollection = createCollection;
|
window.createCollection = createCollection;
|
||||||
|
|
||||||
|
/* Reorder a folder up/down by normalizing sort_order to the new index order. */
|
||||||
|
async function moveCollection(id, dir) {
|
||||||
|
const arr = _cols.slice();
|
||||||
|
const i = arr.findIndex(c => c.id === id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (i < 0 || j < 0 || j >= arr.length) return;
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
try {
|
||||||
|
await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean));
|
||||||
|
load();
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
window.moveCollection = moveCollection;
|
||||||
|
|
||||||
function editCollection(id) {
|
function editCollection(id) {
|
||||||
const col = _cols.find(c => c.id === id);
|
const col = _cols.find(c => c.id === id);
|
||||||
if (!col) return;
|
if (!col) return;
|
||||||
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />
|
||||||
|
${colorPalette(col.color)}
|
||||||
|
<div style="display:flex;gap:8px;margin-top:2px">
|
||||||
|
<button class="mm-btn" onclick="moveCollection(${id},-1)"><i data-lucide="arrow-up"></i> Выше</button>
|
||||||
|
<button class="mm-btn" onclick="moveCollection(${id},1)"><i data-lucide="arrow-down"></i> Ниже</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
||||||
{ label: 'Удалить', onClick: async () => {
|
{ label: 'Удалить', onClick: async () => {
|
||||||
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
|
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
|
||||||
@@ -459,10 +652,11 @@
|
|||||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
|
try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
window.editCollection = editCollection;
|
window.editCollection = editCollection;
|
||||||
|
|
||||||
@@ -502,10 +696,10 @@
|
|||||||
const up = await LS.uploadMaterialFile(fd);
|
const up = await LS.uploadMaterialFile(fd);
|
||||||
if (o.materialId) {
|
if (o.materialId) {
|
||||||
// Аннотация существующего материала — перезаписываем его, а не плодим копии
|
// Аннотация существующего материала — перезаписываем его, а не плодим копии
|
||||||
await LS.updateMaterial(o.materialId, { url: up.url });
|
await LS.updateMaterial(o.materialId, { url: up.url, thumbUrl: up.thumbUrl || null });
|
||||||
close(); load(); LS.toast('Изменения сохранены', 'success');
|
close(); load(); LS.toast('Изменения сохранены', 'success');
|
||||||
} else {
|
} else {
|
||||||
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null });
|
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, thumbUrl: up.thumbUrl || null, sourceTitle: o.sourceTitle || null });
|
||||||
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
|
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
|
||||||
}
|
}
|
||||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
|
||||||
@@ -525,6 +719,7 @@
|
|||||||
async function toFlashcard(id) {
|
async function toFlashcard(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return;
|
if (!mt) return;
|
||||||
|
await ensureFullBody(mt);
|
||||||
let decks = [];
|
let decks = [];
|
||||||
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
|
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
|
||||||
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
|
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Конструктор симуляций — LearnSpace</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/css/ls.css"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||||
|
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ── Раскладка редактора: панели слева + превью справа ── */
|
||||||
|
.sbu-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
|
||||||
|
.sbu-toolbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
|
||||||
|
flex-shrink: 0; backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.sbu-tb-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.sbu-tb-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
|
||||||
|
.sbu-tb-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.sbu-tb-btn { display: inline-flex; align-items: center; gap: 6px; font-size: .82rem; padding: 8px 14px; }
|
||||||
|
.sbu-tb-btn svg { flex-shrink: 0; }
|
||||||
|
.sbu-badge { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 9px; border-radius: 99px; background: rgba(15,23,42,0.08); color: var(--text-3); }
|
||||||
|
.sbu-badge-pub { background: rgba(16,185,129,0.14); color: #0f9d6e; }
|
||||||
|
|
||||||
|
.sbu-body { display: flex; flex: 1; min-height: 0; }
|
||||||
|
/* панели */
|
||||||
|
.sbu-panels { width: 360px; flex-shrink: 0; overflow-y: auto; padding: 14px; border-right: 1px solid var(--border); background: #fafbfd; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
/* превью */
|
||||||
|
.sbu-preview-col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.sbu-preview-hint { font-size: .76rem; color: var(--text-3); padding: 7px 16px; border-bottom: 1px dashed var(--border); background: #fff; }
|
||||||
|
.sbu-preview { flex: 1; min-height: 0; position: relative; background: #0D0D1A; }
|
||||||
|
.sbu-preview .sim-spec-root { position: absolute; inset: 0; }
|
||||||
|
|
||||||
|
/* ── секция-аккордеон ── */
|
||||||
|
.sbu-sec { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; }
|
||||||
|
.sbu-sec-hdr { width: 100%; display: flex; align-items: center; gap: 8px; padding: 11px 13px; border: none; background: none; cursor: pointer; font-family: 'Manrope', sans-serif; }
|
||||||
|
.sbu-sec-title { font-weight: 800; font-size: .82rem; color: var(--text); flex: 1; text-align: left; }
|
||||||
|
.sbu-sec-count { font-size: .68rem; font-weight: 700; color: var(--violet); background: rgba(155,93,229,0.12); padding: 2px 8px; border-radius: 99px; }
|
||||||
|
.sbu-sec-chev { display: inline-flex; color: var(--text-3); transition: transform .18s; }
|
||||||
|
.sbu-sec.open .sbu-sec-chev { transform: rotate(180deg); }
|
||||||
|
.sbu-sec-body { display: none; flex-direction: column; gap: 10px; padding: 0 13px 13px; }
|
||||||
|
.sbu-sec.open .sbu-sec-body { display: flex; }
|
||||||
|
|
||||||
|
/* ── поля ── */
|
||||||
|
.sbu-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.sbu-field-lbl { font-size: .72rem; font-weight: 600; color: var(--text-3); }
|
||||||
|
.sbu-in { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; font-size: .82rem; background: #fff; color: var(--text); }
|
||||||
|
.sbu-in:focus { outline: none; border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,0.12); }
|
||||||
|
.sbu-in-sm { padding: 6px 9px; font-size: .78rem; }
|
||||||
|
.sbu-in-expr { font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; }
|
||||||
|
.sbu-in-color { font-family: 'Menlo', 'Consolas', monospace; }
|
||||||
|
textarea.sbu-in { resize: vertical; }
|
||||||
|
.sbu-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.sbu-row4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; }
|
||||||
|
.sbu-mini { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.sbu-mini-lbl { font-size: .66rem; color: var(--text-3); }
|
||||||
|
.sbu-divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||||
|
.sbu-sub { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); margin-top: 4px; }
|
||||||
|
.sbu-checks { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.sbu-chk, .sbu-of-check { display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--text-2); cursor: pointer; }
|
||||||
|
.sbu-chk input, .sbu-of-check input { accent-color: var(--violet); }
|
||||||
|
|
||||||
|
/* ── кнопки ── */
|
||||||
|
.sbu-add { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 12px; border: 1px dashed var(--border-h); border-radius: 9px; background: #fff; cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; color: var(--text-2); transition: border-color .12s, color .12s; }
|
||||||
|
.sbu-add:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
|
.sbu-add-sm { padding: 6px 10px; font-size: .74rem; }
|
||||||
|
.sbu-add-row { display: flex; gap: 8px; align-items: stretch; }
|
||||||
|
.sbu-add-row .sbu-add { flex: 1; }
|
||||||
|
.sbu-add-row select { flex: 0 0 130px; }
|
||||||
|
.sbu-icon-btn { width: 28px; height: 28px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; color: var(--text-3); display: inline-flex; align-items: center; justify-content: center; transition: border-color .12s, color .12s; }
|
||||||
|
.sbu-icon-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
|
.sbu-del:hover { border-color: #ef4444; color: #ef4444; }
|
||||||
|
.sbu-place.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
|
||||||
|
|
||||||
|
/* ── параметр ── */
|
||||||
|
.sbu-param, .sbu-obj, .sbu-plot, .sbu-wall, .sbu-spring { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fff; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.sbu-param-top { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.sbu-param-top .sbu-in { flex: 1; }
|
||||||
|
|
||||||
|
/* ── объект ── */
|
||||||
|
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
|
||||||
|
.sbu-obj-hdr { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
|
||||||
|
.sbu-in-id { flex: 1; max-width: 120px; }
|
||||||
|
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||||
|
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||||
|
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
|
||||||
|
.sbu-fx:hover { background: rgba(155,93,229,0.2); }
|
||||||
|
.sbu-of.has-err .sbu-in { border-color: #ef4444; }
|
||||||
|
.sbu-of-err { font-size: .68rem; color: #ef4444; }
|
||||||
|
.sbu-of-check { grid-column: 1 / -1; }
|
||||||
|
.sbu-latex { grid-column: 1 / -1; padding: 8px; background: #f1f5f9; border-radius: 8px; font-size: .9rem; min-height: 20px; text-align: center; color: var(--text); }
|
||||||
|
.sbu-empty-sm { font-size: .76rem; color: var(--text-3); padding: 6px 2px; }
|
||||||
|
|
||||||
|
/* ── физика ── */
|
||||||
|
.sbu-phys-toggle { font-weight: 700; font-size: .82rem; }
|
||||||
|
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sbu-wall { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
/* ── палитра ── */
|
||||||
|
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
|
||||||
|
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
|
||||||
|
.sbu-pal-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sbu-pal-chip { padding: 4px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; cursor: pointer; color: var(--text-2); }
|
||||||
|
.sbu-pal-chip:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.06); }
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.sbu-body { flex-direction: column; }
|
||||||
|
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
.sbu-preview { min-height: 320px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="app-sidebar"></aside>
|
||||||
|
<main class="sb-content">
|
||||||
|
<div class="sbu-wrap">
|
||||||
|
<div class="sbu-toolbar" id="sbu-toolbar"></div>
|
||||||
|
<div class="sbu-body">
|
||||||
|
<div class="sbu-panels" id="sbu-panels"></div>
|
||||||
|
<div class="sbu-preview-col">
|
||||||
|
<div class="sbu-preview-hint">
|
||||||
|
Живое превью обновляется при правках. Включите «прицел» у объекта и кликните по сцене, чтобы задать его координаты. Кнопка «Тест» запускает анимацию.
|
||||||
|
</div>
|
||||||
|
<div class="sbu-preview" id="sbu-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/mobile.js"></script>
|
||||||
|
<!-- движок спек-симуляций (Фазы 0–2) -->
|
||||||
|
<script src="/js/labs/_sim_expr.js"></script>
|
||||||
|
<script src="/js/labs/_sim_engine.js"></script>
|
||||||
|
<!-- KaTeX для превью LaTeX-подписей -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||||
|
<!-- логика редактора -->
|
||||||
|
<script src="/js/sim-builder.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Гейт: только teacher/admin
|
||||||
|
var ip = LS.initPage() || {};
|
||||||
|
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||||
|
|
||||||
|
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
|
||||||
|
document.getElementById('sbu-preview').innerHTML =
|
||||||
|
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = SimBuilder.create({
|
||||||
|
host: document.querySelector('.sbu-wrap'),
|
||||||
|
previewHost: document.getElementById('sbu-preview'),
|
||||||
|
panelHost: document.getElementById('sbu-panels'),
|
||||||
|
toolbarHost: document.getElementById('sbu-toolbar')
|
||||||
|
});
|
||||||
|
|
||||||
|
// ?id= -> загрузить существующую симуляцию
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var id = params.get('id');
|
||||||
|
if (id) {
|
||||||
|
builder.init();
|
||||||
|
LS.customSimGet(id).then(function (res) {
|
||||||
|
if (res && res.sim) {
|
||||||
|
builder.loadFromSim(res.sim);
|
||||||
|
} else {
|
||||||
|
LS.toast('Симуляция не найдена', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function (e) {
|
||||||
|
LS.toast((e && e.message) || 'Не удалось загрузить симуляцию', 'error');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
builder.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__simBuilder = builder; // для отладки
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1037,8 +1037,10 @@ window.LS = {
|
|||||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
listMaterials, getMaterial, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
|
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||||
|
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||||
@@ -1251,6 +1253,7 @@ async function uploadMaterialFile(formData) {
|
|||||||
}
|
}
|
||||||
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
||||||
async function listMaterials() { return req('GET', '/materials'); }
|
async function listMaterials() { return req('GET', '/materials'); }
|
||||||
|
async function getMaterial(id) { return req('GET', `/materials/${id}`); }
|
||||||
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
||||||
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
||||||
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
||||||
@@ -1259,6 +1262,16 @@ async function getActivity() { return req('GET', '/dashboard/activit
|
|||||||
async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); }
|
async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); }
|
||||||
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
|
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
|
||||||
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }
|
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }
|
||||||
|
async function customSimsList() { return req('GET', '/custom-sims'); }
|
||||||
|
async function customSimGet(id) { return req('GET', `/custom-sims/${id}`); }
|
||||||
|
async function customSimCreate(data) { return req('POST', '/custom-sims', data); }
|
||||||
|
async function customSimUpdate(id, d) { return req('PUT', `/custom-sims/${id}`, d); }
|
||||||
|
async function customSimDelete(id) { return req('DELETE', `/custom-sims/${id}`); }
|
||||||
|
async function customSimShare(id, d) { return req('POST', `/custom-sims/${id}/share`, d); }
|
||||||
|
async function customSimClone(id) { return req('POST', `/custom-sims/${id}/clone`); }
|
||||||
|
async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); }
|
||||||
|
async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); }
|
||||||
|
async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); }
|
||||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
|
|
||||||
${G('practice', 'Практика и игры', `
|
${G('practice', 'Практика и игры', `
|
||||||
${L('/lab', 'atom', 'Лаборатория')}
|
${L('/lab', 'atom', 'Лаборатория')}
|
||||||
|
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||||
${L('/biochem', 'flask-conical', 'Биохимия')}
|
${L('/biochem', 'flask-conical', 'Биохимия')}
|
||||||
${L('/red-book', 'leaf', 'Красная книга')}
|
${L('/red-book', 'leaf', 'Красная книга')}
|
||||||
${L('/crossword', 'grid-3x3', 'Кроссворд')}
|
${L('/crossword', 'grid-3x3', 'Кроссворд')}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# «Мои материалы» — v2: харднинг и доводка
|
||||||
|
|
||||||
|
> Составлен Opus 2026-06-13. Базовый план (PLAN.md, Фазы 1–6) **полностью реализован**.
|
||||||
|
> Его раздел «Сквозные риски» отложил ровно то, что закрывает этот план: учёт/лимиты/чистку
|
||||||
|
> хранилища и `materials.test.js`. Источник истины по текущему состоянию — код
|
||||||
|
> (`studentMaterialsController.js`, `materials.js`, `my-materials.html`, `board-clip.js`,
|
||||||
|
> `material-save.js`) и [[reference_student_materials]].
|
||||||
|
|
||||||
|
Готчи проекта: новый `:id`-роут → `// @public-by-design` + проверка владельца; большие HTML — только Edit;
|
||||||
|
без эмодзи (inline SVG `.ic`); коммит поимённо + push; перезапуск сервера при правке backend; ветка
|
||||||
|
`feature/sim-builder` в рабочем дереве — НЕ коммитить чужие правки, только свои файлы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 1 — Целостность и безопасность (backend, фундамент) ✅ цель этого захода
|
||||||
|
|
||||||
|
1. **Ссылочно-подсчётная чистка файлов.** `DELETE /:id` и смена `url` (аннотация) сейчас оставляют
|
||||||
|
файл в `uploads/materials/` сиротой. `share` копирует `url` дословно → несколько строк ссылаются на
|
||||||
|
ОДИН файл, поэтому `unlink` только когда на `url` не ссылается ни одна строка. Хелпер
|
||||||
|
`releaseFileForUrl(url)` вызывается ПОСЛЕ delete/update.
|
||||||
|
2. **Allowlist схемы URL.** `create`/`update` принимали любой `url` → `link` со схемой `javascript:`
|
||||||
|
рендерится как рабочий `<a href>` (раздача делает это вектором учитель→ученики). Хелпер `safeUrl`:
|
||||||
|
только `http(s)://` или app-relative `/…` (не `//host`); иначе 400.
|
||||||
|
3. **Квота на пользователя.** Колонка `bytes` (мигр. 073), счёт `SUM(bytes)`/`COUNT(*)`. Лимит по числу
|
||||||
|
материалов — в `create()`; лимит по байтам — в `uploadPersonalFile` (до приёма файла). Конфигурируемо
|
||||||
|
через `MATERIALS_MAX_ITEMS` / `MATERIALS_MAX_BYTES` (для тестов — низкий потолок).
|
||||||
|
4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner,
|
||||||
|
валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле).
|
||||||
|
|
||||||
|
## Фаза 2 — Производительность ✅
|
||||||
|
- ✅ `GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст —
|
||||||
|
ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед
|
||||||
|
просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст).
|
||||||
|
- ✅ Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует
|
||||||
|
`PAGE_SIZE=60` карточек + «Показать ещё»; `_shown` сбрасывается на смену фильтра. Снимает стоимость
|
||||||
|
рендера тысяч узлов, не ломая клиентский поиск (keyset на сервере не нужен на текущих объёмах).
|
||||||
|
- ✅ Серверные миниатюры `board/image`: `uploadPersonalFile` (sharp → webp ≤480px) возвращает `{url, thumbUrl}`;
|
||||||
|
колонка `thumb_url` (мигр. **074**); грид рисует `<img src=thumb_url||url>`, просмотр/скачивание/аннотация —
|
||||||
|
полный `url`. Чистится по ссылкам (releaseFileForUrl теперь матчит url **и** thumb_url); share копирует thumb;
|
||||||
|
квота считает файл+миниатюру. Клиентские сейверы (board-clip/material-save/textbook-clip/draw) пробрасывают `thumbUrl`.
|
||||||
|
|
||||||
|
## Фаза 3 — Доводка заложенных фич ✅
|
||||||
|
- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра.
|
||||||
|
- ✅ Ссылка «открыть исходный урок» на карточке (`/my-lessons?session=<id>`, есть `source_session_id`).
|
||||||
|
- ✅ Цвет папки (палитра 8 пресетов, тинт иконки в рейле) + сортировка папок «Выше/Ниже» в модалке правки
|
||||||
|
(нормализует `sort_order` к индексам). `safeColor` гейтит inline-style инъекцию (только hex).
|
||||||
|
|
||||||
|
## Фаза 4 — UX ✅
|
||||||
|
- ✅ Варианты сортировки (новые/старые/имя/тип) — селект в тулбаре.
|
||||||
|
- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API).
|
||||||
|
- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview` → `mathHtml`).
|
||||||
|
|
||||||
|
### Статус — ПЛАН V2 ВЫПОЛНЕН
|
||||||
|
**Ф1–Ф4 ✅.** Backend: 19 тестов `materials.test.js` (CRUD/владелец/коллекции/share/URL-allowlist/квота/
|
||||||
|
ссылочная чистка url+thumb/round-trip thumb_url). Frontend: headless-смоук `my-materials.html` (синтаксис +
|
||||||
|
deep-link/теги/чекбокс/bulk/тинт папки + `<img>` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и
|
||||||
|
client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
**Ф1 (этот заход) → Ф2 → Ф3 → Ф4.** Ф1 — серверный фундамент (риск-возврат, без него фронт-фичи множат
|
||||||
|
мусор). Дальше преимущественно фронтенд `my-materials.html` + точечные ручки API.
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Feature Context: Конструктор симуляций (SimForge)
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
|
||||||
|
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
|
||||||
|
эффект и в билдере, и в /lab, и на доске.
|
||||||
|
- **Чтение стилей** расширено в `_prepareObjects`; применение — через два хелпера: `_applyStroke(ctx,o)`
|
||||||
|
(ставит globalAlpha=opacity, lineWidth=width, lineJoin/Cap='round', setLineDash по lineStyle, glow→shadow)
|
||||||
|
и `_fillStyleFor(ctx,o,x0,y0,x1,y1)` (линейный градиент `gradient:[c0,c1]` по bbox ИЛИ сплошной fillColor;
|
||||||
|
всё — canvas-стоки, мусорный цвет игнорится). Каждая ветка `_drawObject` в своём `save/restore`.
|
||||||
|
- **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1, `lineStyle` solid|dashed|dotted,
|
||||||
|
`fill`/`gradient:[c0,c1]`, `glow:true`/`shadow`, `pointStyle` filled|hollow|cross|ring, `trailFade`/
|
||||||
|
`trailWidth`/`trailLen`. Полный список с дефолтами — в IMPROVEMENTS.md (Handoff P2→P3/P4).
|
||||||
|
- **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный барбед-треугольник (вырез у основания),
|
||||||
|
длина `max(9,width*3.2)`px, тело линии укорочено на длину головы. **Точки** `_drawPoint` — 4 стиля
|
||||||
|
(filled-деф. = кружок + тонкая белая обводка). **Трассы** `_drawTrail(ctx,pts,o)` — посегментное
|
||||||
|
затухание (alpha 0.08→0.68 от хвоста к голове) либо одна линия без fade.
|
||||||
|
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов, циклически по индексу) вместо единого
|
||||||
|
`#06D6E0`; явный `color`/`fill` всегда сохраняется. `_drawPlot` теперь зовёт `_applyStroke` (dash/opacity/
|
||||||
|
glow на кривых).
|
||||||
|
- Верификация: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов + РЕАЛЬНЫЕ
|
||||||
|
`_sim_expr.js`+`_sim_engine.js`) 23/23: рендер 18-объектной спеки (все типы + все новые поля) ×4 кадра без
|
||||||
|
throw; ctx не протекает (save/restore-баланс depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра);
|
||||||
|
setLineDash/createLinearGradient/fill/stroke/arc вызваны (dashed/dotted/gradient/fills); arrowHeadLen
|
||||||
|
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
|
||||||
|
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
|
||||||
|
в комментарии стр.15. git status: тронут только `_sim_engine.js`.
|
||||||
|
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под
|
||||||
|
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
|
||||||
|
`_drawPoint` готовы к переиспользованию.
|
||||||
|
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
|
||||||
|
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
|
||||||
|
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
|
||||||
|
- **Fix смещения вправо:** `_build` больше не делит строку flex с фикс-панелью 260px. Теперь
|
||||||
|
`root`(relative) → `stage`(absolute inset:0, canvas+labels на всю площадь) + плавающая `panel`
|
||||||
|
(absolute left/top:10px, z-index:5, pointer-events:auto, сворачивается `_togglePanel`, есть только при params)
|
||||||
|
+ бар кнопок вида (right/bottom:10px). Сцена центрирована во всю ширину хоста; пустая спека не съезжает.
|
||||||
|
- **Сетка:** minor(~34px)/major(×5), адаптивна к zoom (`_niceStep(targetPx)` завязан на `_scale`, шаги
|
||||||
|
1/2/5·10^n), рисуется через всю видимую область (`_visibleWorld`), линии на .5px (резкость, без «ступенек»).
|
||||||
|
- **Оси:** X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений
|
||||||
|
(светлый текст + тень на тёмном фоне, `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
|
||||||
|
- **Zoom/Pan:** колесо → `_zoomAt(lx,ly,factor)` (мир-точка под курсором инвариантна, зум-кламп 0.1..50×);
|
||||||
|
pan = drag пустого места (`_setupZoomPan`), приоритет ручек/тел через общий `_pickHandleAt` (pan стартует,
|
||||||
|
только если хит-тест вернул null). Кнопки вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный
|
||||||
|
viewport, SVG `.ic` в углу сцены). `_viewLocked` сохраняет вид при ресайзе. DPR-резкость сохранена.
|
||||||
|
- **destroy** снимает wheel+pan-листенеры и ResizeObserver. Верификация: `node --check` OK; headless-смоук
|
||||||
|
(DOM/canvas-стаб + реальные `_sim_expr.js`+`_sim_engine.js`) 40/40 (центрирование пустой спеки, zoom-инвариант
|
||||||
|
курсора+кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют
|
||||||
|
zoom/pan, fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy чист);
|
||||||
|
эмодзи нет (только `→` в комментариях, как в существующем коде), eval/Function нет.
|
||||||
|
- **Следующее (P2):** качество графики объектов (`_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/
|
||||||
|
`_prepareObjects` в `_sim_engine.js`).
|
||||||
|
- **ВСЕ ФАЗЫ (0–7) РЕАЛИЗОВАНЫ** (в рабочем дереве, не закоммичено — коммит за оркестратором).
|
||||||
|
Фича «Конструктор симуляций» функционально полна: рантайм+физика, БД+API, билдер, каталог в /lab,
|
||||||
|
раздача/клон/шаблоны/привязка, доска онлайн-урока с синхроном классу.
|
||||||
|
- **Фаза 7 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено). Custom-sim на доске онлайн-урока через
|
||||||
|
существующий iframe-конвейер. **Аддитивные** правки трёх файлов; рабочее дерево по ним было ЧИСТЫМ
|
||||||
|
до начала (classroom.html не имел чужих незакоммиченных правок параллельной сессии — проверено git status).
|
||||||
|
- `backend/src/controllers/classroom/sim.js` (+21/-2): `simOpen` принимает `simId='custom:<dbid>'`,
|
||||||
|
валидирует доступ (владелец ИЛИ published ИЛИ admin; иначе 404/403). Встроенный id — прежний regex
|
||||||
|
`^[a-z0-9_-]{1,40}$`. `simState/simMode/simAnnotate/simClose` НЕ тронуты (state-объект уже произвольный).
|
||||||
|
- `frontend/classroom.html` (+31/-4): `_crLoadCustomSims()` (кэш `LS.customSimsList`), `crOpenSimPicker`
|
||||||
|
async с предзагрузкой, `_crRenderSimGrid` мёржит свои+published custom (бейдж «Моя», id `custom:<dbid>`,
|
||||||
|
XSS-escape). Существующий `crPickSim` передаёт id как есть; `onSimOpen` грузит iframe
|
||||||
|
`/lab?embed=1&sim=custom:<id>` (encodeURIComponent безопасен, lab декодирует param).
|
||||||
|
- `frontend/js/labs/lab-glue.js` (+48/-1): `_bridgeCustomSimState(real)` — подключает custom-sim к
|
||||||
|
тому же мосту `sim_state`/`apply_sim_state`, что и встроенные. getState=`{params,running}` /
|
||||||
|
applyState=`setParam`+play/pause поверх SimEngine-инстанса (`real.instance()`). Регистрируется под
|
||||||
|
ключом `_autoSim` (`custom:<dbid>`, т.к. apply у ученика берёт `_simStateRegistry[_autoSim]`),
|
||||||
|
запускает `_startStateEmit`. Вызов в `_registerLazy.open()` после `real.open(ctx)` (только embed).
|
||||||
|
- **Синхрон:** параметры слайдеров + play/pause — полный (demo-режим). Время `t` (фаза анимации)
|
||||||
|
покадрово НЕ синхронится (by design; ученик крутит свой rAF при running). Аннотации/режим — через
|
||||||
|
существующий конвейер без изменений (id-agnostic). Закрытие/смена: `frame.src='about:blank'` сносит
|
||||||
|
весь документ iframe (SimEngine+rAF+слушатели) — утечек нет.
|
||||||
|
- **Доступ:** двойная проверка — `simOpen` на сервере (постановка на доску) + `GET /custom-sims/:id`
|
||||||
|
при загрузке спеки в iframe. Чужой draft → 403 на обоих. На доску только своё или published.
|
||||||
|
- Верификация: `node --check` sim.js / lab-glue.js / извлечённого инлайна classroom.html — OK;
|
||||||
|
эмодзи нет (UTF-8-скан текст-элементов: 0 в js, 11 в classroom.html — все ПРЕ-существующие
|
||||||
|
×/⇒/реакции, не в моих строках); eval/new Function — 0 call-sites; `npm test` 240/248 pass
|
||||||
|
(8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom; обе custom-sims-сьюты зелёные).
|
||||||
|
git status: тронуты только мои 3 файла (+плановые .md); js/api.js НЕ нужен (методы есть с Ф3).
|
||||||
|
- **Фаза 6 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Файлы:
|
||||||
|
`backend/src/controllers/customSimController.js` (+share/clone/related/addLink/removeLink, импорт
|
||||||
|
`pushNotif`), `backend/src/routes/customSims.js` (+POST `/:id/share`, POST `/:id/clone`, GET
|
||||||
|
`/:id/related`, POST `/:id/links`, DELETE `/:id/links/:linkId`), `js/api.js` (+`customSimShare/
|
||||||
|
Clone/Related/AddLink/DelLink`), `frontend/js/labs/lab-glue.js` (аддитивно в IIFE LabCustom:
|
||||||
|
кнопки share/clone/publish-toggle на карточках + делегат + `shareToClass/clone/setStatus`, ICON-блок),
|
||||||
|
`frontend/js/sim-builder.js` (тулбар: «Шаблон»/«Раздать»/publish-toggle; методы `setStatus/
|
||||||
|
openShareModal/openTemplateModal`; данные `TEMPLATES`×4; ICON.template/unpublish),
|
||||||
|
`backend/tests/custom-sims-share.test.js` (new, 15 it, все зелёные).
|
||||||
|
- **РЕШЕНИЕ копия-vs-доступ (зафиксировано):** published custom-sim видна ВСЕМ в каталоге /lab
|
||||||
|
(`list`/`get` отдают published любому; custom-sim НЕ гейтится allowlist'ом content_access 'sim' —
|
||||||
|
тот гейтит только legacy `lab_sims`). Поэтому «раздать классу» = (1) авто-публикация
|
||||||
|
(status→published), (2) ДОЛГОВЕЧНОЕ адресное уведомление ученикам класса через `pushNotif`
|
||||||
|
(notifications-таблица + SSE) со ссылкой `/lab?sim=custom:<id>`. БЕЗ копии (в отличие от «Моих
|
||||||
|
материалов», где оригинал приватный и копия обязательна) и БЕЗ записи content_access.
|
||||||
|
- **Привязка к программе:** переиспользован `lab_sim_links` с `sim_id='custom:<id>'` (sim_id TEXT —
|
||||||
|
отдельная таблица не нужна). Связями СВОЕЙ симуляции управляет владелец/admin (не только admin как
|
||||||
|
у lab_sims). Backend + GET `/related` готовы; UI-редактор связей + чипы в каталоге — остаток (handoff).
|
||||||
|
- **Клон:** копия spec вызвавшему как draft (title += ' (копия)', version=1). Источник: своя любая
|
||||||
|
ИЛИ чужая published (чужой draft → 403).
|
||||||
|
- Верификация: `node --check` всех 6 изм. файлов OK; эмодзи нет (скан — только `→`/`∑` в комментариях,
|
||||||
|
как в существующем коде); eval/Function нет; `npm run lint:routes` 0 unprotected (baseline 0);
|
||||||
|
`npm test` 216/224 pass (8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom — не моя
|
||||||
|
фаза; обе custom-sims-сьюты зелёные). git status: только мои файлы; classroom.html/lab.html не тронуты.
|
||||||
|
- **Фаза 5 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
|
||||||
|
**аддитивные** правки двух файлов параллельной сессии (без рефактора их кода): рабочее дерево
|
||||||
|
по ним было ЧИСТЫМ до начала. classroom.html / backend / `_sim_deps.js` НЕ тронуты.
|
||||||
|
- **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук
|
||||||
|
`if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;` —
|
||||||
|
переводит deep-link/клик `custom:<dbid>` в реестровый id `customsim_<dbid>` (LabRegistry.get/has
|
||||||
|
обрезают часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op.
|
||||||
|
- **`frontend/js/labs/lab-glue.js`**: (а) `renderSims()` merge +`&& !m._custom` (custom не в
|
||||||
|
основной сетке) и вызов `LabCustom.renderSection(_catFilter)`; (б) init-блок (non-embed и embed)
|
||||||
|
зовёт `LabCustom.init()`, отложенное открытие `?sim=custom:*` до загрузки списка; (в) новый
|
||||||
|
IIFE **`window.LabCustom`** в конце файла.
|
||||||
|
- **Поток**: `LS.customSimsList()` (мета без spec) → `_registerLazy` кладёт в LabRegistry
|
||||||
|
манифест-заглушку `customsim_<dbid>` (`_custom:true`) с ленивым `open()`. Секция «Мои симуляции»
|
||||||
|
`#custom-sim-section` (создаётся динамически в `#lab-home`, без правок lab.html/CSS) рендерит
|
||||||
|
карточки из `_meta`. Открытие: `resolveId` → дисп. реестра → `open()` заглушки →
|
||||||
|
`ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=regId` → `registerSpecSim(spec)`
|
||||||
|
(Ф0-адаптер, заменяет заглушку на месте) → `setActive(real)`+`real.open(ctx)` (монтирует SimEngine).
|
||||||
|
**spec лениво** — на старте /lab не грузится. Движок (`_sim_*`) уже eager (Ф0), ленивый файл не нужен.
|
||||||
|
- **Карточка**: preview-SVG + cat-бейдж + бейджи «Моя»(owner)/«Опубликована»(status)/«Черновик»
|
||||||
|
+ кнопки «Редактировать»→`/sim-builder?id=<dbid>` / «Удалить»→`LS.customSimDelete` (владельцу,
|
||||||
|
`owner_id===user.id`). Делегированный клик по `#custom-sim-section`. Иконки — inline SVG `.ic`.
|
||||||
|
- Верификация: `node --check` обоих изменённых файлов OK; эмодзи нет (скан кодпойнтов — только
|
||||||
|
math/box-drawing глифы ∑/═/─/→, как в существующем коде); eval/Function нет; headless-смоук
|
||||||
|
(vm + DOM/SimEngine/LS-стабы, РЕАЛЬНЫЕ `_registry.js`+`_sim_adapter.js`) 22/22: resolveId,
|
||||||
|
регистрация ленивых манифестов+флаг `_custom`, секция/карточки, бейджи, owner-only edit/del,
|
||||||
|
deep-link `data-open`, lazy spec→registerSpecSim→mount, reopen синхронно, delete, встроенные не сломаны.
|
||||||
|
git status: изменены только lab-init.js/lab-glue.js (+ плановые .md); classroom.html/backend чисты.
|
||||||
|
- **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
|
||||||
|
новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка
|
||||||
|
`js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||||
|
- **Учительский редактор `/sim-builder`** (гейт teacher/admin через `LS.initPage()`): панели-
|
||||||
|
аккордеоны (Мета+сцена / Параметры / Объекты / Графики / Физика) слева + живое превью
|
||||||
|
(`SimEngine.mount`, перемонтаж с debounce 280мс) справа + тулбар (Тест/Сброс/Сохранить/
|
||||||
|
Опубликовать). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost})`.
|
||||||
|
- **Генерация спеки** `buildSpec()` → JSON v1 (specVersion:1, meta, viewport, time, params[],
|
||||||
|
objects[]+merged plots, physics?). `_uid` — UI-метка, вырезается; plot материализуется
|
||||||
|
(range_a/range_b → range[a,b]); числовые поля — число ИЛИ строка-выражение (движок ест оба).
|
||||||
|
- **Выражения**: каждое поле проверяется `SimExpr.compile` → inline-ошибка у поля; палитра
|
||||||
|
функций/констант/параметров/`id.x` через модалку. **Запрет имени param `e`** (и pi/t/w/h/...).
|
||||||
|
- **Drag-on-preview**: кнопка-«прицел» у объекта → клик/перетаскивание по `inst.canvas` (px→мир
|
||||||
|
через `inst._toWorld()`) пишет x/y (или конец segment/vector) в свойства. Только на паузе.
|
||||||
|
- **Save/Load**: `customSimCreate`/`customSimUpdate` (?id= → update + replaceState), публикация
|
||||||
|
`status:'published'`; `?id=<id>` → `customSimGet` → `loadFromSim` раскладывает по панелям.
|
||||||
|
- **Клиентская валидация** зеркалит серверную (params≤50/objects≤200/walls≤20/springs≤50/
|
||||||
|
expr≤500/restitution 0..1/JSON≤200КБ) с дружелюбной модалкой-списком ошибок ДО запроса.
|
||||||
|
- **Сайдбар**: пункт `/sim-builder` «Конструктор симуляций» (teacher-only, icon pencil-ruler)
|
||||||
|
в группе «Практика и игры» после «Лаборатория» — минимальная правка `js/sidebar.js`.
|
||||||
|
- Верификация: `node --check` обоих новых .js + извлечённого инлайна html OK; эмодзи нет (скан
|
||||||
|
кодпойнтов, включая no-entry sign — заменён на текст); eval/Function нет (вычисления — SimExpr);
|
||||||
|
headless-смоук (vm + DOM/Blob-стаб) 23/23: buildSpec форма, merge plot+range, strip _uid,
|
||||||
|
physics-блок, валидация valid/reserved-`e`/syntax-error, loadFromSim round-trip стабилен.
|
||||||
|
lab.html/lab-glue.js/_sim_engine.js/_sim_expr.js НЕ тронуты (git status).
|
||||||
|
- **Фаза 3 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только backend + клиент `js/api.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||||
|
- **Миграция 071** `backend/src/db/migrations/071_custom_sims.sql` — таблица `custom_sims` (применена к живой БД через `npm run migrate`, без ошибок).
|
||||||
|
- **API `/api/custom-sims`** (роутер `backend/src/routes/customSims.js`, контроллер `backend/src/controllers/customSimController.js`, смонтировано в `server.js`): GET `/` (свои+published), GET `/:id` (own ИЛИ published), POST `/` (teacher/admin), PUT `/:id` (owner/admin), DELETE `/:id` (owner/admin). Read — router-level authMiddleware; мутации — inline requireRole + per-row ownership.
|
||||||
|
- **`validateSpec(spec)`** в контроллере — серверная валидация БЕЗ исполнения: ≤200KB, specVersion=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500/глубина≤8/points≤1000), whitelist типов объектов, physics (restitution 0..1, dt 1/2000..1/30, mass>0), санитизация текст-полей (escape &<>). Возврат `{ ok, error?, clean? }`.
|
||||||
|
- **Клиент** `js/api.js`: `customSimsList/Get/Create/Update/Delete` → `req(...)`, добавлены в `window.LS`.
|
||||||
|
- Верификация: `node --check` всех новых/изменённых .js OK; `npm run migrate` OK; `npm run lint:routes` чисто (0 unprotected, baseline 0); `backend/tests/custom-sims.test.js` 24/24 pass; общий suite 201/209 (8 fail = 3 baseline auth.test.js + 5 page-тестов без devDep `jsdom` — окружение, не моя фаза). Эмодзи нет; БД через node:sqlite.
|
||||||
|
- **Фаза 2 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||||
|
- **Физический режим**: блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. Фикс-шаговый полу-неявный Эйлер (накопитель dt, кламп шага/скорости), опора на математику `_fx_motion.spring`. Упругие столкновения круг-круг и круг-стена (restitution), пружины (Гук+демпф) между телами/якорями. Drag тел (тащишь — позиция, отпускаешь — бросок со скоростью). Тела сосуществуют с формульными объектами Ф0/Ф1.
|
||||||
|
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в env первыми — снимает forward-ref проблему однопроходного env для тел.
|
||||||
|
- **Интегратор экспортирован** как `window.SimPhysics` (для билдера/доски/headless). Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
|
||||||
|
- Демо за флагом: +`customphys` (пружинный маятник), +`customballs` (упругие шары). Гочи: имя param `e` зарезервировано (число Эйлера) — в демо «шары» упругость названа `el`.
|
||||||
|
- Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии; эмодзи нет (скан кодпойнтов); headless (vm+DOM/canvas-стаб) 28/28: падение под гравитацией (парабола, без NaN), упругие шары (скорости меняются, тела в коробке, ограничены), пружинный маятник (колебания, без взрыва), drag тела (позиция+бросок), смешанная сцена (формульный point + segment на ball.x/y + readout ball.y живут вместе), `SimPhysics.step` raw.
|
||||||
|
- **Фаза 1 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||||
|
- Новые типы объектов спеки: **plot** (график `f(var)` на canvas движка, `trace` — след по `t`), **readout** (живой бейдж, мягкая ошибка через `evalSafe`), **vector** с формой `origin+dx/dy`. **drag** на point/circle (`drag:{param,axis,min,max,paramY}`) — pointer events (мышь+тач), хит-тест в px (16px), двойной clamp (drag.min/max + диапазон параметра). Точные поля — в шапке `_sim_engine.js` и handoff phase-1.
|
||||||
|
- Демо `customdemo` расширено: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории, 2 readout (R, H). По-прежнему за флагом.
|
||||||
|
- Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии, ни одного call-site; эмодзи нет (скан кодпойнтов); headless-тесты (vm + DOM-стаб): подготовка типов, vector end=origin+(dx,dy), plot evaluate, readout evalSafe, drag clamp+slider-sync, рендер всех 8 типов демо ×6 кадров без ошибок, trail/readout-слоты накапливаются корректно.
|
||||||
|
- **Фаза 0 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Ветка `feature/sim-builder` от `master`.
|
||||||
|
- `frontend/js/labs/_sim_expr.js` → `window.SimExpr` (безопасный движок выражений, без eval/Function; `compile/evaluate/evalSafe/compileValue/parse/tokenize`, whitelist + сравнения/логика/тернарник/multi-var env).
|
||||||
|
- `frontend/js/labs/_sim_engine.js` → `window.SimEngine.mount(host, spec) -> { play, pause, reset, setParam, getParam, isRunning, destroy, el }`. Canvas (мир→экран, Y вверх) + KaTeX-оверлей подписей + слайдеры/play/pause/reset. Формат спеки v1 задокументирован в шапке файла.
|
||||||
|
- `frontend/js/labs/_sim_adapter.js` → `window.registerSpecSim(spec)` / `window.SimAdapter` — строит манифест LabRegistry (ленивый хост `#sim-spec-host-<id>` в `#lab-sim`).
|
||||||
|
- `frontend/js/labs/_sim_demo.js` — демо `customdemo` (бросок тела) за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1`. Ученикам не светится.
|
||||||
|
- Подключение в `frontend/lab.html`: 3 каркасных модуля eager после `_graph_panel.js`, демо после `_register-all.js`. `_sim_deps.js` НЕ тронут.
|
||||||
|
- Верификация: `node --check` все 4 файла OK; eval/Function отсутствуют (только в комментариях); эмодзи нет; SimExpr self-test 29/30 (единственный «FAIL» `-2^2=4` — это парити с graph.js).
|
||||||
|
- Лаборатория уже декларативна на уровне регистрации: `frontend/js/labs/_registry.js`
|
||||||
|
(`LabRegistry.register/get/all/setActive/stop/destroy/resolvePreview`), манифест с
|
||||||
|
`open(ctx)/mount(host)/stop/destroy`. ~40 симуляций — рукописные JS-модули в `frontend/js/labs/`.
|
||||||
|
- Каталог в БД: миграция `042_lab_sims.sql` (`lab_sims`), роуты `backend/src/routes/lab.js`
|
||||||
|
(`GET /api/lab/sims`, PATCH/:id, POST /reorder, links). Привязка к программе: `043_lab_sim_links.sql`.
|
||||||
|
|
||||||
|
## Архитектурные решения (зафиксированы при планировании)
|
||||||
|
- **Спека = JSON-данные.** Версия `specVersion`. Корень: `{ specVersion, meta, viewport, params[], objects[], physics?, plots[], controls }`.
|
||||||
|
- **Движок выражений безопасный** — собственный парсер (расширение `y=f(x)` из graph.js):
|
||||||
|
токенайзер → AST → eval по окружению `{ params, t, объекты, whitelisted Math fns }`.
|
||||||
|
⛔ Без `eval`/`Function`. Whitelist: + - * / ^ %, sin cos tan asin acos atan sqrt abs exp ln log min max floor ceil round sign pi e, сравнения, ?:.
|
||||||
|
- **Рантайм** `window.SimEngine.mount(host, spec) -> instance{ play, pause, reset, setParam, destroy }`.
|
||||||
|
Рендер: canvas для геометрии/трасс + SVG/absolute-div оверлей для подписей (KaTeX).
|
||||||
|
Регистрируется в LabRegistry адаптером (одна функция строит манифест из спеки).
|
||||||
|
- **Объект**: `{ id, type, ...props-with-bindings }`. type ∈ point|segment|vector|circle|rect|polyline|path|label|image. Любое числовое свойство может быть числом ИЛИ строкой-выражением.
|
||||||
|
- **Физический режим (Фаза 2)**: объект с `body:{ mass, vx, vy, fixed }` интегрируется `_fx_motion`; силы `physics:{ gravity, springs[], collisions, friction, walls }`. Формульный и физический режимы сосуществуют (формульные объекты — кинематические).
|
||||||
|
- **Безопасность шаринга**: published-спека валидируется на сервере (размер, схема, глубина AST, число объектов/параметров); подписи-строки санитизируются как svg/текст.
|
||||||
|
|
||||||
|
## Temporary Workarounds
|
||||||
|
- (нет)
|
||||||
|
|
||||||
|
## Cross-Phase Dependencies
|
||||||
|
- Ф1 (графики/drag) зависит от рантайма Ф0.
|
||||||
|
- Ф2 (физика) зависит от Ф0 (модель объектов/цикл).
|
||||||
|
- Ф4 (билдер) зависит от Ф0–Ф2 (что строить) + Ф3 (куда сохранять).
|
||||||
|
- Ф5 (каталог) зависит от Ф3 (БД) + Ф0 (адаптер LabRegistry).
|
||||||
|
- Ф6 (раздача) зависит от Ф3+Ф5.
|
||||||
|
- Ф7 (доска) зависит от Ф0 (рантайм) + Ф5 (источник sim) + существующего `simOpen/simState`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- Каждая фаза должна оставлять /lab рабочим (Incremental).
|
||||||
|
- Тестировать рантайм Ф0–Ф2 рукописными спеками-фикстурами (без билдера).
|
||||||
|
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
|
||||||
|
|
||||||
|
## RESUME STATE
|
||||||
|
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
|
||||||
|
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
|
||||||
|
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
|
||||||
|
- **Ф7 файлы (аддитивно, рабочее дерево по ним было чистым до правок):**
|
||||||
|
`backend/src/controllers/classroom/sim.js` (simOpen принимает `custom:<dbid>` + access-check
|
||||||
|
own|published|admin), `frontend/classroom.html` (пикер: свои+published custom через `_crLoadCustomSims`/
|
||||||
|
`_crRenderSimGrid`; id `custom:<dbid>`), `frontend/js/labs/lab-glue.js` (`_bridgeCustomSimState` —
|
||||||
|
мост sim_state/apply_sim_state для custom-sim поверх SimEngine; вызов в `_registerLazy.open`).
|
||||||
|
js/api.js НЕ менялся. Синхрон: параметры+play/pause (не время t). Открытие — iframe `/lab?embed=1&sim=custom:<id>`.
|
||||||
|
- Эндпоинты Ф6: share/clone/related/links на `/api/custom-sims/:id/*`; клиент `LS.customSimShare/
|
||||||
|
Clone/Related/AddLink/DelLink`. Раздача = авто-publish + pushNotif (НЕ копия). Связи — lab_sim_links
|
||||||
|
`sim_id='custom:<id>'`. Остаток Ф6: UI-редактор связей в билдере + чипы в каталоге (backend готов).
|
||||||
|
- Файлы Ф5 (аддитивные правки зоны параллельной сессии — БЕЗ рефактора): `frontend/js/labs/lab-init.js`
|
||||||
|
(+7 строк: хук `LabCustom.resolveId` в `openSim`), `frontend/js/labs/lab-glue.js` (renderSims +`!m._custom`
|
||||||
|
и вызов renderSection; init зовёт `LabCustom.init()`; новый IIFE `window.LabCustom`). `_sim_deps.js`,
|
||||||
|
classroom.html, backend — НЕ тронуты. Публичное API: `window.LabCustom.{init,resolveId,renderSection,ensureSpec,del}`.
|
||||||
|
- id-неймспейс custom: deep-link/клик/`data-open` = `custom:<dbid>`; LabRegistry/host = `customsim_<dbid>`.
|
||||||
|
- Режим: Automated / Orchestrator / Incremental
|
||||||
|
- Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new),
|
||||||
|
`frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`).
|
||||||
|
lab.html/lab-glue.js НЕ тронуты. Публичное API билдера: `window.SimBuilder.create(...)`.
|
||||||
|
- **Номер миграции Ф3: 071** (`071_custom_sims.sql`); следующая свободная — 072.
|
||||||
|
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.SimPhysics` (step/integrate/resolveCollisions), `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector + блок `physics`/`body`/`springs`/`walls` — в шапке `_sim_engine.js` и в handoff phase-0/1/2.
|
||||||
|
- **API персистентности (Ф3)**: `/api/custom-sims` (GET `/`, GET/PUT/DELETE `/:id`, POST `/`) + клиент `LS.customSimsList/Get/Create/Update/Delete`. Контракт спеки на вход/санитизация — в handoff phase-3.
|
||||||
|
- Файлы Ф2 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
|
||||||
|
- Файлы Ф3: `backend/src/db/migrations/071_custom_sims.sql`, `backend/src/controllers/customSimController.js`, `backend/src/routes/customSims.js`, `backend/tests/custom-sims.test.js` (new); `backend/src/server.js`, `js/api.js` (точечные добавления). lab.html/lab-glue.js НЕ тронуты.
|
||||||
|
- Для Ф4 (билдер): слать/получать спеку через `LS.customSimCreate/Update/Get`; сервер вернёт спеку санитизированной (escaped-текст). Лимиты/коды 400 — см. handoff phase-3.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# SimForge — раунд улучшений (визуал / графика / рабочее поле)
|
||||||
|
|
||||||
|
**Branch:** `feature/sim-builder` · **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental
|
||||||
|
**Started:** 2026-06-13
|
||||||
|
|
||||||
|
Полировка конструктора симуляций по всем направлениям. Каждая фаза — реализатор + независимый ревьюер,
|
||||||
|
коммит поимённо. ⛔ Эмодзи нет (SVG .ic); ast-index/Read; общая ветка с параллельной сессией — править
|
||||||
|
свои файлы движка/билдера, чужое (materials/quota) не трогать.
|
||||||
|
|
||||||
|
## Контекст бага «съехало вправо»
|
||||||
|
`_sim_engine.js._build` рисует фикс-панель контролов `width:260px` СЛЕВА + сцену справа. У пустой/новой
|
||||||
|
симуляции панель всё равно 260px → сцена и сетка визуально смещены вправо (правые ~70% хоста). `_fit`
|
||||||
|
(DPR, центрирование по stage) корректен. Фикс — в раскладке (Фаза 1).
|
||||||
|
|
||||||
|
## Фазы
|
||||||
|
|
||||||
|
- [x] **P1 — Рабочее поле (fix смещения + основа сцены).** Контролы из фикс-260px-колонки → плавающая/
|
||||||
|
нижняя ненавязчивая панель (collapsible); canvas-сцена центрирована и во всю ширину. Сетка major/minor +
|
||||||
|
числовые подписи осей + маркер origin. Zoom (колесо к курсору) + pan (drag пустого места) + кнопки
|
||||||
|
fit/reset-view. Работает и в билдере, и в /lab, и на доске (один движок). Подтвердить DPR-резкость.
|
||||||
|
Файлы: `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался — старый CSS превью
|
||||||
|
`.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже корректно растягивает новый full-bleed root).
|
||||||
|
|
||||||
|
**Handoff (P1 → P2):**
|
||||||
|
- **Раскладка:** `_build` теперь делает `root` (`position:relative`) → внутри `stage` (`position:absolute;
|
||||||
|
inset:0`, canvas+labels на всю площадь) + плавающая `panel` (`position:absolute;left/top:10px;z-index:5;
|
||||||
|
pointer-events:auto`, сворачивается кнопкой `_togglePanel`, есть только при наличии params) + бар кнопок
|
||||||
|
вида (`right/bottom:10px`). Смещение вправо устранено: панель больше не отжимает сцену.
|
||||||
|
- **Transform-модель:** `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit) и
|
||||||
|
ЭФФЕКТИВНЫЙ `_scale/_offX/_offY`. `_zoom` — пользовательский множитель к базе, `_viewLocked` — был ли
|
||||||
|
zoom/pan (ресайз тогда сохраняет мир-центр и zoom, не сбрасывает вид). `_toPx/_toWorld` — без изменений сигнатур.
|
||||||
|
- **API вида (новое, публичное):** `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport).
|
||||||
|
Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке, инвариант мир-точки), `_setupZoomPan()`,
|
||||||
|
`_pickHandleAt(lx,ly)` (вынесен из `_setupDrag`, общий хит-тест — pan стартует только если вернул null →
|
||||||
|
приоритет ручек/тел сохранён), `_visibleWorld(W,H)`.
|
||||||
|
- **Сетка/оси:** `_niceStep(targetPx)` теперь завязан на `_scale` (адаптивен к zoom, шаги 1/2/5·10^n);
|
||||||
|
`_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`), линии на .5px
|
||||||
|
(резкость, без «ступенек»); `_drawAxes` рисует оси (прижимаются к краю если 0 вне вида) + числовые подписи
|
||||||
|
делений (светлый текст + тень для тёмного фона, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
|
||||||
|
- **destroy:** снимает wheel + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver.
|
||||||
|
- **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили
|
||||||
|
точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том
|
||||||
|
же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там.
|
||||||
|
- [x] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки
|
||||||
|
векторов, стили линий (solid/dashed/dotted), opacity, градиент-заливки, опц. тень/glow, стили точек
|
||||||
|
(filled/hollow/cross/ring), затухающие трассы; приятная дефолтная палитра. Файл: `_sim_engine.js`.
|
||||||
|
|
||||||
|
**Handoff (P2 → P3/P4): новые поля стиля спеки** (контракт для контролов билдера в P4). Все рендерятся
|
||||||
|
ТОЛЬКО на canvas (`fillStyle/strokeStyle/createLinearGradient/shadowColor`) — XSS нет, мусорный цвет
|
||||||
|
игнорится canvas. Читаются в `_prepareObjects`, применяются в `_drawObject` через хелперы `_applyStroke`
|
||||||
|
(alpha/lineWidth/join/cap/dash/glow) и `_fillStyleFor` (градиент или сплошная заливка):
|
||||||
|
- `opacity` — число `0..1` (деф. 1) → `globalAlpha` на время отрисовки объекта (восстанавливается).
|
||||||
|
- `lineStyle` — `'solid'|'dashed'|'dotted'` (деф. solid) → `setLineDash` (паттерн масштабируется от `width`).
|
||||||
|
- `width` — толщина штриха (деф. 2); для circle/rect `width:0` отключает обводку (только заливка).
|
||||||
|
- `fill`/`fillColor` — цвет заливки (circle/rect/закрытый path). `gradient:[c0,c1]` — линейный градиент
|
||||||
|
(вертикальный по bbox), приоритетнее `fill`. Полигон-заливка только при `closed:true`.
|
||||||
|
- `glow:true` ИЛИ `shadow:'#color'` ИЛИ `shadow:{blur}` — свечение (`shadowColor/shadowBlur`); деф. ВЫКЛ
|
||||||
|
(производительность). `glowColor`/`glowBlur` — точечная настройка (деф. цвет объекта / blur 12).
|
||||||
|
- `pointStyle` (point) — `'filled'|'hollow'|'cross'|'ring'` (деф. filled: заполненный кружок + тонкая
|
||||||
|
белая обводка). hollow — только обводка, ring — толстое кольцо, cross — крестик.
|
||||||
|
- `trailFade` (деф. true) — затухающая трасса (старые сегменты прозрачнее, посегментно alpha 0.08→0.68);
|
||||||
|
`trailWidth` (деф. 1.6), `trailLen` (деф. 2000, макс 5000) — толщина/длина следа. `trailColor` — как было.
|
||||||
|
- **Палитра по умолчанию**: если `color` не задан — циклически `DEFAULT_PALETTE[i % 8]` (cyan/violet/pink/
|
||||||
|
emerald/amber/blue/rose/green) вместо единого `#06D6E0`. Явный `color` всегда сохраняется.
|
||||||
|
- **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный «барбед»-треугольник (вырез у основания),
|
||||||
|
длина = `max(9, width*3.2)` px; тело линии укорочено на длину головы (не торчит сквозь остриё).
|
||||||
|
- **На P3** (графики/диаграммы): `_drawPlot` уже использует `_applyStroke` (dash/opacity/glow работают на
|
||||||
|
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
|
||||||
|
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
|
||||||
|
готовы к переиспользованию.
|
||||||
|
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
|
||||||
|
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
|
||||||
|
поля plot).
|
||||||
|
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
|
||||||
|
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
|
||||||
|
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
|
||||||
|
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
|
||||||
|
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
| Phase | Status | Review | Committed |
|
||||||
|
|-------|--------|--------|-----------|
|
||||||
|
| P1 Working field | Done | ✅ PASS | ✅ |
|
||||||
|
| P2 Object graphics | Done | ✅ PASS | ✅ |
|
||||||
|
| P3 Charts | ⬜ | ⬜ | ⬜ |
|
||||||
|
| P4 Builder UI | ⬜ | ⬜ | ⬜ |
|
||||||
|
| P5 Direct manip + history | ⬜ | ⬜ | ⬜ |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Feature: Конструктор симуляций (SimForge)
|
||||||
|
|
||||||
|
**Branch:** `feature/sim-builder`
|
||||||
|
**Base branch:** `master`
|
||||||
|
**Created:** 2026-06-13
|
||||||
|
**Status:** 🟡 In Progress
|
||||||
|
**Strategy:** Incremental
|
||||||
|
**Mode:** Automated
|
||||||
|
**Execution:** Orchestrator
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Полноценный движок авторинга интерактивных 2D-симуляций для учителя-непрограммиста.
|
||||||
|
Учитель собирает симуляцию из **данных** (JSON-спека): параметры-слайдеры, объекты
|
||||||
|
(фигуры/векторы/точки/подписи с LaTeX), привязанные **формулами** к параметрам и времени
|
||||||
|
`t`; настоящая физика (гравитация/пружины/столкновения/трение); графики; перетаскивание.
|
||||||
|
Сохраняет в БД, публикует в каталог лаборатории, раздаёт классу, открывает на доске
|
||||||
|
онлайн-урока, клонирует чужие и стартует из шаблонов.
|
||||||
|
|
||||||
|
Спека — это **данные, не код**. Движок выражений — безопасный (whitelisted-математика),
|
||||||
|
⛔ без `eval`/`Function`/доступа к DOM/глобалам: спека шарится между людьми.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
- **Build:** нет (vanilla JS, без бандлера)
|
||||||
|
- **Test:** `npm test` (в `backend/`, `node --test tests/*.test.js`)
|
||||||
|
- **Lint:** `npm run lint:routes` (в `backend/`)
|
||||||
|
- ⚠️ После роутов/миграций: `npm run migrate` (живая БД) + рестарт сервера (авто-перезагрузки нет).
|
||||||
|
- ⚠️ `npm test` имеет baseline 3 pre-existing fail (auth.test.js) — хук толерантен (BASELINE_FAILS=3).
|
||||||
|
|
||||||
|
## Project Constraints (соблюдают ВСЕ агенты)
|
||||||
|
- ⛔ Никаких эмодзи в коде — только inline SVG `.ic`.
|
||||||
|
- Поиск по коду: `ast-index` (символы/usages/callers) + `vex` (semantic). НЕ Grep tool.
|
||||||
|
- БД — встроенный `node:sqlite` (`DatabaseSync`), НЕ better-sqlite3. Живая БД `backend/data/learnspace.db`.
|
||||||
|
- Frontend — vanilla JS, `window.LS.*` (js/api.js), без бандлера. Статика через Express.
|
||||||
|
- Стейджить файлы поимённо (НЕ `git add -A` — в репо много мусорных untracked).
|
||||||
|
- Движок выражений — безопасный парсер, не `eval`/`new Function`.
|
||||||
|
- Переиспользовать: LabRegistry, `_fx_motion`, `_graph_panel`, `_phys_visuals`, `_util`,
|
||||||
|
парсер `y=f(x)` из graph.js; паттерн раздачи из «Мои материалы»; `lab_sim_links`;
|
||||||
|
конвейер встраивания sim на доску (`simOpen/simState/simMode/simAnnotate`).
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- [x] Phase 0: Спека v1 + рантайм (формульные сцены) [domain: frontend] → [subplan](./phase-0-runtime-core.md)
|
||||||
|
- [x] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
|
||||||
|
- [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
|
||||||
|
- [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
|
||||||
|
- [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
|
||||||
|
- [x] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
|
||||||
|
- [x] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
|
||||||
|
- [x] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
|
||||||
|
|
||||||
|
## Phase Progress Log
|
||||||
|
|
||||||
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
|
|-------|--------|--------|--------|-------|-----------|
|
||||||
|
| Phase 0: Runtime core | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 1: Plots & interactions | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 5: Catalog | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 6: Sharing | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
| Phase 7: Classroom | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
## Final Review
|
||||||
|
- [x] Comprehensive code review (final-reviewer) — READY TO MERGE, 0 critical
|
||||||
|
- [x] Security review — движок выражений безопасен (нет eval/доступа к globals), ownership/IDOR ок, SQL параметризован; ФИКС: вайтлист цветов в validateSpec (CSS-инъекция `color/bg` через style.cssText закрыта)
|
||||||
|
- [x] Full test suite passes (within baseline) — custom-sims 39/39; общий 241/249 (8 = baseline: 3 auth + 5 jsdom page)
|
||||||
|
- [ ] Merged to `master` — ОЖИДАЕТ одобрения пользователя
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Phase 0: Спека v1 + рантайм (формульные сцены)
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Заложить ядро: формат JSON-спеки v1, безопасный движок выражений, рантайм `SimEngine`,
|
||||||
|
адаптер регистрации в `LabRegistry`. После фазы рукописная спека «брошенное тело» играет в /lab.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Задокументировать формат спеки v1 в шапке нового файла + в CONTEXT.md (params, objects, viewport, controls). → шапка `_sim_engine.js` (полный JSON-формат) + CONTEXT.md.
|
||||||
|
- [x] `frontend/js/labs/_sim_expr.js` — безопасный движок выражений: токенайзер → AST → `evaluate(ast, env)`. Whitelist математики (см. CONTEXT.md). Парсер расширяет логику `y=f(x)` из `graph.js` (тот же подход к токенам/неявному умножению; добавлены сравнения, логика, тернарник, multi-var env, min/max/mod/log(b,x)). ⛔ без `eval`/`Function`. `compile(src) -> {ast, fn(env), error}`.
|
||||||
|
- [x] `frontend/js/labs/_sim_engine.js` — `window.SimEngine.mount(host, spec)`:
|
||||||
|
- canvas с мир→экран (равные оси, вписан в viewport, Y вверх) + оверлей-слой `<div>` для подписей (KaTeX `renderToString`, как в graph.js);
|
||||||
|
- объекты: point|segment|vector|circle|rect|polyline|path|label (числовые свойства = число или строка-выражение, компилируются один раз в mount);
|
||||||
|
- rAF-цикл: `t += dt*speed`, loop/duration, перевычисление привязок, перерисовка, трассы (`trail`);
|
||||||
|
- контролы: слайдеры из `params[]` + play/pause/reset; API `{ play, pause, reset, setParam, getParam, isRunning, destroy, el }`.
|
||||||
|
- [x] `frontend/js/labs/_sim_adapter.js` — `registerSpecSim(spec)` строит манифест LabRegistry (`open(ctx)` → ленивый собственный хост-div + `SimEngine.mount`; `stop` прячет хост+pause; `destroy` уничтожает инстанс; `preview` из спеки или авто-SVG) и регистрирует.
|
||||||
|
- [x] Фикстура-демо: рукописная спека «projectile» (слайдеры θ 0..90 / v 0..30, точка x=v·cosθ·t, y=max(0, v·sinθ·t−5t²), вектор v0, земля, подпись) — зарегистрирована как `customdemo` за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1`. Ученикам не светится (карточки в SIMS нет; добавляется только при включённом флаге).
|
||||||
|
- [x] Подключить новые файлы в /lab прямыми `<script>` (минимально-инвазивно): `_sim_expr/_sim_engine/_sim_adapter` — eager после `_graph_panel.js`; `_sim_demo` — после `_register-all.js`. Lazy-схема `_sim_deps` не тронута (каркасные модули должны быть до диспетчера). Старт /lab и ~40 симуляций не затронуты.
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/js/labs/_sim_expr.js` — движок выражений (new)
|
||||||
|
- `frontend/js/labs/_sim_engine.js` — рантайм (new)
|
||||||
|
- `frontend/js/labs/_sim_adapter.js` — адаптер LabRegistry (new)
|
||||||
|
- `frontend/js/labs/_sim_demo.js` — демо-спека-фикстура (new, временная)
|
||||||
|
- `frontend/lab.html` или `_sim_deps.js` — подключение файлов (минимальная правка)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- В /lab открывается демо-симуляция, слайдеры меняют движение, play/pause/reset работают.
|
||||||
|
- Движок выражений не использует eval/Function; некорректная формула не роняет рантайм (показывает ошибку/0).
|
||||||
|
- Существующие ~40 симуляций и старт /lab не сломаны.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Подписи с LaTeX — переиспользовать существующий рендер формул (KaTeX), не тянуть новый.
|
||||||
|
- Мир-координаты с осью Y вверх (математические), трансформация в экранные внутри движка.
|
||||||
|
- Производительность: компилировать выражения один раз при mount, в цикле только evaluate.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Нет eval/new Function в движке выражений (grep -nE "eval\(|new Function|Function\(" → только упоминания в комментариях, ни одного call-site)
|
||||||
|
- [x] Нет эмодзи (скан: только ASCII + кириллица + θ/∞/box-drawing — те же баннеры, что в graph.js)
|
||||||
|
- [x] Старт /lab и существующие симуляции не регрессировали (не тронуты SIMS/OPEN/манифесты; добавлены только новые `<script>` + register())
|
||||||
|
- [x] Код в стиле проекта (vanilla, IIFE с экспортом в window.*, как _registry/_simbase/_graph_panel)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что готово (Phase 0)
|
||||||
|
- **Движок выражений** `frontend/js/labs/_sim_expr.js` → `window.SimExpr`:
|
||||||
|
- `compile(src) -> { ast, fn, error }`. `fn(env) -> number`, НИКОГДА не бросает (NaN/∞/деление на 0 → 0). `error` — строка при синтаксической ошибке (не бросается).
|
||||||
|
- `evaluate(ast, env) -> number` (то же, безопасно), `evalSafe(ast, env) -> { value, error }` (для билдера/отладки — отличает NaN от валидного 0).
|
||||||
|
- `compileValue(value) -> { fn, error, constant, ast }` — число вернёт константу, строку скомпилирует (используется движком для свойств-привязок).
|
||||||
|
- `parse(src) -> ast` (бросает при ошибке), `tokenize(src)`, `FUNCTIONS` / `CONSTANTS` (объекты-«множества» имён, для подсветки в билдере).
|
||||||
|
- Whitelist: `+ - * / ^ %`, унарный `-`/`+`/`!`, скобки, сравнения `< <= > >= == !=`, логика `&& ||`, тернарник `?:`; функции `sin cos tan tg ctg cot asin acos atan arcsin arccos arctan arctg sqrt abs exp ln log(x|base,x) log2 log10 floor ceil round sign min max mod atan2 pow hypot`; константы `pi e tau`. Идентификаторы (вкл. точечные `obj.x`) берутся ТОЛЬКО из `env`.
|
||||||
|
- ⚠️ Степень право-ассоциативна, базой служит unary → `-2^2 == 4` (паритет с graph.js, задокументировано). Для `-(2^2)` писать скобки.
|
||||||
|
- Самопроверка: 29/30 логических кейсов PASS (единственный «FAIL» — `-2^2`, это и есть парити-поведение, не баг).
|
||||||
|
- **Рантайм** `frontend/js/labs/_sim_engine.js` → `window.SimEngine`:
|
||||||
|
- `mount(host, spec) -> instance`. `instance`: `play() pause() reset() setParam(name,val) getParam(name) isRunning() destroy()` + поле `el` (корневой DOM для скрытия/показа).
|
||||||
|
- Сцена: панель слайдеров+play/pause/reset слева, `<canvas>` + оверлей `<div>` подписей справа. Мир→экран: равные оси, центрирование, Y вверх. Выражения компилируются 1 раз в mount; в rAF — только evaluate. `env` = `{ t, <params>, w, h, xmin/xmax/ymin/ymax, <objId>.x, <objId>.y }`.
|
||||||
|
- Объекты: `point segment vector circle rect polyline path label`. Подписи — KaTeX (фолбэк на текст). Трассы (`trail:true`).
|
||||||
|
- **Адаптер** `frontend/js/labs/_sim_adapter.js` → `window.registerSpecSim(spec)` / `window.SimAdapter`:
|
||||||
|
- Строит манифест LabRegistry из спеки, кладёт `_spec`/`_instance`/`instance()` для будущих фаз. Каждая спек-симуляция получает ленивый хост `#sim-spec-host-<id>` внутри `#lab-sim`. `stop` прячет хост (важно при switch — openSim не знает наших хостов), `destroy` уничтожает инстанс.
|
||||||
|
- **Демо** `frontend/js/labs/_sim_demo.js` — `customdemo` за флагом, для приёмки.
|
||||||
|
- **Подключение** в `frontend/lab.html`: 3 каркасных `<script>` после `_graph_panel.js`, демо после `_register-all.js`.
|
||||||
|
|
||||||
|
### Формат спеки v1 (поддержан фактически)
|
||||||
|
`{ specVersion?, id, cat?, meta:{title,desc}, viewport:{xmin,xmax,ymin,ymax,grid?,axes?,bg?}, time:{autoplay?,loop?,duration?,speed?}, params:[{name,label,min,max,step,value,unit?}], objects:[{id?,type,...числа|строки-выражения, color?,fill?,width?,trail?,trailColor?,latex?,size?,text?,points?,closed?}], theory?, subject?,grade?,topics? }`
|
||||||
|
Полная спецификация полей по типам объектов — в шапке `_sim_engine.js`.
|
||||||
|
|
||||||
|
### Осталось на Фазу 1 / временные решения / риски
|
||||||
|
- **Drag-интеракции, графики, оси-подписи с числами** — Фаза 1 (сейчас оси без числовых меток, есть сетка). `GraphPanelUI` из `_graph_panel.js` готов к переиспользованию для plots.
|
||||||
|
- **Физика** (`body`/`physics`) — Фаза 2, объекты сейчас чисто кинематические (формульные).
|
||||||
|
- **Однопроходный env для `obj.x/obj.y`**: центры объектов вычисляются в порядке объявления за один проход — взаимные ссылки «вперёд» (объект ссылается на тот, что объявлен ниже) дадут значение предыдущего кадра (или 0 на первом). Достаточно для типовых сцен; при необходимости в Ф1 сделать топосортировку/итерации.
|
||||||
|
- **`r` точки** трактуется как экранный радиус в пикселях (не мир-единицы) — намеренно, чтобы точки не «таяли» при масштабе; для circle радиус мировой.
|
||||||
|
- **Демо `customdemo`** — временный раздел, удалить/спрятать после билдера (Фаза 4). Карточка добавляется в каталог только при включённом флаге.
|
||||||
|
- **Санитизация подписей/валидация спеки на сервере** — Фаза 3 (сейчас спеки только рукописные/локальные, в БД не идут).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Phase 1: Графики + интеракции
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Добавить в рантайм графики (plot-объекты), перетаскиваемые ручки (drag → параметр),
|
||||||
|
векторы и числовые readout. После фазы спека со слайдером, draggable-точкой и live-графиком работает.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Plot-объект в спеке: `{ type:'plot', expr:'...', var:'x', range:[a,b], samples, trace? }` —
|
||||||
|
рисует график выражения; `trace:true` — накапливает след по `t`. Решение: рисуем на canvas движка в мир-координатах (НЕ тянем `GraphPanelUI` — он stacked time-series в фикс. оверлее, не `y=f(x)` инлайн). `samples` деф. 200, клампится 2..2000.
|
||||||
|
- [x] Draggable-ручка: `point`/`circle` с `drag:{ param, axis:'x|y|xy', min,max, paramY? }` — перетаскивание мышью/тачем (pointer events) меняет параметр(ы); позиция ручки следует за параметром (x/y объекта = тот же параметр). Хит-тест в экранных px (допуск 16px), приоритет ручек. Clamp по `drag.min/max` И по диапазону самого параметра.
|
||||||
|
- [x] Readout: `{ type:'readout', label, expr, unit, precision, x?, y? }` — живой бейдж; мягкая ошибка через `evalSafe` (NaN/синтаксис → «—», не роняет цикл). Без позиции — авто-столбик в верх-правом углу.
|
||||||
|
- [x] Vector-объект с привязкой к `origin:[ox,oy]` + `dx`/`dy`-выражениям + стрелка (x1/y1/x2/y2 тоже поддержаны). Стрелка уже была в Ф0.
|
||||||
|
- [x] Тач-поддержка drag (pointer events + `touchAction:none`), не ломая логику лабы (слушатели только на canvas движка, снимаются в destroy).
|
||||||
|
- [x] Обновить демо-спеку: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории y(x), 2 readout (дальность R, высота H).
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/js/labs/_sim_engine.js` — типы plot/readout/vector, drag-интеракции (modify)
|
||||||
|
- `frontend/js/labs/_sim_demo.js` — расширить демо (modify)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Перетаскивание ручки меняет параметр; зависимые объекты/график обновляются.
|
||||||
|
- График строится по выражению; trace накапливает след во времени.
|
||||||
|
- Readout показывает живое значение. Тач работает.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Drag не должен конфликтовать с pan/zoom рантайма (если есть). Приоритет хит-теста — ручки.
|
||||||
|
- Сэмплинг графика разумный (без фриза на больших range).
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Drag работает мышью и тачем (pointer events; headless-тест clamp/sync OK)
|
||||||
|
- [x] Нет регрессий рантайма Ф0 (рендер всех 8 типов демо × 6 кадров без ошибок; point/segment/circle/rect/polyline/path/vector/label не тронуты в логике)
|
||||||
|
- [x] Нет эмодзи, стиль проекта (скан кодпойнтов — чисто; IIFE-стиль)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что готово (Phase 1) — только `_sim_engine.js` + `_sim_demo.js`
|
||||||
|
- **plot** — график выражения `f(var)` на отрезке, рисуется на canvas движка в мир-координатах.
|
||||||
|
- **drag** — `point`/`circle` становятся ручками; pointer events (мышь+тач); clamp двойной (drag.min/max + диапазон параметра).
|
||||||
|
- **readout** — живой бейдж на оверлее (тот же `_labelLayer`), мягкая ошибка через `SimExpr.evalSafe`.
|
||||||
|
- **vector** — добавлена форма `origin:[ox,oy]+dx/dy` (конец = origin + (dx,dy)); старая `x1/y1/x2/y2` сохранена; стрелка из Ф0.
|
||||||
|
|
||||||
|
### Формат новых типов спеки (точные поля)
|
||||||
|
```jsonc
|
||||||
|
// график выражения (мир-координаты)
|
||||||
|
{ type:'plot', expr:'sin(x)', var:'x', // var деф. 'x'
|
||||||
|
range:[a,b], // числа/выражения; деф. xmin..xmax
|
||||||
|
samples?:200, // клампится 2..2000
|
||||||
|
trace?:false, // true: точка (var=t) пишется в trail по времени;
|
||||||
|
// при trace без range статич. кривая НЕ рисуется
|
||||||
|
color?, width? }
|
||||||
|
|
||||||
|
// перетаскиваемая ручка (на point/circle)
|
||||||
|
{ type:'point', x:'x0', y:'y0',
|
||||||
|
drag:{ param:'x0', // axis x|y -> этот параметр; xy -> X
|
||||||
|
axis:'x'|'y'|'xy', // деф. 'x'
|
||||||
|
paramY:'y0', // ТОЛЬКО axis:'xy' -> Y (обязателен для 2D)
|
||||||
|
min?, max? } } // деф. ±Infinity; доп. clamp по диапазону параметра
|
||||||
|
|
||||||
|
// живой числовой бейдж
|
||||||
|
{ type:'readout', expr:'...', label?:'R', unit?:'м', precision?:2, // precision 0..8, деф.2
|
||||||
|
x?, y?, // мир-коорд.; без них — авто-столбик верх-право
|
||||||
|
color? }
|
||||||
|
|
||||||
|
// вектор (новая форма)
|
||||||
|
{ type:'vector', origin:[ox,oy], dx:'...', dy:'...', color?, width? }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API инстанса (без изменений сигнатуры)
|
||||||
|
`mount(host,spec) -> { play, pause, reset, setParam, getParam, isRunning, destroy, el }`.
|
||||||
|
Добавлены внутр.: `_toWorld(px,py)`, `_setupDrag`, `_applyDrag`, `_setParamClamped`, `_drawPlot`, `_drawReadout`, `_accumPlotTrace`, `_paramRange`.
|
||||||
|
|
||||||
|
### Осталось / риски / на Фазу 2 (физика)
|
||||||
|
- **Однопроходный env** (`obj.x/obj.y`): из Ф0 — взаимные «вперёд»-ссылки дают значение прошлого кадра. Не трогал; при физике может потребоваться топосорт.
|
||||||
|
- **Drag только point/circle.** Тащить за конец вектора/вершину polyline — не реализовано (не требовалось).
|
||||||
|
- **readout позиционирование** на canvas — через DOM-оверлей (`_labelLayer`), как label. На сервере (Ф3) `label`/`unit` readout надо санитизировать как текст.
|
||||||
|
- **plot и trace на больших range**: ограничены `samples<=2000` и trail `<=2000` точек — без фриза. Очень большие range с тонкой кривой при экстремальном zoom могут ступенчатить — норм для учебных сцен.
|
||||||
|
- **Физика (`body`/`physics`)** — Фаза 2. Plot/drag/readout/vector полностью совместимы с физ-объектами (drag может задавать начальные условия, readout — читать body-величины, если их положить в env в Ф2).
|
||||||
|
- ⛔ `lab.html` и `lab-glue.js` НЕ трогались (зона параллельной сессии). Новых файлов не создавал — всё в `_sim_engine.js`/`_sim_demo.js`.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Phase 2: Физический интегратор
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Добавить настоящую физику: тела с массой, гравитация/пружины/столкновения/трение,
|
||||||
|
перетаскивание тел силой, траектории. Динамика считается движком, а не формулой.
|
||||||
|
После фазы маятник/столкновения/брошенное тело идут динамически из спеки.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Блок `physics` в спеке: `{ enabled, gravity:{x,y}, friction, walls:[...], restitution, dt?, springs? }`. gravity/friction/restitution — числа ИЛИ выражения от params (вычисляются на reset).
|
||||||
|
- [x] Тело-объект: `body:{ mass, vx, vy, fixed }` на point/circle — интегрируется фикс-шагом (накопитель dt). Нач. позиция/vx/vy/масса — числа или выражения от params (вычисляются при reset/init, далее интегрируются). Опора на математику `_fx_motion` (полу-неявный Эйлер); см. ниже.
|
||||||
|
- [x] Пружины: `springs:[{ a, b, k, length, damping? }]` — концы: id тела ИЛИ якорь-точка `[x,y]`. Сила Гука + демпфирование вдоль оси. Рисуются зигзагом.
|
||||||
|
- [x] Столкновения: упругие круг-круг (по нормали, импульс + позиционная коррекция по обратным массам) и круг-стена (restitution). Broadphase O(n^2) (N мало).
|
||||||
|
- [x] Drag тела: тащишь — задаёт позицию (тело «приколото», не интегрируется); отпустил — сообщает скорость (бросок, кламп 40 м/с). Формульные объекты Ф0/Ф1 сосуществуют (drag-ручки и физ-тела в одной сцене).
|
||||||
|
- [x] Траектория: тело с `trail:true` пишет след центра (переиспользован существующий механизм trail; позиция берётся из env-полей тела).
|
||||||
|
- [x] Демо-спеки за флагом: «пружинный маятник» (`customphys`: тело+пружина+гравитация+drag) и «упругие шары» (`customballs`: 3 тела + 4 стены, g/упругость от слайдеров).
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/js/labs/_sim_engine.js` — физический режим, интеграция с _fx_motion (modify)
|
||||||
|
- `frontend/js/labs/_sim_physics.js` — обёртка интегратора/коллизий, если чище отдельно (new, опц.)
|
||||||
|
- `frontend/js/labs/_sim_demo.js` — физ-демо (modify)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Тело под гравитацией падает/летит по параболе через интегратор (не по формуле).
|
||||||
|
- Пружина колеблет груз; шары упруго сталкиваются; стены отражают.
|
||||||
|
- Drag тела работает; формульные объекты Ф0 продолжают работать в той же сцене.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Шаг интегратора фиксированный (накопитель dt) для стабильности.
|
||||||
|
- Не переусложнять коллизии — школьный уровень (круги/стены).
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Опирается на математику `_fx_motion.spring` (полу-неявный/симплектический Эйлер `v+=a·dt; x+=v·dt`) — обобщена на связанные тела в `SimPhysics`; API tween/spring-фабрики `_fx_motion` (rAF-замыкания на одно значение) не подходит для N связанных тел, поэтому тонкий модуль поверх той же математики, без дубля иного интегратора.
|
||||||
|
- [x] Стабильность (нет взрыва энергии): фикс-шаг dt (кламп 1/2000..1/30), накопитель + кап подшагов (8), кламп скорости (1e4), вязкое трение через `exp(-friction·dt)`. Headless-прогон: падение/маятник/шары — конечные, без NaN/∞.
|
||||||
|
- [x] Нет регрессий Ф0/Ф1: формульные point/segment/circle/rect/polyline/path/vector/label/plot/readout/drag работают; тела и формульные объекты в одной сцене (тест mixed).
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что реализовано (Phase 2) — только `_sim_engine.js` + `_sim_demo.js`
|
||||||
|
Файл `_sim_physics.js` НЕ создавался: его нельзя подключить без правки `frontend/lab.html`
|
||||||
|
(зона параллельной сессии). Интегратор живёт внутри `_sim_engine.js` и экспортируется
|
||||||
|
как `window.SimPhysics` (переиспользуемо headless/билдером/доской). Если Ф4+ захочет
|
||||||
|
вынести в отдельный файл — добавить `<script>` в lab.html и `module.exports`-ветку.
|
||||||
|
|
||||||
|
- **`window.SimPhysics`** — `{ step(state, dtFrame), integrate(...), resolveCollisions(...) }`.
|
||||||
|
Полу-неявный Эйлер (математика `_fx_motion.spring`), фикс-шаг, пружины (Гук+демпф),
|
||||||
|
упругие столкновения круг-круг и круг-стена. Без DOM/eval — чистая функция над state.
|
||||||
|
- **Engine**: `_preparePhysics()` (сборка тел/пружин/стен из спеки на reset),
|
||||||
|
`_stepPhysics(dt)` (вызывается в rAF до `_renderFrame`; удерживаемое тело временно
|
||||||
|
fixed), drag тел (`_dragBody`), `_drawSprings()`, рендер point/circle-тел из состояния
|
||||||
|
интегратора, body-поля в `_buildEnv`.
|
||||||
|
|
||||||
|
### Формат (точные поля)
|
||||||
|
```jsonc
|
||||||
|
// глобальный блок физики
|
||||||
|
physics: {
|
||||||
|
enabled: true, // false/отсутствие -> чистая кинематика (Ф0/Ф1)
|
||||||
|
gravity: { x:0, y:-9.8 }, // число ИЛИ выражение от params (вычисляется на reset)
|
||||||
|
friction?: 0, // вязкое затухание (1/с), exp(-friction·dt); число/выражение
|
||||||
|
restitution?: 1, // упругость 0..1 (кламп); число/выражение
|
||||||
|
dt?: 1/240, // фикс-шаг (кламп 1/2000..1/30)
|
||||||
|
walls?: [
|
||||||
|
{ side:'bottom'|'top'|'left'|'right' }, // из границ viewport
|
||||||
|
{ x1,y1, x2,y2 } // произвольный отрезок (нормаль к центру)
|
||||||
|
],
|
||||||
|
springs?: [
|
||||||
|
{ a:'bodyId'|[x,y], b:'bodyId'|[x,y], // конец: id тела ИЛИ якорь-точка
|
||||||
|
k:40, length:5, damping?:0.4 } // k/length/damping — число/выражение
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// тело на point/circle (интегрируется, НЕ формула)
|
||||||
|
{ id:'bob', type:'circle', r:0.6, x:'3', y:'-4', // нач. позиция — число/выражение
|
||||||
|
trail?:true, trailColor?:'#...', // след тела (механизм Ф0)
|
||||||
|
body:{ mass?:1, vx?:0, vy?:0, fixed?:false } } // масса/vx/vy — число/выражение
|
||||||
|
```
|
||||||
|
|
||||||
|
### env-поля тел (доступны readout/plot/label/привязкам)
|
||||||
|
Для каждого тела в env кладутся `<id>.x`, `<id>.y`, `<id>.vx`, `<id>.vy` ИЗ СОСТОЯНИЯ
|
||||||
|
ИНТЕГРАТОРА (не из выражения). Кладутся ПЕРВЫМИ в `_buildEnv` — снимает forward-ref
|
||||||
|
проблему однопроходного env: формульные объекты, ссылающиеся на тело, видят его
|
||||||
|
актуальную позицию/скорость в этом же кадре.
|
||||||
|
|
||||||
|
### Гочи / решения / риски
|
||||||
|
- **Имя param `e` зарезервировано** (число Эйлера в SimExpr). Не использовать `e` как
|
||||||
|
имя параметра в выражениях физики — взять `el`/`elast` и т.п. (демо «шары» учтено).
|
||||||
|
- **Радиус тела для коллизий**: circle — мировой `r`; point — экранные px → переводятся
|
||||||
|
в мир через текущий масштаб (фолбэк 0.3) при `_preparePhysics`. Зависит от `_scale`,
|
||||||
|
поэтому физика собирается ПОСЛЕ `_fit()` (в reset, который зовётся после первого fit).
|
||||||
|
- **Изменение params на лету**: gravity/k/length/restitution/масса/нач.условия пересчит. на
|
||||||
|
**reset** (и на паузе в `t==0` — для предпросмотра старта). Во время проигрывания слайдеры
|
||||||
|
меняют только сами params в env (для readout/формул), но не телепортируют тела/не меняют силы
|
||||||
|
до следующего reset. Это намеренно (стабильность). Если Ф4 захочет live-силы — пересобирать
|
||||||
|
`opts`/springs каждый кадр (тела не трогать).
|
||||||
|
- **Drag во время play**: удерживаемое тело временно `fixed`; на отпускании — скорость из
|
||||||
|
сглаженной оценки движения курсора (кламп 40 м/с).
|
||||||
|
- **Сериализация для Ф3 (БД/API)**: в спеке надо хранить/валидировать блок `physics`
|
||||||
|
(gravity x/y, friction, restitution, dt, walls[], springs[]) и `body{}` на объектах
|
||||||
|
(mass, vx, vy, fixed — числа или строки-выражения). Глубину/число springs/walls и
|
||||||
|
диапазоны (k, mass>0, restitution 0..1, dt) — проверять на сервере как и остальные
|
||||||
|
выражения. Строки-выражения санитизировать так же, как x/y/expr Ф0/Ф1.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Phase 3: БД + API (custom_sims)
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Сохранение custom-симуляций: таблица БД, CRUD API под авторизацией с проверкой владения,
|
||||||
|
серверная валидация спеки. После фазы спека сохраняется/грузится/удаляется через API.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Миграция `backend/src/db/migrations/071_custom_sims.sql`: таблица `custom_sims`
|
||||||
|
(id PK AUTOINCREMENT, owner_id FK users ON DELETE CASCADE, title, description, subject,
|
||||||
|
grade, cat, spec_json TEXT NOT NULL, status TEXT 'draft|published' DEFAULT 'draft',
|
||||||
|
version INT DEFAULT 1, created_at, updated_at) + индексы idx_custom_sims_owner / _status.
|
||||||
|
Идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS).
|
||||||
|
- [x] Контроллер `backend/src/controllers/customSimController.js`: list (own + чужой published),
|
||||||
|
get (own ИЛИ published), create (через роут teacher/admin), update, remove. Владение
|
||||||
|
проверяется на каждой мутации (owner ИЛИ admin → иначе 403; нет строки → 404). version++ на update.
|
||||||
|
- [x] Серверная валидация спеки `validateSpec(spec)`: размер JSON (≤200KB), `specVersion`=1,
|
||||||
|
лимиты (params≤50, objects≤200, walls≤20, springs≤50, expr≤500 симв., глубина≤8, points≤1000),
|
||||||
|
типы объектов из whitelist (point|segment|vector|circle|rect|polyline|path|label|plot|readout),
|
||||||
|
physics (restitution 0..1, dt 1/2000..1/30, body.mass>0), строки-подписи (text/label/unit/meta/
|
||||||
|
drag.param/param.label) санитизируются как текст (escape &<>, обрезка). Возврат {ok,error?,clean?}.
|
||||||
|
⛔ Спека НЕ исполняется на сервере (нет eval/Function/SimExpr).
|
||||||
|
- [x] Роуты `backend/src/routes/customSims.js`: router-level `authMiddleware` (read auth-only);
|
||||||
|
`GET /` + `GET /:id` (видимость в хендлере); `POST /`, `PUT /:id`, `DELETE /:id` — inline
|
||||||
|
`requireRole('teacher','admin')`. Смонтировано в server.js: `app.use('/api/custom-sims', ...)`.
|
||||||
|
- [x] Клиент `js/api.js`: `customSimsList/Get/Create/Update/Delete` → `req(...)` + добавлены в `window.LS`.
|
||||||
|
- [x] Тесты `backend/tests/custom-sims.test.js` (24/24 pass): CRUD happy-path, ownership (чужой
|
||||||
|
PUT/DELETE → 403), get чужой draft → 403, get чужой published → 200, admin override, 404,
|
||||||
|
validateSpec (12 кейсов отказа 400), санитизация. Монтирует роут на shared test-app (как lab-links).
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `backend/src/db/migrations/0NN_custom_sims.sql` (new)
|
||||||
|
- `backend/src/controllers/customSimController.js` (new)
|
||||||
|
- `backend/src/routes/customSims.js` (new)
|
||||||
|
- `backend/src/server.js` — монтирование роутера (modify)
|
||||||
|
- `js/api.js` — клиентские методы (modify)
|
||||||
|
- `backend/tests/custom-sims.test.js` (new)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- POST сохраняет спеку, GET возвращает свои+published, PUT/DELETE только владельцу/админу (403 иначе).
|
||||||
|
- Кривая/огромная спека → 400. Тесты зелёные (в пределах baseline).
|
||||||
|
- `npm run migrate` применяет таблицу; роут не отдаёт SPA-fallback после рестарта.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- node:sqlite (DatabaseSync), НЕ better-sqlite3.
|
||||||
|
- Спека хранится как TEXT(JSON); парс/валидация — на входе.
|
||||||
|
- Не делать blanket `router.use(requireRole('admin'))` — read-роуты auth-only, мутации — inline requireRole.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Ownership и валидация спеки покрыты тестами (24/24)
|
||||||
|
- [x] Миграция идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS)
|
||||||
|
- [x] `npm run lint:routes` чисто (0 unprotected, baseline 0); тесты в пределах baseline
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что реализовано (Phase 3)
|
||||||
|
- **Миграция 071** `custom_sims` (применена к живой БД `npm run migrate` без ошибок).
|
||||||
|
- **API `/api/custom-sims`** (смонтировано в `backend/src/server.js` после `/api/materials`):
|
||||||
|
| Метод | Путь | Доступ | Ответ |
|
||||||
|
|-------|------|--------|-------|
|
||||||
|
| GET | `/api/custom-sims` | auth | `{ sims:[{id,owner_id,title,description,subject,grade,cat,status,version,created_at,updated_at}] }` (свои любого статуса + чужие published; БЕЗ spec) |
|
||||||
|
| GET | `/api/custom-sims/:id` | auth (own ИЛИ published) | `{ sim:{...meta, spec} }` (spec уже распарсен из JSON); чужой draft → 403, нет → 404 |
|
||||||
|
| POST | `/api/custom-sims` | teacher/admin | `201 { id }`; принимает `{ title?, description?, subject?, grade?, cat?, status?, spec }`; кривая спека → 400 |
|
||||||
|
| PUT | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; любое поле опц.; `spec` валидируется заново и version++; 403/404 |
|
||||||
|
| DELETE | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; 403/404 |
|
||||||
|
- **Клиентские методы** (`js/api.js` → `window.LS`): `customSimsList()`, `customSimGet(id)`,
|
||||||
|
`customSimCreate(data)`, `customSimUpdate(id, data)`, `customSimDelete(id)`.
|
||||||
|
|
||||||
|
### Форма записи `custom_sims`
|
||||||
|
`id`, `owner_id`(FK users CASCADE), `title`, `description`, `subject`, `grade`(INT), `cat`
|
||||||
|
(math|phys|chem|bio|game|NULL), `spec_json`(TEXT — валидированный JSON), `status`(draft|published,
|
||||||
|
деф. draft), `version`(INT, ++ на каждом update со spec), `created_at`, `updated_at`.
|
||||||
|
|
||||||
|
### validateSpec — контракт для Ф4 (билдер)
|
||||||
|
Билдер должен слать в `spec` объект формата v1 (specVersion:1, meta, viewport, params[], objects[],
|
||||||
|
physics?). Сервер вернёт **400** при: spec не объект; specVersion≠1; >200KB JSON; глубина >8;
|
||||||
|
params>50; objects>200; walls>20; springs>50; строка-выражение >500 симв.; недопустимый type
|
||||||
|
объекта (вне whitelist point|segment|vector|circle|rect|polyline|path|label|plot|readout);
|
||||||
|
restitution вне 0..1; dt вне 1/2000..1/30; body.mass≤0. Текстовые поля (`text`,`label`,`unit`,
|
||||||
|
`meta.title/desc`, `param.label/unit/name`, `drag.param/paramY`, `id`) **обрезаются и
|
||||||
|
экранируются** (`& < >` → entities) — билдер получит обратно очищенную спеку в `GET /:id`.
|
||||||
|
⚠️ Не использовать имя param `e` (зарезервировано в SimExpr — см. Ф2).
|
||||||
|
|
||||||
|
### На Ф4 (билдер: какие поля шлёт/получает)
|
||||||
|
- **Создание**: `LS.customSimCreate({ title, description?, subject?, grade?, cat?, status?, spec })`
|
||||||
|
→ `{ id }`. После — `LS.customSimUpdate(id, { spec, status:'published' })` для публикации.
|
||||||
|
- **Загрузка для редактирования**: `LS.customSimGet(id)` → `{ sim }`, где `sim.spec` — готовый
|
||||||
|
объект для монтирования в `SimEngine.mount(host, sim.spec)`.
|
||||||
|
- **Список «мои/опубликованные»**: `LS.customSimsList()` → `{ sims }` (метаданные без spec; spec
|
||||||
|
тянуть лениво по `customSimGet`).
|
||||||
|
- Билдер должен учитывать, что сервер вернёт спеку **санитизированной** (escaped-текст) — для
|
||||||
|
KaTeX/canvas это безопасно, но при сравнении «до/после» учитывать экранирование.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Phase 4: Билдер (редактор)
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Учительский редактор: страница с живым превью и панелями для сборки спеки без кода.
|
||||||
|
После фазы учитель собирает рабочую симуляцию с нуля в UI и сохраняет в БД (Ф3).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Страница `frontend/sim-builder.html` (доступ teacher/admin; сайдбар как у других страниц).
|
||||||
|
- [x] Левая панель-аккордеоны + центр-превью (встроенный `SimEngine` инстанс, перемонтаж при правках, debounce 280мс).
|
||||||
|
- [x] Панель **Параметры**: добавить/удалить параметр (имя, min, max, step, начальное, единица) → слайдер в превью.
|
||||||
|
- [x] Панель **Объекты**: добавить объект (тип из whitelist), редактор свойств; числовые поля
|
||||||
|
принимают число ИЛИ выражение; палитра-помощник функций/параметров/объектов; подпись с LaTeX-превью.
|
||||||
|
- [x] Панель **Графики/Физика**: добавить plot (expr/var/range/trace); тумблер физики + её поля (Ф2) + стены/пружины.
|
||||||
|
- [x] Размещение объектов мышью на превью (кнопка-«прицел» у объекта → клик-поставить, drag-двигать) с синхроном в свойства.
|
||||||
|
- [x] Мета: заголовок, описание, предмет, класс, категория + viewport (xmin/xmax/ymin/ymax, сетка/оси, автозапуск/цикл).
|
||||||
|
- [x] Save/Load через `LS.customSims*` (Ф3): новый / редактировать существующий (?id=); кнопки «Сохранить»/«Опубликовать»/«Тест»/«Сброс».
|
||||||
|
- [x] Валидация на клиенте (понятные ошибки до сохранения, модалка-список) + inline-ошибки выражений у полей.
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/sim-builder.html` (new) — страница + инлайн-логика редактора
|
||||||
|
- `frontend/js/labs/_sim_engine.js` — при необходимости hook'и для билдера (live re-mount, highlight) (modify, минимально)
|
||||||
|
- `js/api.js` — если нужны доп. методы (modify, опц.)
|
||||||
|
- ссылка в сайдбаре/навигации для учителя (modify соответствующего include)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Учитель с нуля добавляет параметры/объекты/график, видит живое превью, сохраняет, открывает заново и видит то же.
|
||||||
|
- Ошибка в выражении показывается понятно, не роняет редактор.
|
||||||
|
- Нет эмодзи, дизайн в системе ls.css.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Прагматично: форма-панели + лёгкий drag на превью. НЕ полноценный node-граф.
|
||||||
|
- Это frontend-фаза — использовать гайдлайны frontend-design.
|
||||||
|
- Большой файл — держать логику модульной (можно вынести в `frontend/js/sim-builder.js`).
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Полный цикл build→save→reload→edit работает (headless-смоук 23/23)
|
||||||
|
- [x] Доступ только teacher/admin (LS.initPage gate + редирект /dashboard)
|
||||||
|
- [x] Нет эмодзи; дизайн-система соблюдена (ls.css переменные/классы)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что реализовано (Phase 4)
|
||||||
|
- **Страница `frontend/sim-builder.html`** (URL `/sim-builder`, гейт teacher/admin через
|
||||||
|
`LS.initPage()` → редирект `/dashboard`). Раскладка: `.app-layout > .sidebar(#app-sidebar) +
|
||||||
|
.sb-content`; внутри `.sbu-wrap` = тулбар + `.sbu-body` (панели-аккордеоны слева 360px +
|
||||||
|
превью справа). Подключает движок `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js` (тем
|
||||||
|
же путём, что lab.html — `/js` мапится на корневой `js/`, а `labs/*` проваливается на
|
||||||
|
`express.static(frontend)`), KaTeX CDN, и логику `/js/sim-builder.js`.
|
||||||
|
- **Логика `frontend/js/sim-builder.js`** → `window.SimBuilder.create({host, previewHost,
|
||||||
|
panelHost, toolbarHost}) -> Builder`. Состояние `Builder.st` (meta/subject/grade/cat/
|
||||||
|
viewport/time/params[]/objects[]/plots[]/physics{}); `_uid` на объектах/стенах/пружинах —
|
||||||
|
только UI-метка, вырезается при сборке.
|
||||||
|
- **Генерация спеки** `Builder.buildSpec()` → чистый JSON v1: `{ specVersion:1, meta:{title,
|
||||||
|
desc}, viewport, time, params[], objects[](+merged plots), physics? }`. `stripObj()` убирает
|
||||||
|
`_uid`/пустые поля; **plot** материализуется отдельно (`normalizePlotForSpec`: UI-поля
|
||||||
|
`range_a/range_b` → `range:[a,b]`, границы могут быть числом ИЛИ выражением xmin/xmax). Физика
|
||||||
|
собирается только при `physics.enabled` (gravity{x,y}, friction, restitution клампится 0..1,
|
||||||
|
walls[], springs[]; конец пружины «id» или «x,y» парсится `parseEnd`). Числовые поля объектов
|
||||||
|
хранятся как введено (число/строка-выражение) — движок `SimExpr.compileValue` ест оба.
|
||||||
|
- **Живое превью**: `scheduleRemount(debounce 280мс)` → `remount()` уничтожает старый инстанс,
|
||||||
|
монтирует `SimEngine.mount(previewHost, buildSpec())`, сохраняет play-состояние. Ошибка сборки
|
||||||
|
показывается в превью, не роняет редактор.
|
||||||
|
- **Drag-on-preview**: кнопка-«прицел» у объекта выбирает его (`_selObjId`); pointerdown/move по
|
||||||
|
`inst.canvas` конвертит px→мир через `inst._toWorld()` и пишет x/y (или x2/y2 для segment/
|
||||||
|
vector) в свойства объекта + обновляет поля панели. Работает только на паузе движка.
|
||||||
|
- **Палитра выражений** (`openPalette`): модалка с чипами — параметры, ссылки `id.x/id.y` на
|
||||||
|
объекты с id, `t/w/h`, константы (`SimExpr.CONSTANTS`), функции (`SimExpr.FUNCTIONS`); клик
|
||||||
|
вставляет имя в активное поле (функции — с `()`).
|
||||||
|
- **Валидация (клиент, до запроса)** `Builder.validate()` зеркалит серверную (Ф3): обязателен
|
||||||
|
title; params ≤50, имя-идентификатор, **запрет `e`/служебных (pi/t/w/h/E/PI/tau)**, без дублей,
|
||||||
|
min≤max; objects+plots ≤200; каждое выражение `SimExpr.compile` → ошибка показывается у поля И
|
||||||
|
в списке-модалке; expr ≤500 симв.; walls ≤20, springs ≤50; restitution 0..1; размер JSON ≤200КБ
|
||||||
|
(через Blob). Ошибки — дружелюбная модалка-список.
|
||||||
|
- **Save/Load**: «Сохранить»→`customSimCreate`/`customSimUpdate`(если есть simId); «Опубликовать»
|
||||||
|
добавляет `status:'published'`. После create — `history.replaceState('/sim-builder?id=<id>')`,
|
||||||
|
чтобы повторное сохранение делало update. Загрузка `?id=<id>` → `customSimGet` → `loadFromSim`
|
||||||
|
раскладывает спеку обратно по панелям (объекты vs plots разделяются по `type==='plot'`).
|
||||||
|
- **Сайдбар**: добавлен пункт `/sim-builder` «Конструктор симуляций» (icon `pencil-ruler`,
|
||||||
|
teacher-only) в группу «Практика и игры» сразу после «Лаборатория» — минимальная аддитивная
|
||||||
|
правка `js/sidebar.js` (тот же паттерн `cls:'sb-teacher-only', hidden:!isTch`, активная
|
||||||
|
подсветка через `isActive`).
|
||||||
|
|
||||||
|
### Что осталось / на Ф5 (каталог)
|
||||||
|
- Превью-картинка симуляции (опц., упомянута в задаче) НЕ делалась — `custom_sims` не имеет поля
|
||||||
|
превью; визуальная карточка каталога — забота Ф5 (можно рендерить мини-`SimEngine` как превью).
|
||||||
|
- На Ф5: custom-sims из `LS.customSimsList()` (published + свои) должны попасть в каталог `/lab`
|
||||||
|
через `window.registerSpecSim(spec)` / `SimAdapter` (Ф0). Кнопка «Открыть в конструкторе» из
|
||||||
|
каталога → `/sim-builder?id=<id>` уже работает (страница грузит по `?id`). Билдер пишет спеку
|
||||||
|
ровно в формате, который ест `SimEngine.mount` и серверная `validateSpec` (escaped-текст
|
||||||
|
приходит обратно при GET — для KaTeX/canvas безопасно).
|
||||||
|
- Drag-on-preview пишет только x/y (или конец segment/vector). Перетаскивание точек polyline,
|
||||||
|
origin вектора в форме origin+dx/dy, и хэндлов physics-тел — НЕ сделано (числами/выражениями
|
||||||
|
редактируется). Это осознанный прагматичный минимум (см. Notes плана).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Phase 5: Каталог (custom-sims в /lab)
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Сохранённые custom-симуляции появляются и играют в /lab наравне со встроенными;
|
||||||
|
раздел «Мои симуляции», редактирование/удаление из каталога, deep-link.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] При загрузке /lab подтягивать custom-sims (свои + published) из `GET /api/custom-sims`
|
||||||
|
(`LS.customSimsList()`) и регистрировать ЛЕНИВЫЕ манифесты в LabRegistry. spec НЕ грузится
|
||||||
|
на старте — тянется при первом открытии (`LS.customSimGet` → `registerSpecSim` Ф0-адаптера).
|
||||||
|
- [x] Карточки в каталоге: категория/предмет(grade)/из меты; бейдж «Моя» (owner) / «Опубликована»
|
||||||
|
(status) / «Черновик» (own draft).
|
||||||
|
- [x] Раздел «Мои симуляции» в /lab — отдельная секция `#custom-sim-section` в `#lab-home`
|
||||||
|
(создаётся динамически, без правок lab.html/CSS); уважает текущий фильтр категорий.
|
||||||
|
- [x] Кнопки на карточке custom-sim: «Редактировать» → `/sim-builder?id=<dbid>`, «Удалить»
|
||||||
|
(`LS.customSimDelete`) — только владельцу (`owner_id === user.id`).
|
||||||
|
- [x] Deep-link `/lab?sim=custom:<dbid>` открывает напрямую: хук `LabCustom.resolveId` в `openSim`
|
||||||
|
(lab-init.js) переводит `custom:<dbid>` → реестровый id `customsim_<dbid>`; для custom deep-link
|
||||||
|
открытие отложено до загрузки списка (и в обычном, и в embed-режиме).
|
||||||
|
- [x] Ленивая загрузка: движок (`_sim_expr/_sim_engine/_sim_adapter`) уже eager в lab.html (Ф0),
|
||||||
|
поэтому отдельный ленивый файл не нужен; лениво грузится только spec (тяжёлый JSON) при открытии.
|
||||||
|
`_sim_deps.js` НЕ тронут.
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/js/labs/lab-glue.js` и/или `lab-init.js` — загрузка+регистрация custom-sims, карточки, фильтр (modify)
|
||||||
|
- `frontend/js/labs/_sim_deps.js` — `_sim_*.js` в ленивые зависимости (modify)
|
||||||
|
- `js/api.js` — при необходимости (modify, опц.)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Сохранённая в Ф4 симуляция видна в /lab, открывается и играет.
|
||||||
|
- «Мои симуляции» показывает свои (вкл. draft); published видят и другие.
|
||||||
|
- Edit/Delete с карточки работают; deep-link открывает.
|
||||||
|
- Старт /lab не тормозит (движок грузится лениво).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- НЕ ломать существующий каталог встроенных (lab_sims) — custom-список добавляется поверх.
|
||||||
|
- id-неймспейс `custom:` чтобы не конфликтовать со встроенными.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Встроенные симуляции и старт /lab не регрессировали (custom исключены из основной сетки
|
||||||
|
по флагу `_custom`; падение загрузки списка не ломает каталог — try/catch + мягкий warn)
|
||||||
|
- [x] Draft видит только владелец; published — все (видимость обеспечивает сервер Ф3:
|
||||||
|
`customSimsList` отдаёт свои любого статуса + чужие published; бейджи/кнопки — по `owner_id`)
|
||||||
|
- [x] Ленивая загрузка spec работает (кэш + дедуп; на старте спеки не грузятся)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что реализовано (Phase 5)
|
||||||
|
Только аддитивные правки двух файлов параллельной сессии — без рефактора их кода:
|
||||||
|
- **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук —
|
||||||
|
`if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;`
|
||||||
|
Переводит `custom:<dbid>` → реестровый id `customsim_<dbid>` (LabRegistry.get/has обрезают
|
||||||
|
часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op.
|
||||||
|
- **`frontend/js/labs/lab-glue.js`**:
|
||||||
|
- `renderSims()`: 1 строка в merge — `&& !m._custom` (custom-манифесты не попадают в основную
|
||||||
|
сетку встроенных) + вызов `LabCustom.renderSection(_catFilter)` после рендера грида.
|
||||||
|
- init-блок (non-embed): после `renderSims()` зовёт `LabCustom.init()`; для `?sim=custom:*`
|
||||||
|
открытие отложено до резолва списка. Аналогично в embed-ветке (на `load`).
|
||||||
|
- **`window.LabCustom`** (новый IIFE в конце файла): `init()` (fetch списка, регистрация
|
||||||
|
ленивых манифестов, рендер секции), `resolveId`, `renderSection`, `ensureSpec`, `del`.
|
||||||
|
|
||||||
|
### Как custom-sims попадают в каталог и открываются
|
||||||
|
1. `LS.customSimsList()` → мета (без spec). Для каждой — `_registerLazy(meta)`: в LabRegistry
|
||||||
|
кладётся манифест-заглушка `id='customsim_<dbid>'`, `_custom:true`, мета-поля, и `open()`,
|
||||||
|
который при первом вызове лениво тянет spec.
|
||||||
|
2. Секция «Мои симуляции» (`#custom-sim-section`) рендерится из `_meta` (НЕ из реестра): карточки
|
||||||
|
`.sim-card` с `data-open="custom:<dbid>"`, бейджами и (владельцу) кнопками edit/del. Делегат
|
||||||
|
кликов на секции: открыть / `/sim-builder?id=` / `LS.customSimDelete`.
|
||||||
|
3. Открытие: `openSim('custom:<dbid>')` → `resolveId` → `customsim_<dbid>` → дисп. реестра →
|
||||||
|
`open()` заглушки → `ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=<regId>` →
|
||||||
|
`registerSpecSim(spec)` (Ф0-адаптер строит реальный SimEngine-манифест, ЗАМЕНЯЕТ заглушку на
|
||||||
|
месте) → `setActive(real)` + `real.open(ctx)` (монтирует SimEngine). Повторное открытие —
|
||||||
|
синхронно, реальный манифест уже в реестре, spec из кэша.
|
||||||
|
|
||||||
|
### id-неймспейс
|
||||||
|
- Deep-link / клик / `data-open`: **`custom:<dbid>`**.
|
||||||
|
- LabRegistry / host: **`customsim_<dbid>`** (без `:`). Конвертация — только в `resolveId`.
|
||||||
|
|
||||||
|
### Формат карточки
|
||||||
|
preview-SVG (плейсхолдер) + cat-бейдж (`.sim-cat`) + бейджи «Моя»/«Опубликована»/«Черновик»
|
||||||
|
+ title + desc (+ «N класс») + (владельцу) ряд кнопок «Редактировать»/«Удалить» (inline SVG `.ic`).
|
||||||
|
|
||||||
|
### Риски / заметки для Ф6
|
||||||
|
- `_loadRelated('customsim_<dbid>')` дергает `/api/lab/sims/.../related` (404 для custom) — тихо
|
||||||
|
глотается. Если Ф6 заведёт связи custom-sim с программой — учесть этот id.
|
||||||
|
- Удаление: после `customSimDelete` карточка убирается из секции, но манифест-заглушка остаётся
|
||||||
|
в LabRegistry (LabRegistry не имеет unregister). Не критично (карточки нет, deep-link на
|
||||||
|
удалённую вернёт 404 при ensureSpec). Если Ф6/Ф7 потребуют чистку — добавить unregister в реестр.
|
||||||
|
- Ф6 (раздача/публикация/клон/шаблоны): кнопку «Поделиться/Раздать классу» добавлять в `_cardHtml`
|
||||||
|
(ещё один `data-act`); публикацию toggle — там же. Клон — новый `LS.customSimCreate` со spec
|
||||||
|
из `ensureSpec`. Источник spec для доски (Ф7) — `LabCustom.ensureSpec(dbid)` или живой
|
||||||
|
`LabRegistry.get('customsim_<dbid>').instance()`.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Phase 6: Раздача / шаблоны / клон / программа
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (pending commit — за оркестратором)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Экосистема вокруг custom-sims: публикация, раздача классу, клонирование чужих,
|
||||||
|
старт из шаблонов, привязка к программе (учебник/тема).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Публикация: тумблер draft↔published. В билдере — кнопка «Опубликовать»/«Снять» (PUT status,
|
||||||
|
`setStatus`); в каталоге — кнопки на owner-карточке (publish/unpublish). Только владелец/admin.
|
||||||
|
- [x] Раздача классу: `POST /api/custom-sims/:id/share { classId }` (requireRole teacher,admin +
|
||||||
|
per-row ownership). РЕШЕНИЕ: published custom-sim И ТАК видна всем в каталоге, поэтому раздача =
|
||||||
|
авто-публикация (status→published) + ДОЛГОВЕЧНОЕ уведомление ученикам класса (`pushNotif`,
|
||||||
|
notifications-таблица + SSE) со ссылкой `/lab?sim=custom:<id>`. БЕЗ копии и БЕЗ content_access
|
||||||
|
(custom-sim не гейтится allowlist'ом 'sim' — тот гейтит только legacy lab_sims).
|
||||||
|
- [x] Клон: `POST /api/custom-sims/:id/clone` — копия spec вызвавшему как draft, title += « (копия)»,
|
||||||
|
version=1. Источник: своя (любая) ИЛИ чужая published. Кнопка «Клонировать к себе» на чужой
|
||||||
|
published-карточке (только для teacher/admin) → ведёт на `/sim-builder?id=<newId>`.
|
||||||
|
- [x] Шаблоны: 4 встроенных спеки в `TEMPLATES` (sim-builder.js): пустая, маятник, график y=f(x),
|
||||||
|
бросок. Кнопка «Шаблон» в тулбаре → модалка выбора → `loadFromSim` как новая симуляция.
|
||||||
|
«Создать из существующей» = clone (с чужой карточки).
|
||||||
|
- [x] Привязка к программе: переиспользован `lab_sim_links` с `sim_id='custom:<id>'` (sim_id —
|
||||||
|
TEXT, отдельная таблица не нужна). Эндпоинты на роутере custom-sims: GET `/:id/related`,
|
||||||
|
POST `/:id/links`, DELETE `/:id/links/:linkId` (владелец/admin управляет связями СВОЕЙ симуляции).
|
||||||
|
Клиент: `customSimRelated/AddLink/DelLink`. Backend + чтение готовы; UI-редактор связей в билдере
|
||||||
|
и чипы в каталоге — НЕ сделаны (см. Handoff: остаток).
|
||||||
|
- [x] Тесты: `backend/tests/custom-sims-share.test.js` (15 it): share (авто-publish +
|
||||||
|
durable-уведомление, 400/403/404), clone (новый владелец/draft/spec, чужой published OK, чужой
|
||||||
|
draft 403), links (привязка/удаление/related, ownership). seedRow-паттерн (class/members/textbook).
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `backend/src/controllers/customSimController.js` — share/clone/publish (modify)
|
||||||
|
- `backend/src/routes/customSims.js` — роуты share/clone (modify)
|
||||||
|
- `backend/src/controllers/.../lab links` — связи для custom (reuse `lab.js` links, modify при необходимости)
|
||||||
|
- `frontend/sim-builder.html` / `frontend/js/labs/lab-glue.js` — шаблоны, кнопки публикации/клона/раздачи (modify)
|
||||||
|
- `js/api.js` — методы share/clone (modify)
|
||||||
|
- `backend/tests/custom-sims-share.test.js` (new)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Учитель публикует; другой учитель видит и клонирует к себе (draft).
|
||||||
|
- Выданная классу симуляция доступна ученикам класса (с уведомлением).
|
||||||
|
- Старт из шаблона создаёт рабочую заготовку. Привязка к учебнику показывает чип/кнопку.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Раздача — переиспользовать существующий механизм доступа/уведомлений, не строить новый.
|
||||||
|
- Решение копия-vs-ссылка зафиксировать в CONTEXT.md.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены (привязка к программе — backend+чтение; UI-редактор связей в Handoff)
|
||||||
|
- [x] Ownership на publish/share/clone/links покрыт тестами (чужой draft → 403, чужой published clone → ОК)
|
||||||
|
- [x] Ученик класса получает уведомление; не-владелец/не-свой-класс → 403
|
||||||
|
- [x] Reuse материалов/доступа/links, без дублей (pushNotif, lab_sim_links, паттерн share из materials)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Что реализовано (Ф6)
|
||||||
|
- **Backend** (`customSimController.js` + `customSims.js`):
|
||||||
|
- `POST /:id/share { classId }` — авто-publish + `pushNotif(uid,'sim_shared',msg,'/lab?sim=custom:<id>')`
|
||||||
|
каждому ученику класса. Возвращает `{ ok, sent, status:'published' }`. requireRole(teacher,admin) +
|
||||||
|
ownership симуляции + проверка `classes.teacher_id`.
|
||||||
|
- `POST /:id/clone` → 201 `{ id }`. Источник: своя (любая) ИЛИ чужая published. status=draft, version=1,
|
||||||
|
title += ' (копия)', spec_json копируется как есть.
|
||||||
|
- `GET /:id/related` (auth, own/published) / `POST /:id/links` / `DELETE /:id/links/:linkId`
|
||||||
|
(owner/admin) — поверх `lab_sim_links`, `sim_id='custom:<id>'`. DELETE симуляции чистит её связи.
|
||||||
|
- **Решение копия-vs-доступ:** доступ (published виден всем) + уведомление; НЕ копия, НЕ content_access.
|
||||||
|
- **Клиент** (`js/api.js`): `customSimShare/Clone/Related/AddLink/DelLink` в `window.LS`.
|
||||||
|
- **Каталог** (`lab-glue.js`, IIFE `LabCustom`, аддитивно): owner-карточка — кнопки Раздать классу /
|
||||||
|
Опубликовать↔Снять; чужая published (для teacher/admin) — «Клонировать к себе». Делегат `data-act`
|
||||||
|
расширен (share/clone/publish/unpublish). Модалка выбора класса (`LS.getClasses`+`LS.modal`).
|
||||||
|
Публичное API: добавлены `LabCustom.share/clone/setStatus`.
|
||||||
|
- **Билдер** (`sim-builder.js`): тулбар — «Шаблон» (TEMPLATES×4: пустая/маятник/график/бросок →
|
||||||
|
`loadFromSim` как новая), «Раздать» (для сохранённой), publish-toggle (Опубликовать↔Снять).
|
||||||
|
Новые методы: `setStatus`, `openShareModal`, `openTemplateModal`. ICON.template/unpublish.
|
||||||
|
|
||||||
|
### Остаток (минимум по плану — не сделано, низкий приоритет)
|
||||||
|
- UI-редактор курикулумных связей в билдере (select учебника из `/api/access/catalog` + добавить/
|
||||||
|
удалить) и чипы «Связано с программой» в каталоге/на странице sim. Backend и `/related` готовы —
|
||||||
|
это чисто фронтовая надстройка. Обратный поиск «какие custom-sim привязаны к учебнику» (как
|
||||||
|
`/api/lab/links?kind=textbook&ref_id=`) для custom НЕ добавлен: `/api/lab/links` джойнит `lab_sims`,
|
||||||
|
а у custom строк там нет — при желании добавить отдельный bulk-эндпоинт или LEFT JOIN на custom_sims.
|
||||||
|
|
||||||
|
### Для Ф7 (доска онлайн-урока)
|
||||||
|
- Источник sim для доски: `LS.customSimGet(id)` → `sim.spec`; рендер — `window.SimEngine.mount(host, spec)`
|
||||||
|
(как в билдере/каталоге). Deep-link/идентификатор: `custom:<dbid>`; в LabRegistry — `customsim_<dbid>`
|
||||||
|
(resolveId). published-симуляция доступна всем (для учеников на доске — без доп. прав).
|
||||||
|
- Раздача классу уже шлёт уведомление со ссылкой `/lab?...` — на доске ссылку при необходимости
|
||||||
|
заменить на классную сессию; механизм `pushNotif` переиспользуем.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Phase 7: Доска онлайн-урока
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented (pending commit)
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Открывать custom-симуляцию на доске онлайн-урока через существующий конвейер встраивания
|
||||||
|
sim, с синхроном параметров классу и аннотациями поверх.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Учитель в classroom выбирает sim для доски: добавить в список источников свои+published custom-sims (рядом со встроенными).
|
||||||
|
→ `_crLoadCustomSims()` (`LS.customSimsList`) + мёрж в `_crRenderSimGrid`; карточка с бейджем «Моя», id=`custom:<dbid>`.
|
||||||
|
- [x] Открытие на доске через существующий `simOpen` (controller `classroom/sim.js`, роут `/:id/sim`) —
|
||||||
|
для custom передаётся `custom:<id>`. Рантайм НЕ монтируется напрямую в доску: доска уже грузит sim
|
||||||
|
в **iframe** `/lab?embed=1&sim=...`; для custom это `/lab?embed=1&sim=custom:<id>`, где `LabCustom`/
|
||||||
|
`registerSpecSim` монтирует `SimEngine` (путь Ф5). Конвейер iframe переиспользован 1:1.
|
||||||
|
- [x] Синхрон состояния: параметры + play/pause транслируются классу через существующий мост
|
||||||
|
`sim_state`/`apply_sim_state` (lab-glue) → `simState`. Custom-sim подключён к мосту через
|
||||||
|
`_bridgeCustomSimState(real)` (getState/applyState поверх `SimEngine.params`/`setParam`/`play`/`pause`).
|
||||||
|
⚠️ **Время t (фаза анимации) жёстко НЕ синхронится** — только параметры слайдеров + признак running.
|
||||||
|
- [x] Аннотации поверх — через существующий `simAnnotate` (без изменений конвейера, id-agnostic).
|
||||||
|
- [x] Закрытие/смена sim: `onSimClose` сбрасывает `frame.src='about:blank'` → весь документ iframe
|
||||||
|
(SimEngine + rAF + слушатели + реестр состояния) сносится вместе с iframe. Утечек нет.
|
||||||
|
- [x] Проверка: логическая (трасса teacher→backend→SSE→student) + node --check + npm test (в baseline).
|
||||||
|
Headless classroom тяжёл; синхрон проверен по конвейеру, открытие/доступ/закрытие — по коду.
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `frontend/classroom.html` — выбор custom-sim в источниках доски, монтаж SimEngine, проброс состояния (modify)
|
||||||
|
- `backend/src/controllers/classroom/sim.js` — поддержать `custom:<id>` (валидация доступа к published/own) (modify)
|
||||||
|
- `js/api.js` — при необходимости (modify, опц.)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Учитель открывает custom-sim на доске; ученики видят её синхронно (параметры/анимация/режим).
|
||||||
|
- Аннотации поверх работают; закрытие чистит ресурсы.
|
||||||
|
- Существующее встраивание встроенных sim не регрессировало.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Reuse simOpen/simState/simMode/simAnnotate — НЕ строить новый канал синхрона.
|
||||||
|
- Доступ: на доску можно класть только свои или published (проверка на сервере).
|
||||||
|
- classroom.html большой (8240 строк) — править точечно.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [x] Все задачи выполнены
|
||||||
|
- [x] Синхрон параметров учитель→ученики работает (sim_state мост, params+running)
|
||||||
|
- [x] Доступ к custom-sim на доске проверяется (server simOpen: own|published|admin; иначе 403/404)
|
||||||
|
- [x] Встроенные sim на доске не сломаны (id-ветка для встроенных без изменений); ресурсы чистятся через about:blank
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Финальная фаза -->
|
||||||
|
|
||||||
|
## Handoff — итог Ф7 (степень синхрона, что осталось)
|
||||||
|
|
||||||
|
**Изменённые файлы (минимально, аддитивно):**
|
||||||
|
- `backend/src/controllers/classroom/sim.js` (+21/-2): `simOpen` принимает `custom:<dbid>`,
|
||||||
|
валидирует доступ (владелец ИЛИ published ИЛИ admin → иначе 404/403). Встроенный id — старый regex.
|
||||||
|
`simState/simMode/simAnnotate/simClose` НЕ менялись (state-объект и так произвольный, ≤64KB).
|
||||||
|
- `frontend/classroom.html` (+31/-4): `_crLoadCustomSims()` (кэш `LS.customSimsList`),
|
||||||
|
`crOpenSimPicker` async + предзагрузка, `_crRenderSimGrid` мёржит custom (бейдж «Моя», id `custom:<dbid>`,
|
||||||
|
XSS-escape заголовков/id в onclick). `crPickSim` уже передаёт id как есть.
|
||||||
|
- `frontend/js/labs/lab-glue.js` (+48/-1): `_bridgeCustomSimState(real)` — мост состояния для custom-sim;
|
||||||
|
вызывается в `_registerLazy.open()` после `real.open(ctx)` (только embed). Регистрирует
|
||||||
|
`getState={params,running}` / `applyState` (setParam+play/pause) под ключом `_autoSim`
|
||||||
|
(`custom:<dbid>`), запускает `_startStateEmit`. Тем же `_simStateRegistry`/каналом, что встроенные.
|
||||||
|
|
||||||
|
**Степень синхрона:** параметры слайдеров + признак воспроизведения (play/pause) — ПОЛНЫЙ синхрон
|
||||||
|
учитель→ученики (demo-режим). Время `t` (фаза анимации) НЕ синхронизируется покадрово: ученик
|
||||||
|
запускает свой rAF при running=true, фаза может разъезжаться. Достаточно по требованиям фазы.
|
||||||
|
При желании в будущем: добавить `t` в state и `inst._t` сеттер (потребует расширения SimEngine API).
|
||||||
|
|
||||||
|
**Открытие на доске:** через iframe `/lab?embed=1&sim=custom:<id>` (НЕ прямой mount в доску) —
|
||||||
|
переиспользован существующий конвейер; SimEngine монтируется уже внутри iframe (путь Ф5).
|
||||||
|
|
||||||
|
**Доступ:** двойная проверка — (1) `simOpen` на сервере при постановке на доску;
|
||||||
|
(2) `GET /api/custom-sims/:id` (ensureSpec) при загрузке спеки в iframe. Чужой draft → 403 на обоих.
|
||||||
|
|
||||||
|
**Что осталось / риски:**
|
||||||
|
- Нет интеграционного теста classroom-сессии (нет харнесса; добавление потребовало бы мока сессий
|
||||||
|
и риска зайти в зону параллельной сессии). Проверка — логическая + node --check + npm test (в baseline).
|
||||||
|
- Время анимации не синхронится покадрово (см. выше) — by design.
|
||||||
|
- Остаток Ф6 (UI-редактор связей в билдере + чипы в каталоге) — вне Ф7.
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
## Прогресс
|
## Прогресс
|
||||||
- [x] Фаза 0 (фундамент заложен) — эконом-режим/reduced-motion (LabFX, тумблер), выбор симуляции из списка в редакторе урока, удалён мёртвый `SimUtil`, добавлены `LabPalette` (_palette.js) и `SimBase` (_simbase.js) как опциональные основания. **Адаптация симуляций к SimBase/LabPalette и удаление «дробовика» `_pauseAllSims/closeSim` — постепенно, по мере правок каждой симуляции (требует поштучной проверки, нет фронт-тестов).**
|
- [x] Фаза 0 (фундамент заложен) — эконом-режим/reduced-motion (LabFX, тумблер), выбор симуляции из списка в редакторе урока, удалён мёртвый `SimUtil`, добавлены `LabPalette` (_palette.js) и `SimBase` (_simbase.js) как опциональные основания. **Адаптация симуляций к SimBase/LabPalette и удаление «дробовика» `_pauseAllSims/closeSim` — постепенно, по мере правок каждой симуляции (требует поштучной проверки, нет фронт-тестов).**
|
||||||
- [~] Фаза 1 — сделано: фреймворк `LabTasks` (_tasks.js) + интеграция в теорию; задания на 17 симуляций. Осталось: XP за задания, deep-link на §, наполнение остальных.
|
- [~] Фаза 1 — сделано: фреймворк `LabTasks` (_tasks.js) + интеграция в теорию; задания на 17 симуляций. Осталось: XP за задания, deep-link на §, наполнение остальных.
|
||||||
- [ ] Фаза 2
|
- [x] Фаза 2 — «Сохранить кадр в Мои материалы» + «Скачать PNG»; сохранение/возобновление параметров (localStorage, не в embed); измерительные инструменты `LabMeasure` (линейка + угломер, SVG-оверлей). Остаток-доработка: 3D/WebGL-снимок (preserveDrawingBuffer), привязка линейки к шкале конкретной симуляции.
|
||||||
- [ ] Фаза 3
|
- [ ] Фаза 3
|
||||||
- [ ] Фаза 4
|
- [ ] Фаза 4
|
||||||
- [ ] Фаза 5
|
- [ ] Фаза 5
|
||||||
|
|||||||
Reference in New Issue
Block a user