ebag 0.0.2

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/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # ebag
2
+
3
+ CLI for interacting with ebag.bg (unofficial).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g ebag
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx ebag --help
15
+ ```
16
+
17
+ ## Login
18
+
19
+ Get the cookie header from your browser and store it:
20
+
21
+ ```bash
22
+ ebag login --cookie "<cookie>"
23
+ ```
24
+
25
+ Notes:
26
+ - Session data is stored in plain text at `~/.config/ebag/session.json` with owner-only permissions (`0600`).
27
+ - List-based search caches product details in `~/.config/ebag/cache.json` for up to 6 hours.
28
+ - Completed orders may be cached locally for faster access.
29
+
30
+ ## Search
31
+
32
+ ```bash
33
+ ebag search "lindt"
34
+ ebag search "lindt" --json
35
+ ```
36
+
37
+ ## Delivery slots
38
+
39
+ ```bash
40
+ ebag slots
41
+ ```
42
+
43
+ ## Orders
44
+
45
+ ```bash
46
+ ebag order list
47
+ ebag order list --from 2024-02-01 --to 2024-03-31
48
+ ebag order show DE9CD146FECD05BF
49
+ ```
50
+
51
+ ## Cart
52
+
53
+ ```bash
54
+ ebag cart add 5128 --qty 1
55
+ ebag cart update 5128 --qty 2
56
+ ebag cart show
57
+ ```
58
+
59
+ ## Lists
60
+
61
+ ```bash
62
+ ebag list show
63
+ ebag list add 753250 5128 --qty 1
64
+ ebag list show 753250
65
+ ```
66
+
67
+ ## Contributing
68
+
69
+ ### Build
70
+
71
+ ```bash
72
+ npm run build
73
+ ```
74
+
75
+ ### End-to-end tests
76
+
77
+ By default, `npm run test:e2e` reads cookies from `tests/.secrets/ebag-cookies` (gitignored).
78
+ You can also override with `EBAG_COOKIE`.
79
+
80
+ Optional query override:
81
+
82
+ ```bash
83
+ EBAG_COOKIE="<cookie>" EBAG_TEST_QUERY="lindt" npm run test:e2e
84
+ ```
85
+ Store test cookies in `tests/.secrets/ebag-cookies` (gitignored), then run:
86
+
87
+ ```bash
88
+ npm run test:e2e
89
+ ```
@@ -0,0 +1,12 @@
1
+ import type { OrderDetail, OrderSummary, ProductSummary } from '../lib/types';
2
+ export declare function outputJson(data: unknown): void;
3
+ export declare function formatHeading(text: string): string;
4
+ export declare function outputProducts(products: ProductSummary[]): void;
5
+ export declare function outputList(items: {
6
+ id: number;
7
+ name: string;
8
+ count?: number;
9
+ }[]): void;
10
+ export declare function outputProductDetail(data: Record<string, unknown>): void;
11
+ export declare function outputOrdersList(orders: OrderSummary[]): void;
12
+ export declare function outputOrderDetail(detail: OrderDetail): void;
@@ -0,0 +1,354 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.outputJson = outputJson;
4
+ exports.formatHeading = formatHeading;
5
+ exports.outputProducts = outputProducts;
6
+ exports.outputList = outputList;
7
+ exports.outputProductDetail = outputProductDetail;
8
+ exports.outputOrdersList = outputOrdersList;
9
+ exports.outputOrderDetail = outputOrderDetail;
10
+ function outputJson(data) {
11
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
12
+ }
13
+ function formatHeading(text) {
14
+ if (process.stdout.isTTY && !process.env.NO_COLOR) {
15
+ return `\u001b[1m${text}\u001b[0m`;
16
+ }
17
+ return text;
18
+ }
19
+ function formatPrice(product) {
20
+ if (product.currentPrice)
21
+ return product.currentPrice;
22
+ if (product.pricePromo)
23
+ return product.pricePromo;
24
+ if (product.price)
25
+ return product.price;
26
+ return '';
27
+ }
28
+ function outputProducts(products) {
29
+ for (const product of products) {
30
+ const price = formatPrice(product);
31
+ let source = '';
32
+ if (product.source === 'list') {
33
+ const listNames = (product.listNames || []).filter(Boolean);
34
+ if (listNames.length) {
35
+ source = ` [${listNames.join(', ')}]`;
36
+ }
37
+ }
38
+ const line = `${product.id} ${product.name}${price ? ` - ${price}` : ''}${source}`;
39
+ process.stdout.write(`${line}\n`);
40
+ }
41
+ }
42
+ function outputList(items) {
43
+ for (const item of items) {
44
+ const count = item.count !== undefined ? ` (${item.count})` : '';
45
+ process.stdout.write(`${item.id} ${item.name}${count}\n`);
46
+ }
47
+ }
48
+ function outputProductDetail(data) {
49
+ const id = data.id ? String(data.id) : '';
50
+ const name = data.name ? String(data.name) : '';
51
+ const nameEn = data.name_en ? String(data.name_en) : '';
52
+ const brand = typeof data.brand === 'string'
53
+ ? data.brand
54
+ : data.brand?.name ||
55
+ data.brand
56
+ ?.name_bg ||
57
+ data.brand
58
+ ?.name_en ||
59
+ '';
60
+ const unit = data.unit_weight_text ? String(data.unit_weight_text) : '';
61
+ const price = data.current_price_eur ?? data.price_promo_eur ?? data.price_eur;
62
+ const priceText = price ? String(price) : '';
63
+ const currency = priceText ? 'EUR' : '';
64
+ const availability = data.is_available === false ? 'No' : data.is_available === true ? 'Yes' : '';
65
+ const urlSlug = data.url_slug ? String(data.url_slug) : '';
66
+ const origin = data.country_of_origin ? String(data.country_of_origin) : '';
67
+ const expiryRaw = data.expiry_date ? String(data.expiry_date) : '';
68
+ const expiry = formatDate(expiryRaw);
69
+ const description = data.description ? String(data.description) : '';
70
+ const kvPairs = [
71
+ ['ID', id],
72
+ ['Brand', brand],
73
+ ['Unit', unit],
74
+ ['Price', priceText ? `${priceText}${currency ? ` ${currency}` : ''}` : ''],
75
+ ['Available', availability],
76
+ ['Origin', origin],
77
+ ['Expiry', expiry],
78
+ ['Slug', urlSlug],
79
+ ];
80
+ const energyPairs = formatEnergyValues(data.energy_values);
81
+ const kvLines = [
82
+ ...kvPairs.filter(([, value]) => value),
83
+ ...energyPairs,
84
+ ].map(([key, value]) => `${key}: ${value}`);
85
+ const headerLines = [`# ${name || 'Product'}`, nameEn ? `*${nameEn}*` : ''].filter(Boolean);
86
+ const descriptionMd = description ? htmlToMarkdown(description) : '';
87
+ const { descriptionText, ingredientsText } = splitIngredients(descriptionMd);
88
+ const descriptionBlock = descriptionText ? `# Description\n\n${descriptionText}` : '';
89
+ const ingredientsBlock = ingredientsText ? `\n# Ingredients\n\n${ingredientsText}` : '';
90
+ const kvBlock = kvLines.length ? ['---', ...kvLines, '---'].join('\n') : '';
91
+ const output = [headerLines.join('\n'), kvBlock, descriptionBlock, ingredientsBlock]
92
+ .filter(Boolean)
93
+ .join('\n');
94
+ process.stdout.write(`${output}\n`);
95
+ }
96
+ function formatOrderAmount(order) {
97
+ if ('finalAmountEur' in order && order.finalAmountEur) {
98
+ return `${order.finalAmountEur} EUR`;
99
+ }
100
+ if ('finalAmount' in order && order.finalAmount) {
101
+ return order.finalAmount;
102
+ }
103
+ if ('totals' in order) {
104
+ if (order.totals.totalPaidEur)
105
+ return `${order.totals.totalPaidEur} EUR`;
106
+ if (order.totals.totalEur)
107
+ return `${order.totals.totalEur} EUR`;
108
+ if (order.totals.totalPaid)
109
+ return order.totals.totalPaid;
110
+ if (order.totals.total)
111
+ return order.totals.total;
112
+ }
113
+ return '';
114
+ }
115
+ function formatDateInTimeZone(date, timeZone) {
116
+ const parts = new Intl.DateTimeFormat('en-US', {
117
+ timeZone,
118
+ year: 'numeric',
119
+ month: '2-digit',
120
+ day: '2-digit',
121
+ }).formatToParts(date);
122
+ const lookup = new Map(parts.map((part) => [part.type, part.value]));
123
+ const year = lookup.get('year') || '';
124
+ const month = lookup.get('month') || '';
125
+ const day = lookup.get('day') || '';
126
+ return `${year}-${month}-${day}`;
127
+ }
128
+ function formatOrderStatus(order) {
129
+ const status = order.status;
130
+ if (status === 3)
131
+ return 'Отказана';
132
+ if (status === 4) {
133
+ const date = order.shippingDate ? formatDate(order.shippingDate) : '';
134
+ const todayDate = formatDateInTimeZone(new Date(), 'Europe/Sofia');
135
+ if (date && date >= todayDate)
136
+ return 'Нова';
137
+ return 'Завършена';
138
+ }
139
+ if (status !== undefined) {
140
+ return `Status ${status}`;
141
+ }
142
+ return '';
143
+ }
144
+ function outputOrdersList(orders) {
145
+ for (const order of orders) {
146
+ const date = order.shippingDate ? formatDate(order.shippingDate) : '';
147
+ const slot = order.timeSlotDisplay || '';
148
+ const status = formatOrderStatus(order);
149
+ const total = formatOrderAmount(order);
150
+ const parts = [order.id, date, slot].filter(Boolean);
151
+ const suffix = [status, total].filter(Boolean).join(' - ');
152
+ const line = `${parts.join(' ')}${suffix ? ` - ${suffix}` : ''}`;
153
+ process.stdout.write(`${line}\n`);
154
+ }
155
+ }
156
+ function outputOrderItems(items) {
157
+ const byGroup = new Map();
158
+ const ungrouped = [];
159
+ for (const item of items) {
160
+ if (item.group) {
161
+ const groupItems = byGroup.get(item.group) || [];
162
+ groupItems.push(item);
163
+ byGroup.set(item.group, groupItems);
164
+ }
165
+ else {
166
+ ungrouped.push(item);
167
+ }
168
+ }
169
+ const groupEntries = [...byGroup.entries()];
170
+ if (groupEntries.length) {
171
+ for (const [group, groupItems] of groupEntries) {
172
+ process.stdout.write(`## ${group}\n`);
173
+ for (const item of groupItems) {
174
+ const qty = item.quantity ? ` x${item.quantity}` : '';
175
+ const unit = item.unit ? ` (${item.unit})` : '';
176
+ const price = item.priceEur ? `${item.priceEur} EUR` : item.price || '';
177
+ const line = `- ${item.name}${unit}${qty}${price ? ` - ${price}` : ''}`;
178
+ process.stdout.write(`${line}\n`);
179
+ }
180
+ process.stdout.write('\n');
181
+ }
182
+ }
183
+ if (ungrouped.length) {
184
+ for (const item of ungrouped) {
185
+ const qty = item.quantity ? ` x${item.quantity}` : '';
186
+ const unit = item.unit ? ` (${item.unit})` : '';
187
+ const price = item.priceEur ? `${item.priceEur} EUR` : item.price || '';
188
+ const line = `- ${item.name}${unit}${qty}${price ? ` - ${price}` : ''}`;
189
+ process.stdout.write(`${line}\n`);
190
+ }
191
+ process.stdout.write('\n');
192
+ }
193
+ }
194
+ function outputOrderDetail(detail) {
195
+ const header = `# Order ${detail.id || ''}`.trim();
196
+ const status = formatOrderStatus(detail);
197
+ const date = detail.shippingDate ? formatDate(detail.shippingDate) : '';
198
+ const address = detail.address || '';
199
+ const total = formatOrderAmount(detail);
200
+ const kvPairs = [
201
+ ['ID', detail.id],
202
+ ['Status', status],
203
+ ['Date', date],
204
+ ['Timeslot', detail.timeSlotDisplay || ''],
205
+ ['Address', address],
206
+ ['Total', total],
207
+ ['Discount', detail.totals.discountEur ? `${detail.totals.discountEur} EUR` : detail.totals.discount || ''],
208
+ ['Tip', detail.totals.tipEur ? `${detail.totals.tipEur} EUR` : detail.totals.tip || ''],
209
+ ].filter(([, value]) => value);
210
+ const kvLines = kvPairs.map(([key, value]) => `${key}: ${value}`);
211
+ const kvBlock = kvLines.length ? ['---', ...kvLines, '---'].join('\n') : '';
212
+ process.stdout.write(`${header}\n`);
213
+ if (kvBlock) {
214
+ process.stdout.write(`${kvBlock}\n`);
215
+ }
216
+ process.stdout.write('# Items\n');
217
+ if (detail.items.length) {
218
+ outputOrderItems(detail.items);
219
+ }
220
+ else {
221
+ process.stdout.write('No items found.\n\n');
222
+ }
223
+ if (detail.additionalOrders && detail.additionalOrders.length) {
224
+ process.stdout.write('# Additional Orders\n');
225
+ for (const additional of detail.additionalOrders) {
226
+ process.stdout.write(`## Order ${additional.id}\n`);
227
+ const additionalTotal = formatOrderAmount(additional);
228
+ if (additionalTotal) {
229
+ process.stdout.write(`Total: ${additionalTotal}\n`);
230
+ }
231
+ if (additional.items.length) {
232
+ outputOrderItems(additional.items);
233
+ }
234
+ else {
235
+ process.stdout.write('No items found.\n\n');
236
+ }
237
+ }
238
+ }
239
+ }
240
+ function formatDate(value) {
241
+ if (!value)
242
+ return '';
243
+ const isoMatch = value.match(/(\d{4})-(\d{2})-(\d{2})/);
244
+ if (isoMatch)
245
+ return `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
246
+ const ymdAlt = value.match(/(\d{4})[./](\d{2})[./](\d{2})/);
247
+ if (ymdAlt)
248
+ return `${ymdAlt[1]}-${ymdAlt[2]}-${ymdAlt[3]}`;
249
+ const dmyMatch = value.match(/(\d{2})[./-](\d{2})[./-](\d{4})/);
250
+ if (dmyMatch)
251
+ return `${dmyMatch[3]}-${dmyMatch[2]}-${dmyMatch[1]}`;
252
+ const parsed = new Date(value);
253
+ if (Number.isNaN(parsed.getTime()))
254
+ return value;
255
+ const pad = (n) => String(n).padStart(2, '0');
256
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
257
+ }
258
+ function formatEnergyValues(value) {
259
+ if (!value)
260
+ return [];
261
+ if (Array.isArray(value)) {
262
+ const pairs = [];
263
+ for (const item of value) {
264
+ if (!item || typeof item !== 'object')
265
+ continue;
266
+ const entry = item;
267
+ const label = entry.name_bg ||
268
+ entry.name_en ||
269
+ entry.name;
270
+ const amount = entry.value ??
271
+ entry.amount ??
272
+ entry.quantity ??
273
+ entry.value_bg ??
274
+ entry.value_en ??
275
+ entry.energy;
276
+ if (!label || amount === undefined || amount === null)
277
+ continue;
278
+ pairs.push(...splitEnergyValues(`Energy ${label}`, String(amount)));
279
+ }
280
+ return pairs;
281
+ }
282
+ if (typeof value === 'object') {
283
+ return Object.entries(value)
284
+ .filter(([, entry]) => entry !== null && entry !== undefined)
285
+ .flatMap(([key, entry]) => splitEnergyValues(`Energy ${key}`, String(entry)));
286
+ }
287
+ return [];
288
+ }
289
+ function splitEnergyValues(label, raw) {
290
+ const normalized = normalizeNumberString(raw);
291
+ const kcalMatch = normalized.match(/(\d+(?:\.\d+)?)\s*kcal/i);
292
+ const kjMatch = normalized.match(/(\d+(?:\.\d+)?)\s*kJ/i);
293
+ const hasKcalKjLabel = /kcal/i.test(label) && /kJ/i.test(label);
294
+ const splitMatch = normalized.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
295
+ const baseLabel = label.replace(/\(?\s*kcal\s*\/\s*kJ\s*\)?/i, '').trim();
296
+ const energyLabel = baseLabel || label;
297
+ if (kcalMatch || kjMatch) {
298
+ const items = [];
299
+ if (kcalMatch)
300
+ items.push([`${energyLabel} kcal`, kcalMatch[1]]);
301
+ if (kjMatch)
302
+ items.push([`${energyLabel} kJ`, kjMatch[1]]);
303
+ return items;
304
+ }
305
+ if (splitMatch && hasKcalKjLabel) {
306
+ return [
307
+ [`${energyLabel} kcal`, splitMatch[1]],
308
+ [`${energyLabel} kJ`, splitMatch[2]],
309
+ ];
310
+ }
311
+ return [[label, normalized]];
312
+ }
313
+ function normalizeNumberString(value) {
314
+ if (typeof value === 'number')
315
+ return value.toString();
316
+ if (typeof value !== 'string')
317
+ return String(value);
318
+ const trimmed = value.trim().replace(/\u00a0/g, ' ');
319
+ const noSpaces = trimmed.replace(/\s+/g, '');
320
+ if (noSpaces.includes(',')) {
321
+ return noSpaces.replace(',', '.');
322
+ }
323
+ return noSpaces;
324
+ }
325
+ function htmlToMarkdown(html) {
326
+ let text = html;
327
+ text = text.replace(/<\s*br\s*\/?\s*>/gi, '\n');
328
+ text = text.replace(/<\s*\/p\s*>/gi, '\n\n');
329
+ text = text.replace(/<\s*p[^>]*>/gi, '');
330
+ text = text.replace(/<\s*li[^>]*>/gi, '- ');
331
+ text = text.replace(/<\s*\/li\s*>/gi, '\n');
332
+ text = text.replace(/<\s*ul[^>]*>/gi, '\n');
333
+ text = text.replace(/<\s*\/ul\s*>/gi, '\n');
334
+ text = text.replace(/<[^>]+>/g, '');
335
+ text = text.replace(/&nbsp;/g, ' ');
336
+ text = text.replace(/&amp;/g, '&');
337
+ text = text.replace(/&lt;/g, '<');
338
+ text = text.replace(/&gt;/g, '>');
339
+ text = text.replace(/\n{3,}/g, '\n\n');
340
+ return text.trim();
341
+ }
342
+ function splitIngredients(text) {
343
+ if (!text)
344
+ return { descriptionText: '', ingredientsText: '' };
345
+ const match = text.match(/(?:^|\n)\s*Съставки\s*:?\s*/i);
346
+ if (!match || match.index === undefined) {
347
+ return { descriptionText: text, ingredientsText: '' };
348
+ }
349
+ const start = match.index;
350
+ const end = start + match[0].length;
351
+ const descriptionText = text.slice(0, start).trim();
352
+ const ingredientsText = text.slice(end).trim();
353
+ return { descriptionText, ingredientsText };
354
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};