Chestny Znak scanner: DataMatrix -> National Catalog КБЖУ prefill
- ChZ module (frontend/js/chzn.js): import an anonymous session (exported from
the CRPT app with tools/chzn_export.sh) and keep it alive via /authorize/refresh
— /authorize/anonymous is Play Integrity-gated so BalDav can't mint its own.
GS1 code normalization (reconstruct the GS separator before AI 93), check ->
catalogData good_attrs КБЖУ parser (match by attr group + name prefix, since
producers name the fields differently), optional nutrition/info vitamins.
- chzn_http Rust command: browser UA + returns {status,body} so 204/401/427 drive
the session/refresh logic.
- Scanner modal (BarcodeDetector camera + photo/manual fallbacks) -> prefill the
new-product form; CAMERA permission; settings section to import/refresh session.
- tools/chzn_export.sh: pull + validate the anonymous session from a rooted device.
This commit is contained in:
parent
fe20a4d590
commit
c3ec590da5
7 changed files with 511 additions and 11 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,3 +22,6 @@ db_backups/
|
|||
|
||||
# Session-local analysis harnesses, copied DBs, pulled 50Hz sensor dumps (large)
|
||||
test_recovery/
|
||||
|
||||
# Честный знак session export (tokens — never commit)
|
||||
chzn_session.json
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера |
|
||||
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) |
|
||||
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
|
||||
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
|
||||
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
|
||||
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
|
||||
| `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим |
|
||||
|
|
@ -38,7 +39,7 @@
|
|||
|
||||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `src/lib.rs` | ~750 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек) |
|
||||
| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView |
|
||||
| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR |
|
||||
| `src/main.rs` | 5 | Точка входа |
|
||||
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
|
||||
|
|
@ -71,6 +72,7 @@
|
|||
| `import_healthdiet.js` | Импорт health-diet.ru JSON в seed DB (авто-бамп версии) |
|
||||
| `fetch_openfoodfacts.js` | OpenFoodFacts API |
|
||||
| `fuzzy_dedup.js` | Нечёткая дедупликация |
|
||||
| `chzn_export.sh` | Экспорт анонимной сессии приложения «Честный знак» с рут-девайса (adb) → `chzn_session.json` для импорта в BalDav. Читает `com.qrbonus.xml`, проверяет живым `refresh` |
|
||||
|
||||
### Корень
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
<script src="js/tilecache.js"></script>
|
||||
<script src="js/gps.js"></script>
|
||||
<script src="js/sync.js"></script>
|
||||
<script src="js/chzn.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
278
frontend/js/chzn.js
Normal file
278
frontend/js/chzn.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// Честный знак (CRPT) integration.
|
||||
//
|
||||
// Scan a product's ЧЗ DataMatrix -> resolve via mobile.api.crpt.ru -> pull КБЖУ
|
||||
// from the National Catalog card -> prefill the "new product" form.
|
||||
//
|
||||
// Auth: the CRPT anonymous-session endpoint (/authorize/anonymous) is gated by
|
||||
// Google Play Integrity (bound to the ЧЗ app package) so BalDav cannot mint its
|
||||
// own session. Instead a session is exported from the ЧЗ app on a rooted device
|
||||
// with tools/chzn_export.sh and imported here as a JSON file. The session's sid
|
||||
// (48h) is kept alive indefinitely via /authorize/refresh (NOT integrity-gated).
|
||||
//
|
||||
// All requests go through the Rust `chzn_http` command (returns {status,body})
|
||||
// so there is no CORS and we can see 204/401/427 status codes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let ChZ = (function () {
|
||||
let LS_KEY = "chzn_session";
|
||||
let DEFAULT_BASE = "https://mobile.api.crpt.ru";
|
||||
let DEFAULT_EXPIRES = 172800000; // 48h fallback
|
||||
|
||||
function invoke(url, method, body) {
|
||||
return window.__TAURI__.core.invoke("chzn_http", { url, method, body: body || null });
|
||||
}
|
||||
|
||||
// Returns {status:int, body:string} or throws.
|
||||
async function raw(url, method, body) {
|
||||
let envelope = await invoke(url, method, body);
|
||||
let parsed = JSON.parse(envelope);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getSession() {
|
||||
try { return JSON.parse(localStorage.getItem(LS_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
function setSession(s) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(s));
|
||||
}
|
||||
function hasSession() {
|
||||
let s = getSession();
|
||||
return !!(s && s.userId && s.sid && s.refreshToken && s.instanceId && s.deviceId);
|
||||
}
|
||||
function baseUrl() {
|
||||
let s = getSession();
|
||||
return (s && s.baseUrl) || DEFAULT_BASE;
|
||||
}
|
||||
|
||||
// Import an exported chzn_session.json (text). Returns the stored session.
|
||||
function importFromText(text) {
|
||||
let d = JSON.parse(text);
|
||||
let need = ["userId", "sid", "refreshToken", "instanceId", "deviceId"];
|
||||
for (let k of need) {
|
||||
if (d[k] === undefined || d[k] === null || d[k] === "") {
|
||||
throw new Error("в файле нет поля " + k);
|
||||
}
|
||||
}
|
||||
let s = {
|
||||
source: "chestny_znak",
|
||||
baseUrl: d.baseUrl || DEFAULT_BASE,
|
||||
userId: d.userId,
|
||||
sid: d.sid,
|
||||
refreshToken: d.refreshToken,
|
||||
instanceId: d.instanceId,
|
||||
deviceId: String(d.deviceId),
|
||||
sidObtainedAt: d.sidObtainedAt || Date.now(),
|
||||
expiresInMs: d.expiresInMs || DEFAULT_EXPIRES,
|
||||
};
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function sidExpired(s) {
|
||||
let exp = s.expiresInMs || DEFAULT_EXPIRES;
|
||||
let got = s.sidObtainedAt || 0;
|
||||
return Date.now() >= got + exp - 120000; // refresh 2 min early
|
||||
}
|
||||
|
||||
// POST /mobile/authorize/refresh -> new sid. Updates stored session.
|
||||
async function refresh() {
|
||||
let s = getSession();
|
||||
if (!s) throw new Error("нет сессии Честного знака");
|
||||
let body = JSON.stringify({
|
||||
instanceId: s.instanceId,
|
||||
refresh_token: s.refreshToken,
|
||||
device_id: String(s.deviceId),
|
||||
client: "MOBILE",
|
||||
});
|
||||
let r = await raw(baseUrl() + "/mobile/authorize/refresh", "POST", body);
|
||||
if (r.status !== 200) throw new Error("refresh HTTP " + r.status);
|
||||
let j = JSON.parse(r.body);
|
||||
if (!j.sid) throw new Error("refresh без sid: " + r.body.slice(0, 120));
|
||||
s.sid = j.sid;
|
||||
if (j.refreshToken) s.refreshToken = j.refreshToken;
|
||||
s.expiresInMs = j.expiresIn || s.expiresInMs || DEFAULT_EXPIRES;
|
||||
s.sidObtainedAt = Date.now();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Ensure a fresh sid before an authed call.
|
||||
async function ensureSid() {
|
||||
let s = getSession();
|
||||
if (!s) throw new Error("нет сессии Честного знака — импортируйте chzn_session.json");
|
||||
if (sidExpired(s)) return await refresh();
|
||||
return s;
|
||||
}
|
||||
|
||||
function looksLikeAuthError(r) {
|
||||
if (r.status === 401 || r.status === 403) return true;
|
||||
if (r.status === 400 && /"formErrors"[\s\S]*"(userId|sid)"/.test(r.body || "")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authenticated POST with userId/sid in the query string; refreshes once on auth error.
|
||||
async function authedPost(path, body) {
|
||||
let s = await ensureSid();
|
||||
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
|
||||
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
|
||||
let r = await raw(mk(s), "POST", body);
|
||||
if (looksLikeAuthError(r)) {
|
||||
s = await refresh();
|
||||
r = await raw(mk(s), "POST", body);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
async function authedGet(path) {
|
||||
let s = await ensureSid();
|
||||
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
|
||||
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
|
||||
let r = await raw(mk(s), "GET");
|
||||
if (looksLikeAuthError(r)) {
|
||||
s = await refresh();
|
||||
r = await raw(mk(s), "GET");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// POST /mobile/check with a GS1 code string. Returns parsed check JSON.
|
||||
async function check(code) {
|
||||
let r = await authedPost("/mobile/check", JSON.stringify({ code }));
|
||||
if (r.status !== 200) throw new Error("check HTTP " + r.status + ": " + (r.body || "").slice(0, 160));
|
||||
return JSON.parse(r.body);
|
||||
}
|
||||
|
||||
// Optional bonus: GET /mobile/nutrition/info -> vitamins/minerals. Often 204.
|
||||
async function nutritionInfo(checkId, gtin) {
|
||||
try {
|
||||
let r = await authedGet("/mobile/nutrition/info?checkId=" + encodeURIComponent(checkId) +
|
||||
">in=" + encodeURIComponent(gtin));
|
||||
if (r.status !== 200 || !r.body) return null;
|
||||
return JSON.parse(r.body);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GS1 code normalisation.
|
||||
// A GS1 DataMatrix is 01<gtin14>21<serial><GS>93<crypto>. The GS (0x1D)
|
||||
// separator between the variable-length AI 21 and AI 93 is REQUIRED for
|
||||
// /mobile/check to resolve. Different decoders present it differently, so:
|
||||
// - strip a leading symbology id (]d2, ]C1, {FNC1}) and leading GS chars;
|
||||
// - if the string already contains a GS, keep it;
|
||||
// - otherwise insert a GS before the last "93".
|
||||
// ------------------------------------------------------------------
|
||||
let GS = String.fromCharCode(0x1d);
|
||||
function normalizeCode(rawStr) {
|
||||
let s = String(rawStr || "");
|
||||
s = s.replace(/^\]d2/, "").replace(/^\]C1/, "").replace(/^\]Q3/, "");
|
||||
s = s.replace(/^\{FNC1\}/i, "").replace(/\{GS\}/gi, GS);
|
||||
s = s.replace(new RegExp("^" + GS + "+"), "");
|
||||
if (s.indexOf(GS) === -1) {
|
||||
let m = s.match(/^01(\d{14})21(.+)$/);
|
||||
if (m) {
|
||||
let tail = m[2];
|
||||
let idx = tail.lastIndexOf("93");
|
||||
if (idx > 0) {
|
||||
s = "01" + m[1] + "21" + tail.slice(0, idx) + GS + tail.slice(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function extractGtin(code) {
|
||||
let m = String(code || "").match(/^01(\d{14})/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Parse the National Catalog card (check.catalogData[0].good_attrs) into a
|
||||
// product prefill object. КБЖУ live in the group "Энергетическая ценность,
|
||||
// белки, жиры и углеводы" but attr names vary by producer
|
||||
// ("Белки" vs "Белки, количество", etc.), so match by prefix within the group.
|
||||
// ------------------------------------------------------------------
|
||||
function num(v) {
|
||||
if (v === undefined || v === null) return null;
|
||||
let n = parseFloat(String(v).replace(",", ".").replace(/[^\d.\-]/g, ""));
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
function parseCard(chk) {
|
||||
let cd = (chk.catalogData || [])[0] || {};
|
||||
let attrs = cd.good_attrs || [];
|
||||
let inEnergy = (a) => (a.attr_group_name || "").indexOf("Энергетическая") === 0;
|
||||
let find = (pred) => attrs.find(pred);
|
||||
|
||||
let kcalAttr = find(a => inEnergy(a) && /Энергетическая ценность/i.test(a.attr_name) &&
|
||||
/ккал/i.test((a.attr_value_type || "") + " " + a.attr_name));
|
||||
let proteinAttr = find(a => inEnergy(a) && /^Белки/i.test(a.attr_name));
|
||||
let fatAttr = find(a => inEnergy(a) && /^Жиры/i.test(a.attr_name));
|
||||
let carbsAttr = find(a => inEnergy(a) && /^Углеводы/i.test(a.attr_name));
|
||||
let compAttr = find(a => /^Состав$/i.test(a.attr_name) || (a.attr_group_name === "Состав"));
|
||||
let weightAttr = find(a => /вес нетто|Заявленный объ[её]м/i.test(a.attr_name));
|
||||
|
||||
let name = cd.good_name || chk.productName || "";
|
||||
let prefill = {
|
||||
name: name,
|
||||
kcal_per_100g: kcalAttr ? num(kcalAttr.attr_value) : null,
|
||||
protein_per_100g: proteinAttr ? num(proteinAttr.attr_value) : null,
|
||||
fat_per_100g: fatAttr ? num(fatAttr.attr_value) : null,
|
||||
carbs_per_100g: carbsAttr ? num(carbsAttr.attr_value) : null,
|
||||
composition: compAttr ? String(compAttr.attr_value).trim() : null,
|
||||
net_weight_g: weightAttr ? num(weightAttr.attr_value) : null,
|
||||
gtin: extractGtin(chk.code) || (chk.codeResolveData || {}).gtin || null,
|
||||
source: "chzn",
|
||||
micros: {},
|
||||
};
|
||||
return prefill;
|
||||
}
|
||||
|
||||
// Map ЧЗ vitamin/mineral names to product table columns (unit matches column).
|
||||
let MICRO_MAP = {
|
||||
"Кальций": "calcium_mg", "Фосфор": "phosphorus_mg", "Железо": "iron_mg",
|
||||
"Магний": "magnesium_mg", "Калий": "potassium_mg", "Натрий": "sodium_mg",
|
||||
"Цинк": "zinc_mg", "Медь": "copper_mg", "Селен": "selenium_mcg",
|
||||
"Витамин A": "vit_a_mcg", "Витамин C": "vit_c_mg", "Витамин D": "vit_d_mcg",
|
||||
"Витамин E": "vit_e_mg", "Витамин B1": "vit_b1_mg", "Витамин B2": "vit_b2_mg",
|
||||
"Витамин B6": "vit_b6_mg", "Витамин B12": "vit_b12_mcg", "Фолиевая кислота": "folate_mcg",
|
||||
};
|
||||
function mergeMicros(prefill, nut) {
|
||||
if (!nut || !Array.isArray(nut.nutrition)) return prefill;
|
||||
for (let blk of nut.nutrition) {
|
||||
for (let c of (blk.components || [])) {
|
||||
if (c.key !== "vitaminsAndMinerals") continue;
|
||||
for (let d of (c.details || [])) {
|
||||
for (let row of (d.rows || [])) {
|
||||
let col = MICRO_MAP[row.name];
|
||||
let val = row.calculationValue;
|
||||
if (col && val != null) prefill.micros[col] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return prefill;
|
||||
}
|
||||
|
||||
// Full resolve: GS1 code (raw from scanner) -> prefill object.
|
||||
async function resolve(rawCode) {
|
||||
let code = normalizeCode(rawCode);
|
||||
let chk = await check(code);
|
||||
if (chk.codeFounded === false) {
|
||||
let e = new Error("код не найден в Честном знаке");
|
||||
e.notFound = true;
|
||||
throw e;
|
||||
}
|
||||
let prefill = parseCard(chk);
|
||||
let cid = chk.id;
|
||||
let gtin = prefill.gtin;
|
||||
if (cid && gtin) {
|
||||
let nut = await nutritionInfo(cid, gtin);
|
||||
mergeMicros(prefill, nut);
|
||||
}
|
||||
return prefill;
|
||||
}
|
||||
|
||||
return {
|
||||
getSession, setSession, hasSession, importFromText, refresh, ensureSid,
|
||||
check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve,
|
||||
_raw: raw,
|
||||
};
|
||||
})();
|
||||
|
|
@ -1553,6 +1553,7 @@ async function renderProducts(container) {
|
|||
placeholder="Название, бренд или ингредиент..." />
|
||||
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">⇩</button>
|
||||
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">⇧</button>
|
||||
<button class="add-btn" id="scan-chzn" style="font-size:14px" title="Скан «Честный знак»">▦</button>
|
||||
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
|
||||
</div>
|
||||
<div id="prod-count" class="prod-count"></div>
|
||||
|
|
@ -1724,6 +1725,7 @@ async function renderProducts(container) {
|
|||
// Add custom product button
|
||||
let addCustomBtn = container.querySelector("#add-custom-product");
|
||||
if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true));
|
||||
container.querySelector("#scan-chzn")?.addEventListener("click", () => showChznScanModal(() => doSearch(true)));
|
||||
|
||||
// Export all custom data (manual products + recipes + supplements)
|
||||
container.querySelector("#export-all-custom")?.addEventListener("click", async () => {
|
||||
|
|
@ -1768,41 +1770,55 @@ async function renderProducts(container) {
|
|||
});
|
||||
}
|
||||
|
||||
function showAddProductModal(onSaved) {
|
||||
function showAddProductModal(onSaved, prefill) {
|
||||
let pf = prefill || {};
|
||||
let micros = pf.micros || {};
|
||||
let v = (x) => (x === null || x === undefined || x === "") ? "" : x;
|
||||
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
|
||||
let raw = micros[f.key];
|
||||
if (raw != null && f.mult) raw = Math.round(raw / f.mult * 1000) / 1000;
|
||||
return `<div class="form-group micro-field">
|
||||
<label>${f.label}</label>
|
||||
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" placeholder="—" />
|
||||
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" value="${v(raw)}" placeholder="—" />
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Честный знак source: header note + composition/weight hint (not stored as columns).
|
||||
let chznNote = pf.source === "chzn"
|
||||
? `<div style="background:var(--surface,#1c1c1e);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:8px;font-size:12px;color:var(--muted)">
|
||||
Данные из Честного знака${pf.gtin ? " · GTIN " + esc(pf.gtin) : ""}${pf.net_weight_g ? " · нетто " + esc(pf.net_weight_g) + " г" : ""}
|
||||
${pf.composition ? `<div style="margin-top:4px"><b>Состав:</b> ${esc(pf.composition)}</div>` : ""}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
showModal(`
|
||||
<h3>Новый продукт</h3>
|
||||
${chznNote}
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input id="np-name" type="text" placeholder="Тесто для чебуреков" />
|
||||
<input id="np-name" type="text" value="${esc(v(pf.name))}" placeholder="Тесто для чебуреков" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>ккал/100г</label>
|
||||
<input id="np-kcal" type="number" inputmode="decimal" placeholder="260" />
|
||||
<input id="np-kcal" type="number" inputmode="decimal" value="${v(pf.kcal_per_100g)}" placeholder="260" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>Б</label>
|
||||
<input id="np-protein" type="number" inputmode="decimal" step="0.1" placeholder="7" />
|
||||
<input id="np-protein" type="number" inputmode="decimal" step="0.1" value="${v(pf.protein_per_100g)}" placeholder="7" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>Ж</label>
|
||||
<input id="np-fat" type="number" inputmode="decimal" step="0.1" placeholder="4" />
|
||||
<input id="np-fat" type="number" inputmode="decimal" step="0.1" value="${v(pf.fat_per_100g)}" placeholder="4" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>У</label>
|
||||
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
|
||||
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" value="${v(pf.carbs_per_100g)}" placeholder="48" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
|
||||
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" placeholder="" />
|
||||
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${v(pf.water_pct)}" placeholder="" />
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
|
||||
<div class="micro-grid">${microHtml}</div>`,
|
||||
|
|
@ -1816,7 +1832,7 @@ function showAddProductModal(onSaved) {
|
|||
fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null,
|
||||
carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null,
|
||||
water_pct: parseFloat(overlay.querySelector("#np-water").value) || null,
|
||||
source: "manual",
|
||||
source: pf.source === "chzn" ? "chzn" : "manual",
|
||||
};
|
||||
for (let f of PRODUCT_MICRO_FIELDS) {
|
||||
let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null;
|
||||
|
|
@ -1828,6 +1844,112 @@ function showAddProductModal(onSaved) {
|
|||
});
|
||||
}
|
||||
|
||||
// Честный знак scanner: camera (BarcodeDetector) with photo/manual fallbacks.
|
||||
// On a decoded DataMatrix -> ChZ.resolve -> prefill the new-product modal.
|
||||
function showChznScanModal(onSaved) {
|
||||
if (!ChZ.hasSession()) {
|
||||
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
|
||||
return;
|
||||
}
|
||||
let hasBD = ("BarcodeDetector" in window);
|
||||
let detector = hasBD ? new BarcodeDetector({ formats: ["data_matrix", "qr_code"] }) : null;
|
||||
|
||||
let ov = document.createElement("div");
|
||||
ov.className = "modal-overlay";
|
||||
ov.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header"><button class="modal-back" id="cz-close">←</button></div>
|
||||
<div class="modal-body">
|
||||
<h3>Скан «Честный знак»</h3>
|
||||
<div style="position:relative;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:1/1;max-height:60vh">
|
||||
<video id="cz-video" playsinline muted style="width:100%;height:100%;object-fit:cover"></video>
|
||||
<div style="position:absolute;inset:15%;border:2px solid rgba(255,255,255,.7);border-radius:8px;pointer-events:none"></div>
|
||||
</div>
|
||||
<div id="cz-status" style="margin:10px 0;font-size:13px;color:var(--muted);min-height:18px">Запуск камеры...</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="cz-photo" class="btn-secondary" style="flex:1;font-size:13px">Из фото</button>
|
||||
<button id="cz-manual" class="btn-secondary" style="flex:1;font-size:13px">Ввести код</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
|
||||
let stream = null, scanning = false, busy = false;
|
||||
let base = winStack.length;
|
||||
let statusEl = ov.querySelector("#cz-status");
|
||||
let video = ov.querySelector("#cz-video");
|
||||
|
||||
function cleanup() {
|
||||
scanning = false;
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
}
|
||||
winPush("chzn-scan", () => { cleanup(); ov.remove(); });
|
||||
function close() {
|
||||
let count = winStack.length - base;
|
||||
for (let i = 0; i < count; i++) winStack.pop().close();
|
||||
_skipPops = count;
|
||||
if (count > 0) history.go(-count);
|
||||
}
|
||||
|
||||
async function onDecoded(rawVal) {
|
||||
if (busy) return;
|
||||
busy = true; scanning = false;
|
||||
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
|
||||
try {
|
||||
let prefill = await ChZ.resolve(rawVal);
|
||||
cleanup();
|
||||
close();
|
||||
showAddProductModal(onSaved, prefill);
|
||||
} catch (e) {
|
||||
busy = false;
|
||||
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
|
||||
if (detector && stream) { scanning = true; requestAnimationFrame(loop); }
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
if (busy || !scanning) return;
|
||||
try {
|
||||
let codes = await detector.detect(video);
|
||||
if (codes && codes.length) { scanning = false; onDecoded(codes[0].rawValue); return; }
|
||||
} catch (e) { /* transient decode error, keep scanning */ }
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
if (!detector) { statusEl.textContent = "Сканер недоступен в этом WebView. Выберите фото кода или введите вручную."; return; }
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } });
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
scanning = true;
|
||||
statusEl.textContent = "Наведите на код Честного знака...";
|
||||
loop();
|
||||
} catch (e) {
|
||||
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
|
||||
}
|
||||
}
|
||||
|
||||
ov.querySelector("#cz-photo").onclick = () => {
|
||||
pickFile("image/*", async (buf) => {
|
||||
if (!detector) { statusEl.textContent = "Сканер недоступен — введите код вручную."; return; }
|
||||
try {
|
||||
let bmp = await createImageBitmap(new Blob([buf]));
|
||||
let codes = await detector.detect(bmp);
|
||||
if (codes && codes.length) onDecoded(codes[0].rawValue);
|
||||
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое фото.";
|
||||
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + e.message; }
|
||||
}, true);
|
||||
};
|
||||
ov.querySelector("#cz-manual").onclick = async () => {
|
||||
let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", "");
|
||||
if (code) onDecoded(code.trim());
|
||||
};
|
||||
ov.querySelector("#cz-close").onclick = () => close();
|
||||
|
||||
startCamera();
|
||||
}
|
||||
|
||||
function showEditProductModal(p, onSaved) {
|
||||
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
|
||||
let val = p[f.key] ?? "";
|
||||
|
|
@ -5706,6 +5828,29 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Честный знак</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Сессия</div>
|
||||
<div class="settings-item-desc" id="s-chzn-status">${(() => {
|
||||
let s = ChZ.getSession();
|
||||
if (!s) return "Не импортирована";
|
||||
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
|
||||
return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
|
||||
})()}</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-chzn-import" style="padding:6px 12px;font-size:13px;">Импорт</button>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Обновить сессию</div>
|
||||
<div class="settings-item-desc">Продлить sid через refresh (проверка связи)</div>
|
||||
</div>
|
||||
<button class="btn-secondary" id="s-chzn-refresh" style="padding:6px 12px;font-size:13px;">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">Карты</div>
|
||||
<div class="settings-item">
|
||||
|
|
@ -6019,6 +6164,40 @@ async function renderSettings(container) {
|
|||
showToast(`Удалено ${removed} продуктов`);
|
||||
};
|
||||
|
||||
// Честный знак: import session JSON exported by tools/chzn_export.sh.
|
||||
function chznRefreshStatus() {
|
||||
let el = container.querySelector("#s-chzn-status");
|
||||
if (!el) return;
|
||||
let s = ChZ.getSession();
|
||||
if (!s) { el.textContent = "Не импортирована"; return; }
|
||||
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
|
||||
el.textContent = `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
|
||||
}
|
||||
container.querySelector("#s-chzn-import")?.addEventListener("click", () => {
|
||||
pickFile(".json,application/json", async (text) => {
|
||||
try {
|
||||
ChZ.importFromText(text);
|
||||
showToast("Сессия Честного знака импортирована");
|
||||
chznRefreshStatus();
|
||||
} catch (e) {
|
||||
showToast("Ошибка импорта: " + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
container.querySelector("#s-chzn-refresh")?.addEventListener("click", async () => {
|
||||
let btn = container.querySelector("#s-chzn-refresh");
|
||||
if (!ChZ.hasSession()) { showToast("Сначала импортируйте сессию"); return; }
|
||||
btn.disabled = true; btn.textContent = "...";
|
||||
try {
|
||||
await ChZ.refresh();
|
||||
showToast("Сессия обновлена");
|
||||
chznRefreshStatus();
|
||||
} catch (e) {
|
||||
showToast("Refresh не удался: " + e.message);
|
||||
}
|
||||
btn.disabled = false; btn.textContent = "Обновить";
|
||||
});
|
||||
|
||||
// Import tiles from a file the user picks on the device (SAF picker on Android).
|
||||
container.querySelector("#s-import-tiles").onclick = async () => {
|
||||
let btn = container.querySelector("#s-import-tiles");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -84,6 +84,41 @@ async fn http_request(url: String, method: String, body: Option<String>) -> Resu
|
|||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
// HTTP proxy for the "Честный знак" API. Unlike http_request it (a) sends a
|
||||
// browser-ish User-Agent (the CRPT gateway is picky) and (b) returns the HTTP
|
||||
// status alongside the body as a JSON envelope {"status":N,"body":"..."} so the
|
||||
// caller can drive session logic: 204 = no nutrition data, 401/400 = refresh sid,
|
||||
// 427 = version/attestation gate. http_status_as_error(false) keeps 4xx/5xx as
|
||||
// normal responses instead of errors so we can read their status/body.
|
||||
#[tauri::command]
|
||||
async fn chzn_http(url: String, method: String, body: Option<String>) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||
.timeout_global(Some(std::time::Duration::from_secs(15)))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
let ua = "Mozilla/5.0 (Linux; Android 15)";
|
||||
let mut resp = match method.to_uppercase().as_str() {
|
||||
"POST" => {
|
||||
let b = body.unwrap_or_default();
|
||||
agent.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", ua)
|
||||
.send(b.as_bytes())
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => agent.get(&url).header("User-Agent", ua).call().map_err(|e| e.to_string())?,
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
Ok(serde_json::json!({ "status": status, "body": text }).to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
// Get the active points file name from gps_live_points.jsonl pointer
|
||||
#[tauri::command]
|
||||
fn get_points_filename(app: tauri::AppHandle) -> Result<String, String> {
|
||||
|
|
@ -869,7 +904,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue