vkusvill 0.0.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js ADDED
@@ -0,0 +1,328 @@
1
+ // The application: tabs with page stacks, overlays, mouse routing, toasts and the render loop.
2
+
3
+ import { appendFileSync, mkdirSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { Screen } from "./term/screen.js";
6
+ import { Input } from "./term/input.js";
7
+ import { G, SPIN } from "./ui/draw.js";
8
+ import { T, mix } from "./ui/theme.js";
9
+ import { strWidth, truncate } from "./term/width.js";
10
+ import { stats, onActivity, pruneCache, CACHE_DIR } from "./api.js";
11
+ import { onImageLoad } from "./image.js";
12
+ import { onUrlBlocked } from "./sys.js";
13
+ import { store } from "./store.js";
14
+ import { rub } from "./product.js";
15
+
16
+ export const TABS = [
17
+ { id: "catalog", label: "Каталог" },
18
+ { id: "deals", label: "Акции" },
19
+ { id: "recipes", label: "Рецепты" },
20
+ { id: "shops", label: "Магазины" },
21
+ { id: "cart", label: "Корзина" },
22
+ { id: "profile", label: "Профиль" },
23
+ ];
24
+
25
+ const MIN_W = 64, MIN_H = 18;
26
+ export const LOG_FILE = path.join(CACHE_DIR, "vv-cli.log");
27
+
28
+ function log(e) {
29
+ try {
30
+ mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 });
31
+ appendFileSync(LOG_FILE, `${new Date().toISOString()} ${e?.stack ?? e}\n`, { mode: 0o600 });
32
+ } catch {}
33
+ }
34
+
35
+ export class App {
36
+ constructor({ views, headless = false, size } = {}) {
37
+ this.headless = headless;
38
+ this.screen = new Screen(headless ? { columns: size?.[0] ?? 120, rows: size?.[1] ?? 36, write() {} } : process.stdout);
39
+ this.g = new G(this.screen, this);
40
+ this.views = views;
41
+ this.stacks = {};
42
+ this.overlays = [];
43
+ this.hits = [];
44
+ this.overlayHits = 0;
45
+ this.frame = 0;
46
+ this.toastMsg = null;
47
+ this.lastClick = { t: 0, x: -1, y: -1 };
48
+ this.compare = new Map();
49
+ for (const t of TABS) this.stacks[t.id] = [views[t.id](this)];
50
+ const saved = store.pref("tab");
51
+ this.tab = TABS.some((t) => t.id === saved) ? saved : "catalog";
52
+ onActivity(() => this.invalidate());
53
+ onImageLoad(() => this.invalidate());
54
+ onUrlBlocked(() => this.toast("Это не ссылка ВкусВилла — не открываю", T.red));
55
+ store.listeners.add(() => this.invalidate());
56
+ }
57
+
58
+ get page() { const s = this.stacks[this.tab]; return s[s.length - 1]; }
59
+
60
+ start() {
61
+ const s = this.screen;
62
+ let restored = false;
63
+ const restore = () => {
64
+ if (restored) return;
65
+ restored = true;
66
+ try { s.leave(); this.input?.stop(); } catch {}
67
+ };
68
+ this.restore = restore;
69
+ s.enter();
70
+ process.on("exit", restore);
71
+ for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) process.on(sig, () => this.quit());
72
+ // A stray rejection must not kill the session: log it and say so
73
+ process.on("unhandledRejection", (e) => { log(e); this.toast(`⚠ ${e?.message ?? e}`, T.red); });
74
+ process.on("uncaughtException", (e) => {
75
+ log(e);
76
+ store.saveNow();
77
+ restore();
78
+ console.error(`vkusvill-cli упал: ${e?.message ?? e}\nподробности: ${LOG_FILE}`);
79
+ process.exit(1);
80
+ });
81
+ process.stdout.on("resize", () => { s.resize(process.stdout.columns, process.stdout.rows); this.render(); });
82
+ this.input = new Input(process.stdin, (ev) => this.onEvent(ev));
83
+ this.input.start();
84
+ store.watch();
85
+ if (store.warning) this.toast(store.warning, T.orange);
86
+ this.ticker = setInterval(() => this.tick(), 90);
87
+ setTimeout(() => pruneCache().catch(log), 4000).unref();
88
+ this.page.onShow?.();
89
+ this.render();
90
+ }
91
+
92
+ quit() {
93
+ store.saveNow();
94
+ store.unwatch();
95
+ clearInterval(this.ticker);
96
+ this.restore?.();
97
+ process.exit(0);
98
+ }
99
+
100
+ tick() {
101
+ this.frame++;
102
+ // Pages load more data here, never from draw(): rendering stays free of side effects
103
+ this.page.tick?.();
104
+ const top = this.overlays.at(-1);
105
+ if (stats.active || stats.queued || this.page.busy?.() || top?.animated || this.toastMsg || this.page.animated) this.invalidate();
106
+ if (this.toastMsg && Date.now() > this.toastMsg.until) { this.toastMsg = null; this.invalidate(); }
107
+ }
108
+
109
+ // Coalesces redraws to at most one per ~16 ms
110
+ invalidate() {
111
+ if (this.pending) return;
112
+ this.pending = true;
113
+ const since = Date.now() - (this.lastRender ?? 0);
114
+ setTimeout(() => { this.pending = false; this.render(); }, Math.max(0, 16 - since));
115
+ }
116
+
117
+ // Clickable area registered during render; handlers: click(ev), wheel(dir), passive
118
+ hit(x, y, w, h, handlers) { this.hits.push({ x, y, w, h, ...handlers }); }
119
+
120
+ toast(msg, color = T.lime) {
121
+ this.toastMsg = { msg: String(msg), color, until: Date.now() + 2600 };
122
+ this.invalidate();
123
+ }
124
+
125
+ // ——— Navigation ———
126
+
127
+ switchTab(id) {
128
+ // Menus, the palette and dialogs belong to the tab they were opened on
129
+ this.closeOverlays();
130
+ if (this.tab === id) return;
131
+ this.tab = id;
132
+ store.setPref("tab", id);
133
+ this.page.onShow?.();
134
+ this.invalidate();
135
+ }
136
+
137
+ push(view) {
138
+ this.stacks[this.tab].push(view);
139
+ view.onShow?.();
140
+ this.invalidate();
141
+ }
142
+
143
+ pop() {
144
+ const s = this.stacks[this.tab];
145
+ if (s.length < 2) return false;
146
+ s.pop()?.onHide?.();
147
+ this.page.onShow?.();
148
+ this.invalidate();
149
+ return true;
150
+ }
151
+
152
+ toggleCompare(p) {
153
+ if (this.compare.has(p.id)) {
154
+ this.compare.delete(p.id);
155
+ return this.toast(`Убрано из сравнения · ${this.compare.size}`, T.violet);
156
+ }
157
+ if (this.compare.size >= 4) return this.toast("Сравнивать можно до 4 товаров", T.orange);
158
+ this.compare.set(p.id, p);
159
+ this.toast(`⇄ В сравнении ${this.compare.size} · X — сравнить`, T.violet);
160
+ }
161
+
162
+ openCompare() {
163
+ if (this.compare.size < 2) return this.toast("Отметьте хотя бы 2 товара клавишей x", T.violet);
164
+ this.views.compare(this);
165
+ }
166
+
167
+ overlay(o) {
168
+ this.overlays.push(o);
169
+ this.invalidate();
170
+ return o;
171
+ }
172
+
173
+ closeOverlay(o) {
174
+ this.overlays = this.overlays.filter((x) => x !== o);
175
+ this.invalidate();
176
+ }
177
+
178
+ closeOverlays() {
179
+ for (const o of this.overlays) if (!o.persistent) o.onClose?.();
180
+ this.overlays = this.overlays.filter((o) => o.persistent);
181
+ this.invalidate();
182
+ }
183
+
184
+ // ——— Events ———
185
+
186
+ onEvent(ev) {
187
+ if (ev.type === "key" && ev.ctrl && ev.name === "c") return this.quit();
188
+ if (ev.type === "key" && ev.ctrl && ev.name === "l") { this.screen.invalidateAll(); return this.render(); }
189
+ if (ev.type === "mouse") return this.onMouse(ev);
190
+ const top = this.overlays.at(-1);
191
+ if (top) { top.key(ev, this); return this.invalidate(); }
192
+ if (ev.type === "key" && ev.ctrl && ev.name === "k") return this.views.palette(this);
193
+ if (this.page.key?.(ev)) return this.invalidate();
194
+ if (ev.type !== "key") return;
195
+ if (ev.name === "escape") return this.pop();
196
+ const k = ev.hot ?? ev.name;
197
+ const n = Number(ev.ch);
198
+ if (ev.ch && n >= 1 && n <= TABS.length) return this.switchTab(TABS[n - 1].id);
199
+ if (ev.name === "tab" || ev.name === "backtab") {
200
+ const i = TABS.findIndex((t) => t.id === this.tab);
201
+ return this.switchTab(TABS[(i + (ev.name === "tab" ? 1 : TABS.length - 1)) % TABS.length].id);
202
+ }
203
+ if (k === "q") return this.quit();
204
+ if (k === "?" || k === "&" || ev.name === "f1") return this.views.help(this);
205
+ if (k === ":" || k === "^") return this.views.palette(this);
206
+ if (k === "b") return this.views.barcode(this);
207
+ if (k === "c" && !ev.shift) return this.switchTab("cart");
208
+ }
209
+
210
+ onMouse(ev) {
211
+ const inside = (h) => ev.x >= h.x && ev.x < h.x + h.w && ev.y >= h.y && ev.y < h.y + h.h;
212
+ // An open overlay is modal: the mouse only sees its areas
213
+ const modal = this.overlays.length > 0;
214
+ const from = modal ? this.overlayHits : 0;
215
+ if (ev.action === "wheelup" || ev.action === "wheeldown") {
216
+ const dir = ev.action === "wheelup" ? -1 : 1;
217
+ for (let i = this.hits.length - 1; i >= from; i--) if (this.hits[i].wheel && inside(this.hits[i])) { this.hits[i].wheel(dir); break; }
218
+ return this.invalidate();
219
+ }
220
+ if (ev.action !== "down" || ev.button !== 0) return;
221
+ const now = Date.now();
222
+ ev.double = now - this.lastClick.t < 400 && this.lastClick.x === ev.x && this.lastClick.y === ev.y;
223
+ this.lastClick = { t: now, x: ev.x, y: ev.y };
224
+ let handled = false;
225
+ for (let i = this.hits.length - 1; i >= from; i--) {
226
+ const h = this.hits[i];
227
+ if (h.click && !h.passive && inside(h)) { h.click(ev); handled = true; break; }
228
+ }
229
+ // A click outside the overlay closes it; a click on the header also goes through (switching tabs)
230
+ if (modal && !handled && !this.overlays.at(-1)?.persistent) {
231
+ this.closeOverlays();
232
+ if (ev.y === 0) {
233
+ for (let i = from - 1; i >= 0; i--) {
234
+ const h = this.hits[i];
235
+ if (h.click && inside(h)) { h.click(ev); break; }
236
+ }
237
+ }
238
+ }
239
+ this.invalidate();
240
+ }
241
+
242
+ // ——— Rendering ———
243
+
244
+ render() {
245
+ this.lastRender = Date.now();
246
+ const g = this.g, s = this.screen;
247
+ this.hits = [];
248
+ s.clear(T.bg);
249
+ if (s.w < MIN_W || s.h < MIN_H) {
250
+ g.textC(0, s.w, Math.floor(s.h / 2) - 1, "ВкусВилл не помещается", { fg: T.lime, bold: true });
251
+ g.textC(0, s.w, Math.floor(s.h / 2) + 1, `нужно хотя бы ${MIN_W}×${MIN_H}, сейчас ${s.w}×${s.h}`, { fg: T.dim });
252
+ this.overlayHits = 0;
253
+ return s.flush();
254
+ }
255
+ this.drawHeader(g);
256
+ const body = { x: 0, y: 1, w: s.w, h: s.h - 2 };
257
+ s.pushClip(body.x, body.y, body.w, body.h);
258
+ this.page.draw(g, body);
259
+ s.popClip();
260
+ this.drawStatus(g);
261
+ this.overlayHits = this.hits.length;
262
+ for (const o of this.overlays) o.draw(g, this);
263
+ s.flush();
264
+ }
265
+
266
+ drawHeader(g) {
267
+ const w = g.w;
268
+ g.fill(0, 0, w, 1, T.header);
269
+ let labels = TABS.map((t, i) => ` ${i + 1} ${t.label}${t.id === "cart" && store.count ? ` ${store.count}` : ""} `);
270
+ const cartW = store.count ? strWidth(` ▣ ${rub(store.total)} `) + 1 : 0;
271
+ let tabsW = labels.reduce((sum, l) => sum + strWidth(l), 0);
272
+ // Too narrow: drop the leading space of every tab, then the wordmark
273
+ if (4 + tabsW + cartW > w) { labels = labels.map((l) => l.slice(1)); tabsW -= labels.length; }
274
+ const full = 15 + tabsW + cartW <= w;
275
+ let x = g.text(0, 0, " ◆ ", { fg: T.lime, bg: T.greenD, bold: true });
276
+ if (full) {
277
+ x = g.text(x, 0, "Вкус", { fg: T.white, bg: T.greenD, bold: true });
278
+ x = g.text(x, 0, "Вилл ", { fg: T.lime, bg: T.greenD, bold: true });
279
+ }
280
+ x = g.text(x, 0, "▶", { fg: T.greenD, bg: T.header });
281
+ if (full) x += 1;
282
+ TABS.forEach((t, i) => {
283
+ const active = t.id === this.tab;
284
+ const label = labels[i];
285
+ if (x + strWidth(label) > w - cartW) return;
286
+ const x0 = x;
287
+ x = g.text(x, 0, label, { fg: active ? T.bg : T.sub, bg: active ? T.green : T.header, bold: active });
288
+ this.hit(x0, 0, x - x0, 1, { click: () => this.switchTab(t.id) });
289
+ });
290
+ // Right side: network activity and the cart total
291
+ let rx = w - 1;
292
+ if (store.count) {
293
+ const t = ` ▣ ${rub(store.total)} `;
294
+ rx = g.textR(rx + 1, 0, t, { fg: T.bg, bg: T.lime, bold: true }) - 1;
295
+ this.hit(rx + 1, 0, strWidth(t), 1, { click: () => this.switchTab("cart") });
296
+ }
297
+ const room = Math.max(0, rx - x - 1);
298
+ if (stats.active || stats.queued || this.page.busy?.()) {
299
+ g.textR(rx, 0, truncate(`${SPIN[this.frame % SPIN.length]} ${stats.queued ? `очередь ${stats.queued}` : "ВкусВилл"}`, room), { fg: T.lime, bg: T.header });
300
+ } else if (store.saveError) {
301
+ g.textR(rx, 0, truncate(`⚠ не сохранилось: ${store.saveError}`, Math.min(room, 50)), { fg: T.red, bg: T.header });
302
+ } else if (stats.lastError) {
303
+ g.textR(rx, 0, truncate(`⚠ ${stats.lastError}`, Math.min(room, 44)), { fg: T.red, bg: T.header });
304
+ }
305
+ }
306
+
307
+ drawStatus(g) {
308
+ const y = g.h - 1;
309
+ g.fill(0, y, g.w, 1, T.header);
310
+ const top = this.overlays.at(-1);
311
+ const hints = top?.hints?.() ?? this.page.hints?.() ?? [];
312
+ const all = [...hints, ...(top ? [] : [["?", "помощь"], ["^K", "команды"]])];
313
+ // A toast takes the right end of the line; hints never run under it
314
+ let toastW = 0;
315
+ if (this.toastMsg) {
316
+ const m = truncate(` ${this.toastMsg.msg} `, Math.max(10, Math.floor(g.w * 0.6)));
317
+ toastW = strWidth(m);
318
+ const bg = mix(this.toastMsg.color, T.bg, 0.15);
319
+ g.text(g.w - toastW, y, m, { fg: T.bg, bg, bold: true });
320
+ }
321
+ let x = 1;
322
+ for (const [k, label] of all) {
323
+ const need = strWidth(k) + strWidth(label) + 4;
324
+ if (x + need > g.w - toastW - 1) break;
325
+ x = g.key(x, y, k, label);
326
+ }
327
+ }
328
+ }
package/src/cli.js ADDED
@@ -0,0 +1,159 @@
1
+ // Plain-output commands: tables, colored when stdout is a terminal, and --json for scripts.
2
+
3
+ import qrcode from "qrcode-generator";
4
+ import * as api from "./api.js";
5
+ import { normalize, rub, compactNum, qtyText, lineTotal, plural } from "./product.js";
6
+ import { store, CART_MAX, QTY_MAX } from "./store.js";
7
+ import { padEnd, padStart } from "./term/width.js";
8
+ import { openState } from "./views/shops.js";
9
+
10
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
11
+ const c = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
12
+ const green = c("38;5;77"), lime = c("38;5;155"), dim = c("2"), bold = c("1"), orange = c("38;5;215"), yellow = c("38;5;221"), red = c("38;5;203");
13
+ const width = () => Math.min(process.stdout.columns || 100, 120);
14
+
15
+ const out = (s = "") => process.stdout.write(s + "\n");
16
+
17
+ function productTable(items) {
18
+ const w = width();
19
+ const nameW = Math.max(20, w - 40);
20
+ for (const it of items) {
21
+ const p = normalize(it);
22
+ const price = p.old ? orange(padStart(rub(p.price), 9)) : bold(padStart(rub(p.price), 9));
23
+ const off = p.off ? orange(padStart(`−${p.off}%`, 5)) : " ";
24
+ const rating = p.rating ? yellow("★") + padEnd(p.rating.toFixed(1).replace(".", ","), 4) : " ";
25
+ out(`${dim(padStart(String(p.id), 7))} ${padEnd(p.name, nameW)} ${rating} ${off} ${price}`);
26
+ }
27
+ }
28
+
29
+ export async function search(q, { json, sort, page = 1 }) {
30
+ if (!q) throw new Error("Что искать? vv search творог");
31
+ const d = await api.search(q, { page, sort: sort ?? "popularity" });
32
+ if (json) return out(JSON.stringify(d, null, 2));
33
+ out(`${green("◆")} ${bold(`«${q}»`)} ${dim(`· ${d.meta?.total ?? 0} ${plural(d.meta?.total ?? 0, "товар", "товара", "товаров")} · стр. ${d.meta?.page}/${d.meta?.pages}`)}`);
34
+ productTable(d.items ?? []);
35
+ if (d.meta?.has_more) out(dim(` ещё: vv search ${q} --page ${page + 1}`));
36
+ }
37
+
38
+ export async function deals({ json, type }) {
39
+ const d = await api.discounts({ type });
40
+ if (json) return out(JSON.stringify(d, null, 2));
41
+ out(`${orange("%")} ${bold(type === "card" ? "Скидки по карте" : "Скидки от нескольких штук")} ${dim(`· ${d.meta?.total ?? 0}`)}`);
42
+ productTable(d.items ?? []);
43
+ }
44
+
45
+ function card(p) {
46
+ out(bold(p.name));
47
+ out(`${p.old ? orange(rub(p.price)) + " " + dim(`было ${rub(p.old)}`) : lime(rub(p.price))}${p.unit === "кг" ? dim(" за кг") : ""} ${p.rating ? yellow("★ " + String(p.rating).replace(".", ",")) + dim(` · ${compactNum(p.votes)}`) : ""}`);
48
+ if (p.offInfo) out(orange(p.offInfo));
49
+ if (p.kcal != null) out(`${dim("на 100 г")} Б ${p.p ?? "—"} · Ж ${p.f ?? "—"} · У ${p.c ?? "—"} · ${lime(Math.round(p.kcal) + " ккал")}`);
50
+ if (p.comp) out(`${dim("Состав:")} ${p.comp}`);
51
+ out(dim(p.url));
52
+ }
53
+
54
+ export async function product(id, { json }) {
55
+ if (!id) throw new Error("Нужен id товара: vv product 27695");
56
+ const d = await api.details(id);
57
+ if (json) return out(JSON.stringify(d, null, 2));
58
+ card(normalize(d));
59
+ }
60
+
61
+ export async function barcode(code, { json }) {
62
+ if (!/^\d{13}$/.test(code ?? "")) throw new Error("Нужен штрихкод EAN-13: 13 цифр");
63
+ const d = await api.barcode(code);
64
+ if (json) return out(JSON.stringify(d, null, 2));
65
+ card(normalize(d));
66
+ }
67
+
68
+ export async function recipes(q, { json }) {
69
+ const d = await api.recipes({ q });
70
+ if (json) return out(JSON.stringify(d, null, 2));
71
+ out(`${green("◆")} ${bold("Рецепты")} ${dim(`· ${d.meta?.total ?? 0}`)}`);
72
+ for (const r of d.items ?? []) {
73
+ out(`${bold(r.name)} ${dim([r.cooking_time?.name, r.complexity?.name, r.portions && `${r.portions} порц.`].filter(Boolean).join(" · "))}`);
74
+ out(dim(` ${(r.ingredients ?? []).map((i) => i.name).join(", ")}`));
75
+ }
76
+ }
77
+
78
+ export async function shops(q, { json }) {
79
+ // Filter by a metro station or city name, looked up in the reference lists of the first page
80
+ const first = await api.shops({ page: 1 });
81
+ let f = {};
82
+ if (q) {
83
+ // Best match wins: exact name, then a name starting with the query, then one containing it;
84
+ // on a tie a city beats a region and a metro station ("Волгоград" is the city, not the avenue)
85
+ const norm = (s) => s.toLowerCase().replace(/ё/g, "е").replace(/^(г|п|пгт|с|д|рп)\.\s*/, "").replace(/\s+(обл|край|респ)\.?$/, "").trim();
86
+ const ql = norm(q);
87
+ const rank = { city: 0, region: 1, subway: 2 };
88
+ let best = null;
89
+ for (const def of first.meta?.filters ?? []) {
90
+ if (!(def.param in rank)) continue;
91
+ for (const item of def.items) {
92
+ const n = norm(item.name);
93
+ const score = n === ql ? 0 : n.startsWith(ql) ? 1 : n.includes(ql) ? 2 : -1;
94
+ if (score < 0) continue;
95
+ const key = score * 10 + rank[def.param];
96
+ if (!best || key < best.key) best = { key, param: def.param, id: item.id };
97
+ }
98
+ }
99
+ if (!best) throw new Error(`Не знаю метро или город «${q}»`);
100
+ f = { [best.param]: best.id };
101
+ }
102
+ const d = Object.keys(f).length ? await api.shops({ page: 1, ...f }) : first;
103
+ if (json) return out(JSON.stringify(d, null, 2));
104
+ for (const s of d.items ?? []) {
105
+ const st = openState(s.schedule);
106
+ out(`${st ? (st.open ? lime("●") : red("○")) : " "} ${bold(s.address)} ${dim([s.subway?.name && `м. ${s.subway.name}`, s.city?.name].filter(Boolean).join(" · "))}`);
107
+ out(dim(` ${st?.text ?? s.schedule ?? ""}${s.phone?.length ? " · " + s.phone.join(", ") : ""}`));
108
+ }
109
+ if (d.meta?.has_more) out(dim(` …и ещё ${d.meta.total - (d.items?.length ?? 0)}`));
110
+ }
111
+
112
+ export function printQR(text) {
113
+ const q = qrcode(0, "L");
114
+ q.addData(text);
115
+ q.make();
116
+ const n = q.getModuleCount(), quiet = 2, size = n + quiet * 2;
117
+ const dark = (r, col) => r >= quiet && col >= quiet && r < n + quiet && col < n + quiet && q.isDark(r - quiet, col - quiet);
118
+ for (let y = 0; y < size; y += 2) {
119
+ let line = " ";
120
+ for (let x = 0; x < size; x++) {
121
+ const top = dark(y, x), bot = y + 1 < size ? dark(y + 1, x) : false;
122
+ line += `\x1b[38;5;${top ? 16 : 231}m\x1b[48;5;${bot ? 16 : 231}m▀`;
123
+ }
124
+ out(line + "\x1b[0m");
125
+ }
126
+ }
127
+
128
+ export async function cart(args, { json }) {
129
+ const [sub, id, qty] = args;
130
+ if (sub === "add") {
131
+ if (!/^\d{1,9}$/.test(id ?? "")) throw new Error("vv cart add <id> [кол-во] — id товара числом");
132
+ const p = normalize(await api.details(id));
133
+ const q = qty === undefined ? (p.unit === "кг" ? 0.5 : 1) : Number(String(qty).replace(",", "."));
134
+ if (!Number.isFinite(q) || q <= 0 || q > QTY_MAX) throw new Error(`Количество — число от 0,01 до ${QTY_MAX}`);
135
+ if (!store.qty(p.id) && store.count >= CART_MAX) throw new Error(`В корзине уже ${CART_MAX} позиций — больше ВкусВилл не принимает`);
136
+ store.add(p, q);
137
+ store.saveNow();
138
+ return out(`${lime("✚")} ${p.name} ${dim(`· в корзине ${qtyText(p, store.qty(p.id))}`)}`);
139
+ }
140
+ if (sub === "clear") { store.clearCart(); store.saveNow(); return out("Корзина очищена"); }
141
+ if (sub === "link") {
142
+ if (!store.cart.length) throw new Error("Корзина пуста");
143
+ const d = await api.cartLink(store.cart.map((l) => ({ id: l.p.id, q: l.q })));
144
+ if (json) return out(JSON.stringify(d, null, 2));
145
+ out();
146
+ if (process.stdout.isTTY) printQR(d.link);
147
+ out();
148
+ out(` ${lime(d.link)}`);
149
+ out(dim(` ${store.count} ${plural(store.count, "позиция", "позиции", "позиций")} · ${rub(store.total)} · оплата и доставка — у ВкусВилла`));
150
+ return;
151
+ }
152
+ if (json) return out(JSON.stringify(store.cart.map((l) => ({ id: l.p.id, name: l.p.name, q: l.q, price: l.p.price })), null, 2));
153
+ if (!store.cart.length) return out(dim("Корзина пуста — npx vkusvill-cli, чтобы набрать"));
154
+ const nameW = Math.max(20, width() - 30);
155
+ for (const l of store.cart) out(`${padEnd(l.p.name, nameW)} ${padStart(qtyText(l.p, l.q), 8)} ${bold(padStart(rub(lineTotal(l.p, l.q)), 10))}`);
156
+ out(`${padEnd("Итого", nameW + 9)} ${lime(bold(padStart(rub(store.total), 10)))}`);
157
+ out(dim("vv cart link — ссылка и QR-код"));
158
+ }
159
+
package/src/image.js ADDED
@@ -0,0 +1,144 @@
1
+ // Product and recipe photos: WebP → RGBA (wasm, no system libraries) → pixels sized to terminal cells.
2
+ // One pixel is half a cell: the top colour is the ▀ glyph, the bottom one is the cell background.
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { createRequire } from "node:module";
6
+ import { diskGet, diskPut } from "./api.js";
7
+ import { safeUrl } from "./safe.js";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const MAX_BYTES = 8 * 1024 * 1024; // larger downloads are refused
11
+ const MAX_PIXELS = 25e6; // larger decoded images are refused
12
+ const KEEP_WIDTH = 480; // decoded images are shrunk right away: we never draw wider than this
13
+ const DECODED_LIMIT = 40;
14
+ const RESIZED_LIMIT = 300;
15
+
16
+ let decoder = null;
17
+ function getDecoder() {
18
+ decoder ??= (async () => {
19
+ const mod = await import("@jsquash/webp/decode.js");
20
+ const wasm = require.resolve("@jsquash/webp/codec/dec/webp_dec.wasm");
21
+ await mod.init(await WebAssembly.compile(await readFile(wasm)));
22
+ return mod.default;
23
+ })();
24
+ return decoder;
25
+ }
26
+
27
+ const decoded = new Map(); // url → { w, h, data } | Promise | Error
28
+ const resized = new Map(); // url|w|h|mode → { w, h, px }
29
+ let onLoad = () => {};
30
+ export function onImageLoad(fn) { onLoad = fn; }
31
+
32
+ async function fetchBytes(url) {
33
+ const hit = await diskGet("img", url, 30 * 24 * 3600e3);
34
+ if (hit) return Buffer.from(hit, "base64");
35
+ const res = await fetch(url, { signal: AbortSignal.timeout(15e3) });
36
+ if (!res.ok) throw new Error(`photo ${res.status}`);
37
+ if (!safeUrl(res.url, "image")) throw new Error("photo redirected off VkusVill");
38
+ if (+(res.headers.get("content-length") ?? 0) > MAX_BYTES) throw new Error("photo too large");
39
+ const buf = Buffer.from(await res.arrayBuffer());
40
+ if (buf.length > MAX_BYTES) throw new Error("photo too large");
41
+ diskPut("img", url, buf.toString("base64"));
42
+ return buf;
43
+ }
44
+
45
+ // Box-filter downscale of RGBA to at most KEEP_WIDTH wide, alpha flattened onto white
46
+ function shrink({ width, height, data }) {
47
+ const s = Math.min(1, KEEP_WIDTH / width);
48
+ const w = Math.max(1, Math.round(width * s)), h = Math.max(1, Math.round(height * s));
49
+ const out = new Uint8ClampedArray(w * h * 4);
50
+ for (let y = 0; y < h; y++) {
51
+ const y0 = Math.floor((y * height) / h), y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / h));
52
+ for (let x = 0; x < w; x++) {
53
+ const x0 = Math.floor((x * width) / w), x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / w));
54
+ let r = 0, g = 0, b = 0, n = 0;
55
+ for (let sy = y0; sy < y1; sy++) for (let sx = x0; sx < x1; sx++) {
56
+ const i = (sy * width + sx) * 4, a = data[i + 3] / 255;
57
+ r += data[i] * a + 255 * (1 - a); g += data[i + 1] * a + 255 * (1 - a); b += data[i + 2] * a + 255 * (1 - a); n++;
58
+ }
59
+ const o = (y * w + x) * 4;
60
+ out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n; out[o + 3] = 255;
61
+ }
62
+ }
63
+ return { w, h, data: out };
64
+ }
65
+
66
+ async function load(url) {
67
+ const decode = await getDecoder();
68
+ const buf = await fetchBytes(url);
69
+ const img = await decode(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
70
+ if (img.width * img.height > MAX_PIXELS) throw new Error("photo too large");
71
+ return shrink(img);
72
+ }
73
+
74
+ // Bounding box of the content inside a plain light margin: the product gets bigger on a dark screen
75
+ function contentBox(img) {
76
+ const { w, h, data } = img;
77
+ const light = (i) => data[i] > 238 && data[i + 1] > 238 && data[i + 2] > 238;
78
+ let x0 = w, y0 = h, x1 = -1, y1 = -1;
79
+ for (let y = 0; y < h; y += 2) for (let x = 0; x < w; x += 2) {
80
+ if (!light((y * w + x) * 4)) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
81
+ }
82
+ if (x1 < 0) return { x: 0, y: 0, w, h };
83
+ const pad = Math.round(Math.max(x1 - x0, y1 - y0) * 0.06);
84
+ x0 = Math.max(0, x0 - pad); y0 = Math.max(0, y0 - pad); x1 = Math.min(w - 1, x1 + pad); y1 = Math.min(h - 1, y1 + pad);
85
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
86
+ }
87
+
88
+ function remember(map, key, value, limit) {
89
+ map.delete(key);
90
+ map.set(key, value);
91
+ while (map.size > limit) map.delete(map.keys().next().value);
92
+ }
93
+
94
+ // Pixels for a maxW × maxH cell area; mode is "contain" (fit inside) or "cover" (fill, cropping).
95
+ // Returns null while loading, { error: true } on failure.
96
+ export function image(url, maxW, maxH, { mode = "contain", trim = true } = {}) {
97
+ if (!url || maxW < 2 || maxH < 1) return null;
98
+ if (!safeUrl(url, "image")) return { error: true };
99
+ const rkey = `${url}|${maxW}|${maxH}|${mode}`;
100
+ if (resized.has(rkey)) return resized.get(rkey);
101
+ const d = decoded.get(url);
102
+ if (!d) {
103
+ const p = load(url).then(
104
+ (img) => { remember(decoded, url, img, DECODED_LIMIT); onLoad(); },
105
+ (e) => { remember(decoded, url, e instanceof Error ? e : new Error(String(e)), DECODED_LIMIT); onLoad(); },
106
+ );
107
+ decoded.set(url, p);
108
+ return null;
109
+ }
110
+ if (d instanceof Promise) return null;
111
+ if (d instanceof Error) return { error: true };
112
+ const box = trim ? contentBox(d) : { x: 0, y: 0, w: d.w, h: d.h };
113
+ const pxH = maxH * 2;
114
+ let tw, th, sx = box.x, sy = box.y, sw = box.w, sh = box.h;
115
+ if (mode === "cover") {
116
+ tw = maxW; th = pxH;
117
+ const ar = tw / th;
118
+ if (sw / sh > ar) { const nw = sh * ar; sx += (sw - nw) / 2; sw = nw; } else { const nh = sw / ar; sy += (sh - nh) / 2; sh = nh; }
119
+ } else {
120
+ const s = Math.min(maxW / sw, pxH / sh);
121
+ tw = Math.max(1, Math.round(sw * s)); th = Math.max(2, Math.round(sh * s));
122
+ if (th % 2) th++;
123
+ }
124
+ const px = new Int32Array(tw * th);
125
+ const data = d.data, W = d.w;
126
+ // Average over the source area of every target pixel (sampled, up to 4×4 points)
127
+ for (let ty = 0; ty < th; ty++) {
128
+ const ya = sy + (ty * sh) / th, yb = sy + ((ty + 1) * sh) / th;
129
+ for (let tx = 0; tx < tw; tx++) {
130
+ const xa = sx + (tx * sw) / tw, xb = sx + ((tx + 1) * sw) / tw;
131
+ let r = 0, g = 0, b = 0, n = 0;
132
+ const stepY = Math.max(1, Math.floor((yb - ya) / 4)), stepX = Math.max(1, Math.floor((xb - xa) / 4));
133
+ for (let y = Math.floor(ya); y < Math.max(Math.floor(ya) + 1, Math.ceil(yb)); y += stepY)
134
+ for (let x = Math.floor(xa); x < Math.max(Math.floor(xa) + 1, Math.ceil(xb)); x += stepX) {
135
+ const i = (Math.min(d.h - 1, y) * W + Math.min(W - 1, x)) * 4;
136
+ r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
137
+ }
138
+ px[ty * tw + tx] = ((r / n) << 16) | ((g / n) << 8) | (b / n);
139
+ }
140
+ }
141
+ const out = { w: tw, h: th, px };
142
+ remember(resized, rkey, out, RESIZED_LIMIT);
143
+ return out;
144
+ }