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/LICENSE +21 -0
- package/README.md +91 -0
- package/README.ru.md +89 -0
- package/bin/vv.js +12 -0
- package/package.json +39 -0
- package/src/api.js +228 -0
- package/src/app.js +328 -0
- package/src/cli.js +159 -0
- package/src/image.js +144 -0
- package/src/main.js +113 -0
- package/src/product.js +135 -0
- package/src/safe.js +37 -0
- package/src/store.js +190 -0
- package/src/sys.js +36 -0
- package/src/term/input.js +131 -0
- package/src/term/screen.js +204 -0
- package/src/term/width.js +74 -0
- package/src/ui/draw.js +324 -0
- package/src/ui/product-ui.js +192 -0
- package/src/ui/theme.js +47 -0
- package/src/ui/widgets.js +236 -0
- package/src/views/browser.js +251 -0
- package/src/views/cart.js +306 -0
- package/src/views/catalog.js +150 -0
- package/src/views/overlays.js +324 -0
- package/src/views/product.js +308 -0
- package/src/views/profile.js +228 -0
- package/src/views/recipes.js +358 -0
- package/src/views/shops.js +396 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Entry point: parses arguments and starts either the full-screen interface or a plain-output command.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { App } from "./app.js";
|
|
5
|
+
import { catalogView, dealsView } from "./views/catalog.js";
|
|
6
|
+
import { recipesView } from "./views/recipes.js";
|
|
7
|
+
import { shopsView } from "./views/shops.js";
|
|
8
|
+
import { cartView } from "./views/cart.js";
|
|
9
|
+
import { profileView } from "./views/profile.js";
|
|
10
|
+
import { Splash, Help, Palette, Barcode, CompareView, analogsView } from "./views/overlays.js";
|
|
11
|
+
import * as cli from "./cli.js";
|
|
12
|
+
|
|
13
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
|
+
|
|
15
|
+
export const VIEWS = {
|
|
16
|
+
catalog: catalogView,
|
|
17
|
+
deals: dealsView,
|
|
18
|
+
recipes: recipesView,
|
|
19
|
+
shops: shopsView,
|
|
20
|
+
cart: cartView,
|
|
21
|
+
profile: profileView,
|
|
22
|
+
palette: (app) => app.overlay(new Palette(app)),
|
|
23
|
+
help: (app) => app.overlay(new Help()),
|
|
24
|
+
barcode: (app) => app.overlay(new Barcode(app)),
|
|
25
|
+
analogs: analogsView,
|
|
26
|
+
compare: (app) => app.push(new CompareView(app)),
|
|
27
|
+
searchCatalog: (app, q) => {
|
|
28
|
+
app.stacks.catalog.length = 1;
|
|
29
|
+
app.switchTab("catalog");
|
|
30
|
+
app.stacks.catalog[0].search(q);
|
|
31
|
+
},
|
|
32
|
+
searchRecipes: (app, q) => {
|
|
33
|
+
app.stacks.recipes.length = 1;
|
|
34
|
+
app.switchTab("recipes");
|
|
35
|
+
const v = app.stacks.recipes[0];
|
|
36
|
+
v.input.set(q);
|
|
37
|
+
v.setQuery(q);
|
|
38
|
+
v.focus = "list";
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const HELP = `
|
|
43
|
+
ВкусВилл в терминале · vkusvill-cli ${"$VERSION"}
|
|
44
|
+
|
|
45
|
+
npx vkusvill-cli полноэкранный интерфейс
|
|
46
|
+
npx vkusvill-cli <запрос> открыть интерфейс сразу с поиском
|
|
47
|
+
|
|
48
|
+
Команды с обычным выводом (для скриптов, --json — сырые данные):
|
|
49
|
+
vv search <запрос> поиск товаров [--sort price_asc|price_desc|rating|new] [--page N]
|
|
50
|
+
vv deals скидки по карте [--quantity — «от N штук»]
|
|
51
|
+
vv product <id> карточка товара
|
|
52
|
+
vv barcode <EAN-13> товар по штрихкоду
|
|
53
|
+
vv recipes <запрос> рецепты
|
|
54
|
+
vv shops [метро|город] магазины
|
|
55
|
+
vv cart корзина из интерфейса
|
|
56
|
+
vv cart add <id> [кол-во] положить товар в корзину
|
|
57
|
+
vv cart link ссылка на корзину ВкусВилла и QR-код
|
|
58
|
+
vv cart clear очистить корзину
|
|
59
|
+
|
|
60
|
+
Флаги: --json --no-splash --version --help
|
|
61
|
+
Окружение: VV_TOKEN (заказы и персональные скидки), VV_COLOR=256|truecolor, NO_COLOR
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const VALUE_FLAGS = new Set(["--sort", "--page"]);
|
|
65
|
+
const BOOL_FLAGS = new Set(["--json", "--quantity", "--no-splash", "--version", "--help"]);
|
|
66
|
+
const SORTS = ["popularity", "rating", "price_asc", "price_desc", "new"];
|
|
67
|
+
|
|
68
|
+
// Splits argv into positional words, boolean flags and flag values (`--page 2` or `--page=2`)
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const args = [], flags = new Set(), values = {};
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
let a = argv[i];
|
|
73
|
+
if (a === "-h") a = "--help";
|
|
74
|
+
if (a === "-v") a = "--version";
|
|
75
|
+
if (!a.startsWith("--")) { args.push(a); continue; }
|
|
76
|
+
const [name, inline] = a.split(/=(.*)/s, 2);
|
|
77
|
+
if (VALUE_FLAGS.has(name)) {
|
|
78
|
+
const v = inline ?? argv[++i];
|
|
79
|
+
if (v === undefined) throw new Error(`Флагу ${name} нужно значение`);
|
|
80
|
+
values[name.slice(2)] = v;
|
|
81
|
+
} else if (BOOL_FLAGS.has(name)) flags.add(name);
|
|
82
|
+
else throw new Error(`Неизвестный флаг ${name} — vv --help`);
|
|
83
|
+
}
|
|
84
|
+
return { args, flags, values };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function main(argv) {
|
|
88
|
+
const { args, flags, values } = parseArgs(argv);
|
|
89
|
+
if (flags.has("--version") || args[0] === "version") return console.log(VERSION);
|
|
90
|
+
if (flags.has("--help") || args[0] === "help") return console.log(HELP.replace("$VERSION", VERSION));
|
|
91
|
+
if (values.sort !== undefined && !SORTS.includes(values.sort)) throw new Error(`--sort: одно из ${SORTS.join(", ")}`);
|
|
92
|
+
if (values.page !== undefined && !/^[1-9]\d{0,4}$/.test(values.page)) throw new Error("--page: номер страницы, 1 и больше");
|
|
93
|
+
const json = flags.has("--json");
|
|
94
|
+
const cmd = args[0];
|
|
95
|
+
const commands = {
|
|
96
|
+
search: () => cli.search(args.slice(1).join(" "), { json, sort: values.sort, page: +(values.page ?? 1) }),
|
|
97
|
+
deals: () => cli.deals({ json, type: flags.has("--quantity") ? "quantity" : "card" }),
|
|
98
|
+
product: () => cli.product(args[1], { json }),
|
|
99
|
+
barcode: () => cli.barcode(args[1], { json }),
|
|
100
|
+
recipes: () => cli.recipes(args.slice(1).join(" "), { json }),
|
|
101
|
+
shops: () => cli.shops(args.slice(1).join(" "), { json }),
|
|
102
|
+
cart: () => cli.cart(args.slice(1), { json }),
|
|
103
|
+
};
|
|
104
|
+
if (commands[cmd]) return commands[cmd]();
|
|
105
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
106
|
+
if (args.length) return cli.search(args.join(" "), { json });
|
|
107
|
+
return console.log(HELP.replace("$VERSION", VERSION));
|
|
108
|
+
}
|
|
109
|
+
const app = new App({ views: VIEWS });
|
|
110
|
+
app.start();
|
|
111
|
+
if (args.length) VIEWS.searchCatalog(app, args.join(" "));
|
|
112
|
+
if (!flags.has("--no-splash") && !args.length && !process.env.VV_NO_SPLASH) app.overlay(new Splash(app));
|
|
113
|
+
}
|
package/src/product.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// MCP response item → a convenient model: price per 100 g, kcal and macros, composition flags, photos, properties.
|
|
2
|
+
// Flags are heuristics over the first manufacturer's ingredients; in JS \b doesn't see Cyrillic, hence the (?<![а-яё]) boundaries.
|
|
3
|
+
|
|
4
|
+
import { cleanDeep } from "./safe.js";
|
|
5
|
+
|
|
6
|
+
const W = "(?<![а-яё])";
|
|
7
|
+
export const RX = {
|
|
8
|
+
sugar: /сахар(?!озаменит)|сироп|(?<![а-яё])мед(?![а-яё])|(?<![а-яё])мёд|фруктоз|глюкоз|декстроз|патока|сгущ|инверт/iu,
|
|
9
|
+
noSugar: /без (доб\.?|добавленного )?сахара/iu,
|
|
10
|
+
gluten: new RegExp(`пшени|глютен|ячмен|ржан|${W}рож|овсян|овёс|овес|солод|манн|булгур|кускус|полб|спельт|сухар|панир`, "iu"),
|
|
11
|
+
glutenFree: /без глютена/iu,
|
|
12
|
+
lactose: /молок|молоч|сливк|сливоч|творог|сыр(?!ь)|кефир|йогурт|сыворот|лактоз|казеин/iu,
|
|
13
|
+
lactoseFree: /безлактоз|без лактозы|безмолочн|растительн/iu,
|
|
14
|
+
additives: /(?<![a-zа-яё])[EЕ] ?-?\d{3}[a-z]?|загустител[ьи]|стабилизатор[ы]?|эмульгатор[ы]?|консервант[ы]?|ароматизатор[ы]?|краситель|регулятор[ы]? кислотности|модифицированн[а-я]+ крахмал|усилител[ья] вкуса/giu,
|
|
15
|
+
palm: /пальмов/iu,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const decode = (s = "") =>
|
|
19
|
+
String(s).replace(/ /g, " ").replace(/"/g, '"').replace(/«/g, "«").replace(/»/g, "»")
|
|
20
|
+
.replace(/—/g, "—").replace(/–/g, "–").replace(/�?39;/g, "'").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
21
|
+
.replace(/\u00a0/g, " ");
|
|
22
|
+
|
|
23
|
+
const stripTags = (s) => decode(String(s).replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")).replace(/[ \t]+/g, " ").trim();
|
|
24
|
+
|
|
25
|
+
// «ООО "А": текст;<br>ООО "Б": текст» → the first manufacturer's text, without its name
|
|
26
|
+
export function firstMaker(value = "") {
|
|
27
|
+
let part = String(value).split(/<br\s*\/?>/i)[0];
|
|
28
|
+
part = part.replace(/^\s*(ООО|АО|ЗАО|ИП|ОАО|ПАО)?[^:]{0,80}?"[^"]*"+\s*:/u, "");
|
|
29
|
+
part = part.replace(/^\s*[^:]{0,60}(ИП|ООО|АО)[^:]{0,40}:/u, "");
|
|
30
|
+
part = part.replace(/\s*Поставщики:.*$/su, "");
|
|
31
|
+
return stripTags(part).replace(/^[;.\s]+|[;.\s]+$/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nutrition(v) {
|
|
35
|
+
const num = (re) => { const m = v.match(re); return m ? parseFloat(m[1].replace(",", ".")) : null; };
|
|
36
|
+
return {
|
|
37
|
+
p: num(/белк[а-я]*\s*([\d.,]+)/iu), f: num(/жир[а-я]*\s*([\d.,]+)/iu), c: num(/углевод[а-я]*\s*([\d.,]+)/iu),
|
|
38
|
+
kcal: num(/([\d.,]+)\s*ккал/iu), sugars: num(/сахар[а-я() ]*[-–—]?\s*([\d.,]+)/iu),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Name: a trailing weight («Творог 5%, 180 г») is split off for compact rows
|
|
43
|
+
function splitName(name) {
|
|
44
|
+
const m = /^(.*?),?\s+(\d[\d.,]*\s*(?:г|кг|мл|л|шт)\.?(?:\s*[×x]\s*\d+)?)\s*$/iu.exec(name);
|
|
45
|
+
return m && m[1].length > 2 ? { title: m[1], pack: m[2] } : { title: name, pack: "", whole: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalize(item) {
|
|
49
|
+
if (!item || item.__vv) return item;
|
|
50
|
+
const name = decode(item.name ?? "").trim();
|
|
51
|
+
const props = (item.properties ?? []).map((p) => ({ name: decode(p.name), raw: p.value, value: firstMaker(p.value) }));
|
|
52
|
+
const prop = (needle) => props.find((p) => p.name.includes(needle));
|
|
53
|
+
const comp = prop("Состав")?.value ?? "";
|
|
54
|
+
const nutr = nutrition(prop("ценность")?.value ?? "");
|
|
55
|
+
const w = item.weight ?? {};
|
|
56
|
+
const grams = item.unit === "кг" ? 1000 : Math.round((w.value || 0) * (w.unit === "кг" ? 1000 : 1));
|
|
57
|
+
const price = item.price?.current ?? 0;
|
|
58
|
+
const old = item.price?.old ?? null;
|
|
59
|
+
const compL = comp.toLowerCase().replace(/ё/g, "е");
|
|
60
|
+
const text = `${name.toLowerCase()} ${compL}`;
|
|
61
|
+
const adds = [...new Set((comp.match(RX.additives) || []).map((s) => s.toLowerCase().replace(/\s+/g, " ")))];
|
|
62
|
+
const { title, pack, whole } = splitName(name);
|
|
63
|
+
return {
|
|
64
|
+
__vv: true,
|
|
65
|
+
id: item.id,
|
|
66
|
+
name,
|
|
67
|
+
title,
|
|
68
|
+
pack: pack || (grams && whole && !/\d\s*(г|кг|мл|л)(?![а-яё])/iu.test(name) ? (grams >= 1000 ? `${+(grams / 1000).toFixed(2)} кг` : `${grams} г`) : ""),
|
|
69
|
+
slug: item.slug,
|
|
70
|
+
url: item.url ?? `https://vkusvill.ru/goods/${item.id}/`,
|
|
71
|
+
description: decode(item.description ?? "").trim(),
|
|
72
|
+
brand: item.brand ?? null,
|
|
73
|
+
price,
|
|
74
|
+
old: old && old > price ? old : null,
|
|
75
|
+
off: item.price?.discount_percent ? Math.round(item.price.discount_percent) : old && old > price ? Math.round((1 - price / old) * 100) : 0,
|
|
76
|
+
offInfo: item.price?.discount_info ?? null,
|
|
77
|
+
unit: item.unit ?? "шт",
|
|
78
|
+
grams,
|
|
79
|
+
per100: grams ? Math.round((price / grams) * 1000) / 10 : null,
|
|
80
|
+
rating: item.rating?.average ? Math.round(item.rating.average * 10) / 10 : 0,
|
|
81
|
+
votes: item.rating?.count ?? 0,
|
|
82
|
+
images: (item.images ?? []).map((i) => i.medium ?? i.small ?? i.large).filter(Boolean),
|
|
83
|
+
thumb: item.images?.[0]?.small ?? item.images?.[0]?.medium ?? null,
|
|
84
|
+
category: (item.category ?? []).map((c) => c.name).reverse(),
|
|
85
|
+
props,
|
|
86
|
+
comp,
|
|
87
|
+
...nutr,
|
|
88
|
+
flags: {
|
|
89
|
+
sugar: RX.sugar.test(compL) && !RX.noSugar.test(name),
|
|
90
|
+
gluten: RX.gluten.test(compL) && !RX.glutenFree.test(name),
|
|
91
|
+
lactose: RX.lactose.test(text) && !RX.lactoseFree.test(name),
|
|
92
|
+
additives: adds.length,
|
|
93
|
+
palm: RX.palm.test(compL),
|
|
94
|
+
},
|
|
95
|
+
additiveList: adds,
|
|
96
|
+
available: item.available_quantity ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Compact snapshot for the cart and history, without long texts
|
|
101
|
+
export function snapshot(p) {
|
|
102
|
+
return {
|
|
103
|
+
id: p.id, name: p.name, title: p.title, pack: p.pack, price: p.price, old: p.old, off: p.off, unit: p.unit, grams: p.grams,
|
|
104
|
+
url: p.url, thumb: p.thumb, images: (p.images ?? []).slice(0, 1), rating: p.rating, votes: p.votes, category: p.category,
|
|
105
|
+
p: p.p, f: p.f, c: p.c, kcal: p.kcal, flags: p.flags, per100: p.per100,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Snapshots come from the state file, which may be hand-edited: strings are cleaned, shapes enforced
|
|
110
|
+
export function fromSnapshot(raw) {
|
|
111
|
+
const s = cleanDeep(raw);
|
|
112
|
+
const strings = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === "string") : []);
|
|
113
|
+
return {
|
|
114
|
+
__vv: true, description: "", props: [], comp: "", additiveList: [], offInfo: null,
|
|
115
|
+
...s,
|
|
116
|
+
title: typeof s.title === "string" ? s.title : s.name,
|
|
117
|
+
images: strings(s.images),
|
|
118
|
+
category: strings(s.category),
|
|
119
|
+
flags: s.flags && typeof s.flags === "object" ? s.flags : {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const rub = (n) => (n == null ? "—" : `${Math.round(n).toLocaleString("ru-RU").replace(/\u00a0|\u202f/g, " ")} ₽`);
|
|
124
|
+
export const plural = (n, one, few, many) => {
|
|
125
|
+
const m10 = n % 10, m100 = n % 100;
|
|
126
|
+
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? few : many;
|
|
127
|
+
};
|
|
128
|
+
export const compactNum = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(".", ",")} млн` : n >= 1e4 ? `${Math.round(n / 1000)} тыс` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(".", ",")} тыс` : String(n));
|
|
129
|
+
|
|
130
|
+
export const qtyStep = (p) => (p.unit === "кг" ? 0.1 : 1);
|
|
131
|
+
export const qtyText = (p, q) => (p.unit === "кг" ? `${+q.toFixed(2)} кг` : `${q} шт`);
|
|
132
|
+
// Line cost: for items sold by weight, price per kg × kg
|
|
133
|
+
export const lineTotal = (p, q) => p.price * q;
|
|
134
|
+
// Line weight in grams, for the kcal and macro totals
|
|
135
|
+
export const lineGrams = (p, q) => (p.unit === "кг" ? q * 1000 : (p.grams || 0) * q);
|
package/src/safe.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Defense against untrusted data: anything from the network may contain terminal control characters
|
|
2
|
+
// (ESC, C1 codes such as \x9b, which is CSI) and bidi override characters that spoof
|
|
3
|
+
// the visible text. We clean on input and open outbound links only to known domains.
|
|
4
|
+
|
|
5
|
+
const UNSAFE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
6
|
+
|
|
7
|
+
export const clean = (s) => String(s ?? "").replace(UNSAFE, "").replace(/\t/g, " ");
|
|
8
|
+
|
|
9
|
+
// Recursively cleans every string in a response
|
|
10
|
+
export function cleanDeep(v, depth = 0) {
|
|
11
|
+
if (depth > 40) return null;
|
|
12
|
+
if (typeof v === "string") return clean(v);
|
|
13
|
+
if (Array.isArray(v)) return v.map((x) => cleanDeep(x, depth + 1));
|
|
14
|
+
if (v && typeof v === "object") {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [k, x] of Object.entries(v)) if (k !== "__proto__" && k !== "constructor" && k !== "prototype") out[clean(k)] = cleanDeep(x, depth + 1);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HOSTS = {
|
|
23
|
+
open: ["vkusvill.ru", "yandex.ru"],
|
|
24
|
+
image: ["vkusvill.ru"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// https only, and only known domains (subdomains included)
|
|
28
|
+
export function safeUrl(url, kind = "open") {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
if (u.protocol !== "https:" || u.username || u.password) return null;
|
|
32
|
+
const host = u.hostname.toLowerCase();
|
|
33
|
+
return HOSTS[kind].some((h) => host === h || host.endsWith("." + h)) ? u.href : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Persistent state: cart, favourites, history, recent searches and UI preferences.
|
|
2
|
+
// One JSON file in ~/.config/vv-cli/state.json, readable by the owner only.
|
|
3
|
+
//
|
|
4
|
+
// - Writes are atomic (temp file + rename) and debounced; exit and crash paths flush synchronously.
|
|
5
|
+
// - Everything read from disk is validated: a hand-edited or truncated file never crashes the app.
|
|
6
|
+
// - A file that cannot be parsed is kept as state.json.broken-<time> instead of being overwritten.
|
|
7
|
+
// - Another process (a second TUI, `vv cart add`) may change the file: the TUI watches it and reloads.
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync, watchFile, unwatchFile } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { snapshot, fromSnapshot, lineTotal, qtyStep } from "./product.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = 2;
|
|
15
|
+
const DIR = process.env.VV_CONFIG_DIR ?? path.join(process.env.XDG_CONFIG_HOME ?? (process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : path.join(homedir(), ".config")), "vv-cli");
|
|
16
|
+
const FILE = path.join(DIR, "state.json");
|
|
17
|
+
|
|
18
|
+
export const CART_MAX = 100;
|
|
19
|
+
export const QTY_MAX = 40;
|
|
20
|
+
const HISTORY_MAX = 60;
|
|
21
|
+
const QUERIES_MAX = 20;
|
|
22
|
+
const FAVS_MAX = 500;
|
|
23
|
+
|
|
24
|
+
const isId = (v) => Number.isInteger(v) && v > 0 && v < 1e9;
|
|
25
|
+
const clampQty = (q) => Math.min(QTY_MAX, Math.max(0.01, Math.round(q * 100) / 100));
|
|
26
|
+
|
|
27
|
+
function validProduct(p) {
|
|
28
|
+
return p && typeof p === "object" && isId(p.id) && typeof p.name === "string" && Number.isFinite(p.price);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class Store {
|
|
32
|
+
constructor() {
|
|
33
|
+
this.cart = []; // [{ p, q, at }] — `at` is when the price snapshot was taken
|
|
34
|
+
this.favs = [];
|
|
35
|
+
this.history = [];
|
|
36
|
+
this.queries = [];
|
|
37
|
+
this.prefs = {}; // UI state: last tab, filters, sorts
|
|
38
|
+
this.warning = null; // shown once by the TUI
|
|
39
|
+
this.saveError = null; // last failed write, shown by the TUI
|
|
40
|
+
this.listeners = new Set();
|
|
41
|
+
this.mtime = 0;
|
|
42
|
+
this.load();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
load() {
|
|
46
|
+
let raw;
|
|
47
|
+
try { raw = readFileSync(FILE, "utf8"); } catch { return; }
|
|
48
|
+
let s;
|
|
49
|
+
try {
|
|
50
|
+
s = JSON.parse(raw);
|
|
51
|
+
if (!s || typeof s !== "object") throw new Error("not an object");
|
|
52
|
+
} catch {
|
|
53
|
+
const backup = `${FILE}.broken-${Date.now()}`;
|
|
54
|
+
try { renameSync(FILE, backup); } catch {}
|
|
55
|
+
this.warning = `Файл состояния был повреждён — копия в ${backup}`;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
this.cart = (Array.isArray(s.cart) ? s.cart : [])
|
|
60
|
+
.filter((l) => validProduct(l?.p) && Number.isFinite(l.q) && l.q > 0)
|
|
61
|
+
.slice(0, CART_MAX)
|
|
62
|
+
.map((l) => ({ p: fromSnapshot(l.p), q: clampQty(l.q), at: Number.isFinite(l.at) ? l.at : now }));
|
|
63
|
+
this.favs = (Array.isArray(s.favs) ? s.favs : []).filter(validProduct).slice(0, FAVS_MAX).map(fromSnapshot);
|
|
64
|
+
this.history = (Array.isArray(s.history) ? s.history : []).filter(validProduct).slice(0, HISTORY_MAX).map(fromSnapshot);
|
|
65
|
+
this.queries = (Array.isArray(s.queries) ? s.queries : []).filter((q) => typeof q === "string" && q.length <= 100).slice(0, QUERIES_MAX);
|
|
66
|
+
this.prefs = s.prefs && typeof s.prefs === "object" && !Array.isArray(s.prefs) ? s.prefs : {};
|
|
67
|
+
try { this.mtime = statSync(FILE).mtimeMs; } catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
save() {
|
|
71
|
+
clearTimeout(this.timer);
|
|
72
|
+
this.pending = true;
|
|
73
|
+
this.timer = setTimeout(() => this.saveNow(), 200);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
saveNow() {
|
|
77
|
+
clearTimeout(this.timer);
|
|
78
|
+
this.pending = false;
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
81
|
+
const data = {
|
|
82
|
+
v: VERSION,
|
|
83
|
+
cart: this.cart.map((l) => ({ p: snapshot(l.p), q: l.q, at: l.at })),
|
|
84
|
+
favs: this.favs.map(snapshot),
|
|
85
|
+
history: this.history.map(snapshot),
|
|
86
|
+
queries: this.queries,
|
|
87
|
+
prefs: this.prefs,
|
|
88
|
+
};
|
|
89
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
90
|
+
writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
91
|
+
renameSync(tmp, FILE);
|
|
92
|
+
// Older versions created these with default permissions: tighten on every write
|
|
93
|
+
try { chmodSync(FILE, 0o600); chmodSync(DIR, 0o700); } catch {}
|
|
94
|
+
this.mtime = statSync(FILE).mtimeMs;
|
|
95
|
+
this.saveError = null;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// Surfaced by the TUI; a lost write must never be silent
|
|
98
|
+
this.saveError = e.message;
|
|
99
|
+
this.notify();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Reload when another process rewrites the file (polling: fs.watch is unreliable across platforms)
|
|
104
|
+
watch() {
|
|
105
|
+
watchFile(FILE, { interval: 1000 }, (cur) => {
|
|
106
|
+
// A pending local write wins: it is the newer intent and is about to land
|
|
107
|
+
if (!cur.mtimeMs || cur.mtimeMs === this.mtime || this.pending) return;
|
|
108
|
+
this.load();
|
|
109
|
+
this.notify();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
unwatch() { unwatchFile(FILE); }
|
|
114
|
+
|
|
115
|
+
notify() { for (const fn of this.listeners) fn(); }
|
|
116
|
+
|
|
117
|
+
changed() {
|
|
118
|
+
this.save();
|
|
119
|
+
this.notify();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ——— Preferences ———
|
|
123
|
+
|
|
124
|
+
pref(key, fallback) { return this.prefs[key] ?? fallback; }
|
|
125
|
+
setPref(key, value) { this.prefs[key] = value; this.save(); }
|
|
126
|
+
|
|
127
|
+
// ——— Cart ———
|
|
128
|
+
|
|
129
|
+
qty(id) { return this.cart.find((l) => l.p.id === id)?.q ?? 0; }
|
|
130
|
+
|
|
131
|
+
add(p, delta = qtyStep(p)) {
|
|
132
|
+
if (!validProduct(p) || !Number.isFinite(delta) || delta === 0) return false;
|
|
133
|
+
const line = this.cart.find((l) => l.p.id === p.id);
|
|
134
|
+
if (line) {
|
|
135
|
+
const q = Math.round((line.q + delta) * 100) / 100;
|
|
136
|
+
if (q < 0.01) this.cart = this.cart.filter((l) => l !== line);
|
|
137
|
+
else line.q = clampQty(q);
|
|
138
|
+
if (p.comp) { line.p = p; line.at = Date.now(); } // a fuller product refreshes the snapshot
|
|
139
|
+
} else if (delta > 0) {
|
|
140
|
+
if (this.cart.length >= CART_MAX) return false;
|
|
141
|
+
this.cart.push({ p, q: clampQty(delta), at: Date.now() });
|
|
142
|
+
}
|
|
143
|
+
this.changed();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fresh price data for a cart line
|
|
148
|
+
refresh(id, p) {
|
|
149
|
+
const line = this.cart.find((l) => l.p.id === id);
|
|
150
|
+
if (!line || !validProduct(p)) return false;
|
|
151
|
+
const changed = line.p.price !== p.price;
|
|
152
|
+
line.p = { ...line.p, ...p };
|
|
153
|
+
line.at = Date.now();
|
|
154
|
+
return changed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
stale(maxAge) { return this.cart.filter((l) => Date.now() - l.at > maxAge); }
|
|
158
|
+
|
|
159
|
+
remove(id) { this.cart = this.cart.filter((l) => l.p.id !== id); this.changed(); }
|
|
160
|
+
clearCart() { this.cart = []; this.changed(); }
|
|
161
|
+
|
|
162
|
+
get total() { return this.cart.reduce((s, l) => s + lineTotal(l.p, l.q), 0); }
|
|
163
|
+
get savings() { return this.cart.reduce((s, l) => s + (l.p.old ? (l.p.old - l.p.price) * l.q : 0), 0); }
|
|
164
|
+
get count() { return this.cart.length; }
|
|
165
|
+
|
|
166
|
+
// ——— Favourites and history ———
|
|
167
|
+
|
|
168
|
+
isFav(id) { return this.favs.some((p) => p.id === id); }
|
|
169
|
+
|
|
170
|
+
toggleFav(p) {
|
|
171
|
+
if (this.isFav(p.id)) this.favs = this.favs.filter((x) => x.id !== p.id);
|
|
172
|
+
else this.favs = [p, ...this.favs].slice(0, FAVS_MAX);
|
|
173
|
+
this.changed();
|
|
174
|
+
return this.isFav(p.id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
viewed(p) {
|
|
178
|
+
this.history = [p, ...this.history.filter((x) => x.id !== p.id)].slice(0, HISTORY_MAX);
|
|
179
|
+
this.save();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
rememberQuery(q) {
|
|
183
|
+
q = q.trim().slice(0, 100);
|
|
184
|
+
if (!q) return;
|
|
185
|
+
this.queries = [q, ...this.queries.filter((x) => x !== q)].slice(0, QUERIES_MAX);
|
|
186
|
+
this.save();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const store = new Store();
|
package/src/sys.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// System integration: open a link in the browser, copy to the clipboard.
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { safeUrl } from "./safe.js";
|
|
5
|
+
|
|
6
|
+
let onBlocked = () => {};
|
|
7
|
+
export function onUrlBlocked(fn) { onBlocked = fn; }
|
|
8
|
+
|
|
9
|
+
// Opens only https links to known hosts. No shell is involved: on Windows `cmd /c start` would
|
|
10
|
+
// interpret `&` and `|` inside the URL, so rundll32 gets the URL as a plain argument instead.
|
|
11
|
+
export function openUrl(url) {
|
|
12
|
+
const safe = safeUrl(url, "open");
|
|
13
|
+
if (!safe) { onBlocked(url); return false; }
|
|
14
|
+
const [cmd, args] =
|
|
15
|
+
process.platform === "darwin" ? ["open", [safe]]
|
|
16
|
+
: process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", safe]]
|
|
17
|
+
: ["xdg-open", [safe]];
|
|
18
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// System clipboard first; OSC 52 as a fallback (works over SSH in most terminals).
|
|
23
|
+
export function copy(text) {
|
|
24
|
+
const tryCmd = (cmd, args) => new Promise((resolve) => {
|
|
25
|
+
try {
|
|
26
|
+
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
27
|
+
p.on("error", () => resolve(false));
|
|
28
|
+
p.on("close", (code) => resolve(code === 0));
|
|
29
|
+
p.stdin.end(text);
|
|
30
|
+
} catch { resolve(false); }
|
|
31
|
+
});
|
|
32
|
+
const osc = () => { if (process.stdout.isTTY) process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); return true; };
|
|
33
|
+
if (process.platform === "darwin") return tryCmd("pbcopy", []).then((ok) => ok || osc());
|
|
34
|
+
if (process.platform === "win32") return tryCmd("clip", []).then((ok) => ok || osc());
|
|
35
|
+
return tryCmd("wl-copy", []).then((ok) => ok || tryCmd("xclip", ["-selection", "clipboard"])).then((ok) => ok || osc());
|
|
36
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Terminal input parsing: keys, mouse (SGR), paste. Hotkeys also work with the Russian keyboard layout.
|
|
2
|
+
|
|
3
|
+
const RU = "йцукенгшщзхъфывапролджэячсмитьбюЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮё";
|
|
4
|
+
const EN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>`";
|
|
5
|
+
const RU2EN = new Map([...RU].map((c, i) => [c, EN[i]]));
|
|
6
|
+
|
|
7
|
+
const PASTE_MAX = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
const SEQ = {
|
|
10
|
+
"[A": "up", "[B": "down", "[C": "right", "[D": "left", "[H": "home", "[F": "end",
|
|
11
|
+
"OA": "up", "OB": "down", "OC": "right", "OD": "left", "OH": "home", "OF": "end",
|
|
12
|
+
"[1~": "home", "[4~": "end", "[7~": "home", "[8~": "end", "[2~": "insert", "[3~": "delete",
|
|
13
|
+
"[5~": "pageup", "[6~": "pagedown", "[Z": "backtab",
|
|
14
|
+
"OP": "f1", "OQ": "f2", "OR": "f3", "OS": "f4", "[11~": "f1", "[15~": "f5",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function key(name, extra = {}) {
|
|
18
|
+
return { type: "key", name, ch: null, ctrl: false, alt: false, shift: false, ...extra };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Input {
|
|
22
|
+
constructor(stdin, onEvent) {
|
|
23
|
+
this.stdin = stdin;
|
|
24
|
+
this.onEvent = onEvent;
|
|
25
|
+
this.buf = "";
|
|
26
|
+
this.pasting = null;
|
|
27
|
+
this.timer = null;
|
|
28
|
+
this.handler = (data) => this.feed(data.toString("utf8"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start() {
|
|
32
|
+
if (this.stdin.isTTY) this.stdin.setRawMode(true);
|
|
33
|
+
this.stdin.resume();
|
|
34
|
+
this.stdin.on("data", this.handler);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stop() {
|
|
38
|
+
this.stdin.off("data", this.handler);
|
|
39
|
+
if (this.stdin.isTTY) this.stdin.setRawMode(false);
|
|
40
|
+
this.stdin.pause();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
feed(s) {
|
|
44
|
+
clearTimeout(this.timer);
|
|
45
|
+
this.buf += s;
|
|
46
|
+
this.parse();
|
|
47
|
+
// A lone ESC is the Esc key if nothing arrives after it
|
|
48
|
+
if (this.buf === "\x1b") this.timer = setTimeout(() => { this.buf = ""; this.emit(key("escape")); }, 30);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
emit(ev) {
|
|
52
|
+
this.onEvent(ev);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
parse() {
|
|
56
|
+
while (this.buf.length) {
|
|
57
|
+
const b = this.buf;
|
|
58
|
+
if (this.pasting !== null) {
|
|
59
|
+
// Pasted text is capped: a runaway paste must not grow memory without bound
|
|
60
|
+
const end = b.indexOf("\x1b[201~");
|
|
61
|
+
if (end < 0) { this.pasting = (this.pasting + b).slice(0, PASTE_MAX); this.buf = ""; return; }
|
|
62
|
+
this.pasting = (this.pasting + b.slice(0, end)).slice(0, PASTE_MAX);
|
|
63
|
+
this.emit({ type: "paste", text: this.pasting });
|
|
64
|
+
this.pasting = null;
|
|
65
|
+
this.buf = b.slice(end + 6);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (b[0] === "\x1b") {
|
|
69
|
+
if (b.length === 1) return;
|
|
70
|
+
if (b.startsWith("\x1b[200~")) { this.pasting = ""; this.buf = b.slice(6); continue; }
|
|
71
|
+
// SGR mouse: ESC [ < b ; x ; y M|m
|
|
72
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(b);
|
|
73
|
+
if (m) {
|
|
74
|
+
this.buf = b.slice(m[0].length);
|
|
75
|
+
this.emit(mouse(+m[1], +m[2] - 1, +m[3] - 1, m[4] === "m"));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (b.startsWith("\x1b[<")) { if (/^\x1b\[<[\d;]*$/.test(b)) return; this.buf = b.slice(3); continue; }
|
|
79
|
+
// CSI with modifiers: ESC [ 1 ; 5 A
|
|
80
|
+
const csi = /^\x1b\[(\d*)(?:;(\d+))?([A-Za-z~])/.exec(b);
|
|
81
|
+
if (csi) {
|
|
82
|
+
this.buf = b.slice(csi[0].length);
|
|
83
|
+
const mod = csi[2] ? +csi[2] - 1 : 0;
|
|
84
|
+
const code = csi[3] === "~" ? `[${csi[1]}~` : `[${csi[3]}`;
|
|
85
|
+
const name = SEQ[code];
|
|
86
|
+
if (name) this.emit(key(name, { shift: !!(mod & 1), alt: !!(mod & 2), ctrl: !!(mod & 4) }));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (b[1] === "O" && b.length >= 3) {
|
|
90
|
+
this.buf = b.slice(3);
|
|
91
|
+
const name = SEQ["O" + b[2]];
|
|
92
|
+
if (name) this.emit(key(name));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (b[1] === "[" ) { if (/^\x1b\[[\d;]*$/.test(b)) return; this.buf = b.slice(2); continue; }
|
|
96
|
+
// Alt+character
|
|
97
|
+
const [ch] = [...b.slice(1)];
|
|
98
|
+
this.buf = b.slice(1 + ch.length);
|
|
99
|
+
if (ch === "\x1b") { this.emit(key("escape")); this.buf = "\x1b" + this.buf; continue; }
|
|
100
|
+
this.emit(printable(ch, { alt: true }));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const [ch] = [...b];
|
|
104
|
+
this.buf = b.slice(ch.length);
|
|
105
|
+
const c = ch.charCodeAt(0);
|
|
106
|
+
if (ch === "\r" || ch === "\n") this.emit(key("enter"));
|
|
107
|
+
else if (ch === "\t") this.emit(key("tab"));
|
|
108
|
+
else if (ch === "\x7f" || ch === "\b") this.emit(key("backspace"));
|
|
109
|
+
else if (c === 0) this.emit(key("space", { ctrl: true }));
|
|
110
|
+
else if (c < 27) this.emit(key(String.fromCharCode(c + 96), { ctrl: true }));
|
|
111
|
+
else if (c < 32) continue;
|
|
112
|
+
else this.emit(printable(ch));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function printable(ch, extra = {}) {
|
|
118
|
+
const hot = RU2EN.get(ch) ?? ch;
|
|
119
|
+
return { type: "key", name: ch === " " ? "space" : ch, ch, hot, ctrl: false, alt: false, shift: ch !== ch.toLowerCase(), ...extra };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mouse(code, x, y, release) {
|
|
123
|
+
const base = code & ~(4 | 8 | 16 | 32);
|
|
124
|
+
const drag = !!(code & 32);
|
|
125
|
+
let action;
|
|
126
|
+
if (code & 64) action = (code & 1) ? "wheeldown" : "wheelup";
|
|
127
|
+
else if (release) action = "up";
|
|
128
|
+
else if (drag) action = "drag";
|
|
129
|
+
else action = "down";
|
|
130
|
+
return { type: "mouse", action, button: base & 3, x, y, shift: !!(code & 4), ctrl: !!(code & 16) };
|
|
131
|
+
}
|