Merge branch 'master' into feature/edit-recipe-event-bus

This commit is contained in:
pakintada@gmail.com 2026-07-01 08:53:13 +07:00
commit 3625712678
28 changed files with 6414 additions and 783 deletions

View file

@ -192,7 +192,7 @@ async function connectWithRetry<T>(
export async function connnectViaWebUSB(connectAndroidServer = true) {
adbConnectionStatus.setConnecting();
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
logger.info('usb ok', globalThis.navigator.usb);
logger.info('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
if (device) {
logger.info('connect ', device.name);
@ -441,6 +441,15 @@ export async function executeCmd(command: string) {
}
}
export async function goToMachineHome() {
if (!getAdbInstance()) return;
try {
await executeCmd('input keyevent KEYCODE_HOME');
} catch (e) {
console.error('[goToMachineHome] error', e);
}
}
/**
* Execute an ADB command and stream its output via callbacks.
* Used for commands like `logcat` that run indefinitely.

View file

@ -22,11 +22,14 @@ import {
handleSheetStreamEnd,
handleSheetStreamError,
handleCatalogsResponse,
handlePriceSlotsResponse,
isPriceSlotsPayload,
handleListMenuResponse,
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
handleRawStreamEnd
handleRawStreamEnd,
handleSheetPriceResponse
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
@ -49,10 +52,10 @@ import { auth as authStore } from '../stores/auth';
import { v4 as uuidv4 } from 'uuid';
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
import { env } from '$env/dynamic/public';
import * as semver from 'semver';
import { WebCryptoHelper } from '../utils/crypto';
import { GlobalEventBus } from '../utils/eventBus';
import { handleIncomingRemoteShell } from '../stores/remoteShellStore';
import * as semver from 'semver';
export const messages = writable<string[]>([]);
@ -298,26 +301,76 @@ const handlers: Record<string, (payload: any) => void> = {
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
const content = p.content ?? p.value ?? p.payload;
const ref = p.ref ?? '';
if (target && currentUid && target === currentUid) {
if (!msg && p.content?.catalogs) {
handleCatalogsResponse(p.content);
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
console.log('[Sheet] Notify content received:', {
msg,
target,
currentUid,
contentKeys: content && typeof content === 'object' ? Object.keys(content) : [],
contentItems: Array.isArray(content) ? content.length : undefined
});
if (!target || (currentUid && target === currentUid)) {
if (!msg && content?.catalogs) {
handleCatalogsResponse(content);
addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`);
return;
}
if (
!msg &&
(content?.priceSlots ||
content?.priceslots ||
content?.price_slots ||
content?.slots ||
content?.param === 'priceslot' ||
content?.option === 'PriceSlot' ||
isPriceSlotsPayload(content))
) {
handlePriceSlotsResponse(content);
addNotification('INFO:Loaded PriceSlot data');
return;
}
if (!msg && ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
addNotification('INFO:Loaded sheet price data');
return;
}
// Handle streaming messages (with msg field)
switch (msg) {
case 'priceslot':
case 'price_slot':
handlePriceSlotsResponse(content);
addNotification('INFO:Loaded PriceSlot data');
break;
case 'start':
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
if (ref === 'price') {
addNotification('INFO:Sheet price streaming started');
} else {
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
}
break;
case 'chunk':
handleSheetStreamChunk(p);
if (isPriceSlotsPayload(content)) {
handlePriceSlotsResponse(content);
} else if (ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
} else {
handleSheetStreamChunk(p);
}
break;
case 'end':
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
if (ref === 'price') {
addNotification('INFO:Sheet price streaming complete');
} else {
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
}
break;
case 'error':
handleSheetStreamError(p);
@ -325,8 +378,17 @@ const handlers: Record<string, (payload: any) => void> = {
break;
default:
// Handle other content notifications from sheet-service
logger.info('[Sheet] Received content:', p.content);
logger.info('[Sheet] Received content:', {
contentItems: Array.isArray(content) ? content.length : undefined
});
}
} else {
console.warn('[Sheet] Ignored content because target does not match current user:', {
target,
currentUid,
msg,
contentItems: Array.isArray(content) ? content.length : undefined
});
}
return;
}
@ -404,6 +466,7 @@ const handlers: Record<string, (payload: any) => void> = {
country: current_meta?.country ?? '',
content: saved_product_code_to_get_from_sheet,
param: 'price',
option: 'price',
stream: true,
request_id
});
@ -481,14 +544,23 @@ const handlers: Record<string, (payload: any) => void> = {
// Header for price stream
handleRawStreamHeader('price', p);
},
raw_stream_priceslot: (p) => {
handleRawStreamHeader('priceslot', p);
},
raw_stream_chunk_price: (p) => {
// Chunk for price stream
handleRawStreamChunk('price', p);
},
raw_stream_chunk_priceslot: (p) => {
handleRawStreamChunk('priceslot', p);
},
raw_stream_end_price: (p) => {
// End for price stream
handleRawStreamEnd('price', p);
},
raw_stream_end_priceslot: (p) => {
handleRawStreamEnd('priceslot', p);
},
announce: (p) => {
// Server-pushed announcement (e.g., closing maintenance)
GlobalEventBus.emit('announce', p);
@ -499,13 +571,14 @@ const handlers: Record<string, (payload: any) => void> = {
}
};
export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) {
export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
const APP_VERSION = env.PUBLIC_APP_SEMVER;
const parsedMessage = JSON.parse(raw);
const ack: HandshakeAck = JSON.parse(raw);
// logger.info(`[WS MSG] type=${msg.type}`, msg.payload);
const ack: HandshakeAck = parsedMessage;
if (ack != null && ack.status === 'authenticated') {
// has server response
if (!clientPrivateKey) return;
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
@ -513,35 +586,40 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey: Cryp
return;
}
if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
// secured message decryption
let sharedKeyStore = get(sharedKey);
if (sharedKeyStore) {
let raw_payload = JSON.parse(raw);
let decrypted_string = await WebCryptoHelper.decryptMessage(
sharedKeyStore,
raw_payload.ciphertext,
raw_payload.iv
parsedMessage.ciphertext,
parsedMessage.iv
);
let actual_message: WSMessage = JSON.parse(decrypted_string);
if (actual_message.type !== 'heartbeat') {
// console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
}
handlers[actual_message.type]?.(actual_message.payload);
}
} else {
const msg: WSMessage = JSON.parse(raw);
const msg: WSMessage = parsedMessage;
if (msg.type !== 'heartbeat') {
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
}
if (msg == null) {
// error response
addNotification('ERR:No response from server');
return;
}
// raw streaming type
// if (msg.type.startsWith('raw_stream')) {
// // convert
// let sub_type = msg.type.replace('raw_stream_', '');
// msg.payload.sub_type = sub_type;
// msg.type = 'raw_stream';
// }
// raw streaming type
// if (msg.type.startsWith('raw_stream')) {
// // convert
// let sub_type = msg.type.replace('raw_stream_', '');
// msg.payload.sub_type = sub_type;
// msg.type = 'raw_stream';
// }
handlers[msg.type]?.(msg.payload);
}

View file

@ -1,12 +1,12 @@
import { logger } from '$lib/core/utils/logger';
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
import { sharedKey, socketStore } from '../stores/websocketStore';
import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
import { auth } from '../stores/auth';
import { WebCryptoHelper } from '../utils/crypto';
import * as semver from 'semver';
import { env } from '$env/dynamic/public';
import * as semver from 'semver';
export const queue = writable<string[]>([]);
@ -23,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) {
}
}
function waitForWsAuthReady(timeoutMs = 10000): Promise<boolean> {
if (get(wsAuthReady)) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(false);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(true);
});
});
}
// Websocket message wrapper for commands like `sheet`, `command`
export async function sendCommandRequest(target: CommandRequest, values: any): Promise<boolean> {
const authReady = await waitForWsAuthReady();
if (!authReady) {
console.warn('[WS Send] Skip command request because websocket auth is not ready', {
target,
param: values?.param
});
return false;
}
let srv_name = getServiceName(target);
let curr_user = get(auth);
@ -85,6 +117,13 @@ export async function sendMessage(
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
}
// console.log('[WS Send]', {
// type: logMessage.type,
// service: logMessage.payload?.srv_name,
// param: logMessage.payload?.values?.param,
// bytes: data.length,
// secured: isSecuredAppVersion(APP_VERSION)
// });
socket.send(data);
return true;
}

View file

@ -8,10 +8,18 @@ import {
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
setPendingProductCodesCountry
setPendingProductCodesCountry,
setPendingPriceSlotsCountry,
priceSlotsLoading,
resetPriceSlotsCountry
} from '../stores/sheetStore';
import type { PriceSlot } from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
type SheetCellUpdate = { value: string; coord: { row: number; col: number } };
type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] };
type SheetRowCreate = { header?: string[]; cells: string[] };
export async function requestCatalogs(country: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
country: country,
@ -19,27 +27,179 @@ export async function requestCatalogs(country: string): Promise<boolean> {
});
}
export async function requestPriceSlots(country: string): Promise<boolean> {
/**
* Register a newly created catalog as a Grist table so it shows in the overview
* and menus can be added to it. `catalog` is the .skt filename produced by
* /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt").
*/
export async function addCatalog(
country: string,
catalogName: string,
catalog: string
): Promise<boolean> {
return await sendCommandRequest('sheet', {
country: country,
param: 'priceslot'
catalog: catalog,
catalog_name: catalogName,
param: 'add/catalog'
});
}
export async function requestPriceSlots(country: string): Promise<boolean> {
setPendingPriceSlotsCountry(country);
resetPriceSlotsCountry(country);
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function requestPriceSlot(country: string, slotNumber: number): Promise<boolean> {
setPendingPriceSlotsCountry(country);
return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
}
async function requestPriceSlotOption(country: string, option: string): Promise<boolean> {
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
...data,
priceslot: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
priceSlotsLoading.set(true);
const values = {
country: country,
param: 'price',
option,
stream: true,
request_id
};
console.log('[sheetService] Sending PriceSlot request:', values);
const sent = await sendCommandRequest('sheet', values);
console.log('[sheetService] PriceSlot request sent:', sent);
if (!sent) {
priceSlotsLoading.set(false);
}
return sent;
}
export async function refreshPriceSlotList(country: string): Promise<boolean> {
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function updatePriceSlot(
country: string,
content: {
slot: number;
name: string;
description: string;
products: { product_code: string; price: number | null; row_index?: number }[];
}
slot: PriceSlot,
content: SheetRowUpdate[]
): Promise<boolean> {
return await sendCommandRequest('sheet', {
// console.log('[sheetService] Sending PriceSlot update:', {
// country,
// slot: slot.slot,
// name: slot.name,
// description: slot.description,
// kind: slot.kind,
// rows: content.length,
// param: 'update/price',
// option: `PriceSlot${slot.slot}`
// });
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/priceslot'
param: 'update/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot update sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlot(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
console.log('[sheetService] Sending PriceSlot create:', {
country,
slot: slot.slot,
name: slot.name,
description: slot.description,
kind: slot.kind,
rows: content.length,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot create sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlotRows(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
if (!content || content.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows add sent:', {
country,
slot: slot.slot,
rows: content.length,
sent
});
return sent;
}
export async function deletePriceSlotRows(
country: string,
slot: PriceSlot,
rowIds: number[]
): Promise<boolean> {
if (!rowIds || rowIds.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: rowIds.map((target_id) => ({ target_id })),
param: 'delete/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows delete sent:', {
country,
slot: slot.slot,
rows: rowIds.length,
sent
});
return sent;
}
export async function enterRoom(country: string, catalog: string): Promise<boolean> {
@ -188,9 +348,13 @@ export async function requestGenLayout(country: string): Promise<boolean> {
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
export async function requestSheetPrice(country: string, productCodes: string[]): Promise<boolean> {
export async function requestSheetPrice(
country: string,
productCodes: string[],
force = false
): Promise<boolean> {
// Check if already sent
if (hasSheetPriceBeenSent('price')) {
if (!force && hasSheetPriceBeenSent('price')) {
logger.warn('[sheetService] Price request already sent, skipping');
return false;
}
@ -232,6 +396,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
country: country,
content: content,
param: 'price',
option: 'price',
stream: true,
request_id
});
console.log('[sheetService] Sheet price request sent:', { country, request_id, sent });
if (sent) {
markSheetPriceAsSent('price');
} else {
sheetPriceLoading.set(false);
}
return sent;
}
export async function requestAllSheetPrice(country: string, force = false): Promise<boolean> {
if (!force && hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
...data,
price: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
sheetPriceLoading.set(true);
console.log('[sheetService] Sending all sheet price request:', { country, request_id });
const sent = await sendCommandRequest('sheet', {
country,
content: [],
param: 'price',
option: 'price',
stream: true,
request_id
});

View file

@ -25,16 +25,304 @@ export interface PriceSlotProduct {
row_index?: number;
}
export interface PriceSlotServiceRow {
row_index?: number;
cells: Record<string, string>;
}
export interface PriceSlot {
slot: number;
name: string;
description: string;
kind?: 'price' | 'service';
header?: string[];
products: PriceSlotProduct[];
serviceRows?: PriceSlotServiceRow[];
}
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
export const priceSlotNamespaces = writable<Record<string, PriceSlot[]>>({});
export const priceSlotsLoading = writable<boolean>(false);
export const priceSlotsError = writable<string | null>(null);
let pendingPriceSlotsCountry = '';
export function setPendingPriceSlotsCountry(country: string) {
pendingPriceSlotsCountry = country.toLowerCase();
}
export function resetPriceSlotsCountry(country: string) {
const key = country.toLowerCase();
priceSlots.update((data) => ({
...data,
[key]: []
}));
priceSlotNamespaces.update((data) => ({
...data,
[key]: []
}));
priceSlotsError.set(null);
}
function normalizePriceSlotProduct(product: any): PriceSlotProduct | null {
const cells = Array.isArray(product?.cells) ? product.cells : [];
const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value;
const productCode =
product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1);
if (!productCode) return null;
const priceValue =
product?.price ??
product?.Price ??
product?.value ??
product?.cash_price ??
product?.CashPrice ??
cellValue(5);
const price =
priceValue === '' || priceValue === undefined || priceValue === null
? null
: Number(priceValue);
return {
product_code: String(productCode),
name: String(
product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? ''
),
price: Number.isNaN(price) ? null : price,
row_index: product?.row_index ?? product?.row
};
}
function getPriceSlotHeader(slot: any): string[] {
const header = Array.isArray(slot?.header) ? slot.header : [];
return header.map((value: any) => String(value ?? '').trim());
}
function isServicePriceSlotHeader(header: string[]): boolean {
return header.some((value) => value.toLowerCase() === 'servicetype');
}
function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null {
const cells = Array.isArray(row?.cells) ? row.cells : [];
const mappedCells = header.reduce<Record<string, string>>((result, columnName, index) => {
if (!columnName) return result;
const value =
row?.[columnName] ??
row?.[columnName.replace(/\s+/g, '')] ??
cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ??
'';
result[columnName] = String(value ?? '');
return result;
}, {});
if (Object.values(mappedCells).every((value) => value === '')) return null;
return {
row_index: row?.row_index ?? row?.row,
cells: mappedCells
};
}
function normalizePriceSlot(slot: any, index: number): PriceSlot {
const sheetName = slot?.sheet ?? slot?.Sheet;
const displayName = slot?.name ?? slot?.title ?? sheetName;
const slotNumber = Number(
slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1
);
const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? [];
const header = getPriceSlotHeader(slot);
const isServiceSlot = isServicePriceSlotHeader(header);
const headerName = isServiceSlot ? header[12] : header[10];
const headerDescription = isServiceSlot ? header[13] : header[11];
const products = (Array.isArray(productsSource) ? productsSource : [])
.map(normalizePriceSlotProduct)
.filter((product): product is PriceSlotProduct => product !== null);
const serviceRows = isServiceSlot
? (Array.isArray(productsSource) ? productsSource : [])
.map((row) => normalizePriceSlotServiceRow(row, header))
.filter((row): row is PriceSlotServiceRow => row !== null)
: [];
return {
slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber,
name: String(
headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}`
),
description: String(headerDescription ?? ''),
kind: isServiceSlot ? 'service' : 'price',
header,
products: isServiceSlot ? [] : products,
serviceRows
};
}
function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot {
const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1);
const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber;
return {
slot,
name: sheetName || `PriceSlot${slot}`,
description: '',
kind: 'price',
header: [],
products: []
};
}
function getPriceSlotSource(content: any) {
return (
content?.priceSlots ??
content?.priceslots ??
content?.price_slots ??
content?.slots ??
content?.data ??
content?.value ??
content?.content ??
content
);
}
function getPriceSlotItems(content: any): any[] {
const source = getPriceSlotSource(content);
if (Array.isArray(source)) {
return source.flatMap((item) => {
if (Array.isArray(item?.sheet)) {
return item.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
return [item];
});
}
if (Array.isArray(source?.sheet)) {
return source.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source];
if (typeof source === 'object' && source) {
return Object.entries(source).map(([key, value]) => ({
...(typeof value === 'object' && value ? value : {}),
name: (value as any)?.name ?? key
}));
}
return [];
}
export function handlePriceSlotsResponse(content: any) {
console.log('[PriceSlot] Raw backend response:', {
items: Array.isArray(content) ? content.length : undefined,
keys:
content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : []
});
const country = String(
content?.country ?? content?.Country ?? pendingPriceSlotsCountry
).toLowerCase();
const source = getPriceSlotSource(content);
const slotList = getPriceSlotItems(content);
if (!country || slotList.length === 0) {
console.warn('[PriceSlot] No slot list found:', {
country,
sourceItems: Array.isArray(source) ? source.length : undefined
});
priceSlotsError.set('No PriceSlot data found in backend response');
priceSlotsLoading.set(false);
return;
}
const normalizedSlots = slotList.map((slot, index) =>
isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index)
);
if (normalizedSlots.length === 0) {
console.warn('[PriceSlot] Response did not include usable rows:', {
country,
slotListItems: slotList.length
});
return;
}
console.log('[PriceSlot] Normalized slots:', {
country,
slots: normalizedSlots.length,
firstSlot: normalizedSlots[0]
? {
slot: normalizedSlots[0].slot,
name: normalizedSlots[0].name,
kind: normalizedSlots[0].kind,
products: normalizedSlots[0].products.length,
serviceRows: normalizedSlots[0].serviceRows?.length ?? 0
}
: undefined
});
const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any));
if (loadedSlots.length > 0) {
priceSlots.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(slot.slot, slot);
}
for (const slot of loadedSlots) {
merged.set(slot.slot, slot);
}
return {
...data,
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
};
});
}
priceSlotNamespaces.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(slot.slot, slot);
}
for (const slot of normalizedSlots) {
merged.set(slot.slot, slot);
}
return {
...data,
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
};
});
priceSlotsError.set(null);
priceSlotsLoading.set(false);
}
export function isPriceSlotsPayload(content: any): boolean {
const source = getPriceSlotSource(content);
if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true;
if (Array.isArray(source?.sheet)) {
return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'));
}
if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot');
if (!Array.isArray(source)) return false;
return source.some(
(item) =>
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')))
);
}
function isPriceSlotNamespace(slot: any): slot is PriceSlot {
return (
typeof slot?.slot === 'number' &&
Array.isArray(slot?.products) &&
slot.products.length === 0 &&
Array.isArray(slot?.header) &&
slot.header.length === 0 &&
slot.name?.startsWith?.('PriceSlot')
);
}
export const countryPrimaryLanguageMap: Record<string, string> = {
THAI: 'Thai',
@ -79,11 +367,19 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
// Sheet column configuration by country for new_layout_v2
// Maps language keys to column indices and product code columns
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
language: Record<string, number>;
productCode: { hot: number; cold: number; blend: number };
primaryLanguage: string;
}> = {
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
string,
{
// Column→language map for the new-layout-v2 sheet (menu name/desc rows).
language: Record<string, number>;
// Column→language map for the name-desc-v2 sheet (Translations). Different
// namespace/sheet so the columns can differ from new-layout-v2; falls back
// to `language` when not set (countries where the two are identical).
nameDescLanguage?: Record<string, number>;
productCode: { hot: number; cold: number; blend: number };
primaryLanguage: string;
}
> = {
tha: {
language: { en: 3, th: 4, zh: 5, my: 8 },
productCode: { hot: 9, cold: 10, blend: 11 },
@ -91,6 +387,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
},
aus: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3, th: 4, ms: 7 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -101,11 +398,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
},
hkg: {
language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 },
nameDescLanguage: { en: 3, zh_hans: 4, zh_hant: 5 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'zh_hant'
},
ltu: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
nameDescLanguage: { en: 3, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'lt'
},
@ -131,6 +430,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
},
sgp: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -152,8 +452,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
};
export function getSheetColumnConfig(countryCode: string) {
return SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()]
|| SHEET_COLUMN_CONFIG_BY_COUNTRY.default;
return (
SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()] ||
SHEET_COLUMN_CONFIG_BY_COUNTRY.default
);
}
export function handleCatalogsResponse(content: CatalogsResponse) {
@ -305,10 +607,13 @@ export interface SheetPriceItem {
// Price sheet header name mappings by country
// Maps our field names to the actual header names in the sheet
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
cash_price: string[]; // Possible header names for cash price
non_cash_price: string[]; // Possible header names for non-cash price
}> = {
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
string,
{
cash_price: string[]; // Possible header names for cash price
non_cash_price: string[]; // Possible header names for non-cash price
}
> = {
tha: {
cash_price: ['Price'],
non_cash_price: ['MainPrice']
@ -367,7 +672,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
// Find column index from header array by matching header names
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
for (const name of possibleNames) {
const idx = headerArray.findIndex(h => h.toLowerCase() === name.toLowerCase());
const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase());
if (idx !== -1) {
// Return col index (header index + 1 because cells start from col 1)
return idx + 1;
@ -383,7 +688,9 @@ export const lastRequestSheetPrice = writable<Record<string, Record<string, Gris
export const sheetPriceHeader = writable<Record<string, string[]>>({});
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
export const sheetPriceAllRows = writable<Record<string, Record<string, { row: number; cells: GristCell[] }[]>>>({});
export const sheetPriceAllRows = writable<
Record<string, Record<string, { row: number; cells: GristCell[] }[]>>
>({});
// Helper function to get price value from cells using dynamic header lookup
export function getPriceFromCells(
@ -391,29 +698,43 @@ export function getPriceFromCells(
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
const colIdx = getPriceColumnIndex(country, priceType);
if (colIdx < 0) return null;
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
return priceCell?.value ?? null;
}
export function getPriceColumnIndex(
country: string,
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): number {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
return null;
return -1;
}
// Get possible header names for this country
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
const headerNames =
PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const possibleNames =
priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
// Find the column index for this price type
const colIdx = findHeaderIndex(headers, possibleNames);
//logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
if (colIdx < 0) {
logger.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
return null;
logger.warn(
`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
possibleNames
);
return -1;
}
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
//logger.info(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
return priceCell?.value ?? null;
return colIdx;
}
// Store for tracking streaming state
@ -445,15 +766,20 @@ export const streamingRawData = writable<
// Handler: raw_stream header (e.g., raw_stream_price)
export function handleRawStreamHeader(subtype: string, payload: any) {
logger.info(`[RawStream] Header for ${subtype}:`, payload);
let targetSubtype = subtype;
const currentData = get(streamingRawData);
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
logger.info(`[RawStream] Header for ${targetSubtype}:`, payload);
// Get existing stream data to preserve country from request
const currentData = get(streamingRawData);
const existingData = currentData[subtype];
const existingData = currentData[targetSubtype];
streamingRawData.update((data) => ({
...data,
[subtype]: {
[targetSubtype]: {
request_id: payload.request_id,
header: payload.header || payload.headers,
country: payload.country || existingData?.country || '',
@ -462,7 +788,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
}
}));
if (subtype === 'price') {
if (targetSubtype === 'price') {
sheetPriceStreamMeta.set({
request_id: payload.request_id,
country: payload.country || existingData?.country || '',
@ -474,10 +800,18 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
export function handleRawStreamChunk(subtype: string, payload: any) {
logger.info(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length);
const currentData = get(streamingRawData);
const streamData = currentData[subtype];
let targetSubtype = subtype;
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
console.log(
`[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
payload.raw?.length
);
const streamData = currentData[targetSubtype];
if (!streamData || streamData.request_id !== payload.request_id) {
logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
@ -489,7 +823,7 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
streamingRawData.update((data) => ({
...data,
[subtype]: {
[targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
rawParts: [...(streamData.rawParts || []), payload.raw]
@ -505,25 +839,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
streamingRawData.update((data) => ({
...data,
[subtype]: {
[targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
chunks: [...streamData.chunks, ...contentArray]
}
}));
logger.info(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`);
logger.info(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`);
}
// Handler: raw_stream end (e.g., raw_stream_end_price)
export function handleRawStreamEnd(subtype: string, payload: any) {
logger.info(`[RawStream] End payload for ${subtype}:`, payload);
const currentData = get(streamingRawData);
const streamData = currentData[subtype];
let targetSubtype = subtype;
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
console.log(`[RawStream] End payload for ${targetSubtype}:`, payload);
const streamData = currentData[targetSubtype];
if (!streamData || streamData.request_id !== payload.request_id) {
logger.warn(`[RawStream] End received for unknown stream: ${subtype}`);
logger.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
return;
}
@ -555,18 +894,41 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
}
}
logger.info(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
logger.info(
`[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}`
);
if (subtype === 'price') {
processSheetPriceData(country, streamData.header || [], chunks);
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
sheetPriceLoading.set(false);
if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
handlePriceSlotsResponse({ country, slots: chunks });
}
if (targetSubtype === 'priceslot') {
priceSlotsLoading.set(false);
}
if (targetSubtype === 'price') {
const looksLikePriceSlot = chunks.some((item) => {
return (
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) ||
item?.option === 'PriceSlot' ||
item?.param === 'priceslot'
);
});
if (looksLikePriceSlot) {
handlePriceSlotsResponse({ country, slots: chunks });
} else {
processSheetPriceData(country, streamData.header || [], chunks);
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
sheetPriceLoading.set(false);
}
}
// Clear the streaming data
streamingRawData.update((data) => {
const newData = { ...data };
delete newData[subtype];
delete newData[targetSubtype];
return newData;
});
}
@ -601,8 +963,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Find column indices dynamically from header
// product_code header is typically "ProductCode" or similar
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
logger.info(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
const productCodeIdx = findHeaderIndex(effectiveHeader, [
'ProductCode',
'Product_Code',
'product_code',
'Code'
]);
logger.info(
`[SheetPrice] productCodeIdx from header:`,
productCodeIdx,
'header:',
effectiveHeader
);
const priceByProductCode: Record<string, GristCell[]> = {};
// Track ALL rows per product code (for duplicates)
@ -703,7 +1075,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Log duplicates info
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
if (duplicates.length > 0) {
logger.info(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3));
logger.info(
`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`,
duplicates.slice(0, 3)
);
}
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
const sampleKey = Object.keys(priceByProductCode)[0];
@ -711,6 +1086,20 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
}
}
export function handleSheetPriceResponse(country: string, content: any) {
const resolvedCountry = country || get(streamingRawData).price?.country || '';
const chunks = Array.isArray(content) ? content : [content];
processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
sheetPriceLoading.set(false);
}
export function handleSheetPriceResponse(country: string, content: any) {
const resolvedCountry = country || get(streamingRawData).price?.country || '';
const chunks = Array.isArray(content) ? content : [content];
processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
sheetPriceLoading.set(false);
}
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);
@ -770,14 +1159,24 @@ export function loadProductCodesFromCache(country?: string): boolean {
// Only load if country matches (or no country filter specified)
if (data.codes && Array.isArray(data.codes)) {
if (country && data.country && data.country !== country) {
logger.info('[sheetStore] Cache is for different country:', data.country, '!= requested:', country);
logger.info(
'[sheetStore] Cache is for different country:',
data.country,
'!= requested:',
country
);
// Clear the store for different country
existingProductCodes.set(new Set());
return false;
}
existingProductCodes.set(new Set(data.codes));
currentProductCodesCountry = data.country || '';
logger.info('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown');
logger.info(
'[sheetStore] Loaded',
data.codes.length,
'product codes from cache for',
data.country || 'unknown'
);
return true;
}
}
@ -799,7 +1198,13 @@ export function clearProductCodes() {
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
// Use pending country if not in payload
const country = payload.country || pendingProductCodesCountry;
logger.info('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
logger.info(
'[sheetStore] Received list_menu_response for',
country,
':',
payload.codes?.length,
'codes'
);
if (payload && payload.codes) {
existingProductCodes.set(new Set(payload.codes));
@ -815,7 +1220,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
timestamp: Date.now()
})
);
logger.info('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
logger.info(
'[sheetStore] Saved',
payload.codes.length,
'product codes to cache for',
country
);
} catch (e) {
logger.warn('[sheetStore] Failed to save to cache:', e);
}

View file

@ -19,6 +19,7 @@ const ENABLE_WS_DEBUG: boolean = false;
export const socketConnectionOfflineCount = writable<number>(0);
export const socketAlreadySendHeartbeat = writable<number>(0);
export const socketStore = writable<WebSocket | null>(null);
export const wsAuthReady = writable<boolean>(false);
export const sharedKey = writable<CryptoKey | null>(null);
@ -54,6 +55,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
});
}
export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise<WebSocket | null> {
const openSocket = await waitForOpenSocket(timeoutMs);
if (!openSocket) return null;
if (get(wsAuthReady)) return openSocket;
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(null);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(openSocket);
});
});
}
export async function connectToWebsocket(id_token?: string) {
if (browser) {
// logger.info('connecting to ', env.PUBLIC_WSS);
@ -64,6 +90,7 @@ export async function connectToWebsocket(id_token?: string) {
let ws_url = env.PUBLIC_WSS;
socket = new WebSocket(ws_url);
wsAuthReady.set(false);
sharedKey.set(null);
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
@ -89,13 +116,16 @@ export async function connectToWebsocket(id_token?: string) {
sendAuthInfoInterval = setInterval(async () => {
if (get(sharedKey)) {
auth_data = get(authStore);
perms = get(permission);
// Debug: check if auth_data has uid
logger.info('[WS Auth] Sending auth info with:', {
uid: auth_data?.uid,
name: auth_data?.displayName,
email: auth_data?.email
email: auth_data?.email,
date: new Date()
});
await sendMessage({
const sent = await sendMessage({
type: 'auth',
payload: {
user: {
@ -106,9 +136,10 @@ export async function connectToWebsocket(id_token?: string) {
}
}
});
wsAuthReady.set(sent);
clearInterval(sendAuthInfoInterval);
}
}, 3000);
}, 2000);
}
logger.info(socket);
@ -161,10 +192,12 @@ export async function connectToWebsocket(id_token?: string) {
socket.addEventListener('close', () => {
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
socket = null;
clearInterval(socketCheck);
clearInterval(sendAuthInfoInterval);
if (auth.currentUser && !socket) {
logger.info('try reconnect websocket ...');
@ -179,6 +212,7 @@ export async function connectToWebsocket(id_token?: string) {
socket.addEventListener('error', (e) => {
// logger.info('WebSocket error: ', e);
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
clearInterval(socketCheck);
});

View file

@ -1,4 +1,14 @@
export class WebCryptoHelper {
private static bytesToBase64(bytes: Uint8Array) {
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
static async generateKeyPair() {
const keyPair = await window.crypto.subtle.generateKey(
{
@ -10,7 +20,7 @@ export class WebCryptoHelper {
);
const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic)));
const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
return { privateKey: keyPair.privateKey, publicKeyBase64 };
}
@ -60,8 +70,8 @@ export class WebCryptoHelper {
encodedText
);
const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer)));
const ivBase64 = btoa(String.fromCharCode(...iv));
const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
return { ciphertext: ciphertextBase64, iv: ivBase64 };
}