diff --git a/bun.lock b/bun.lock index b36186e..979b130 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@dnd-kit/abstract": "^0.2.4", "@dnd-kit/helpers": "^0.2.4", "@tanstack/match-sorter-utils": "^8.19.4", + "@types/semver": "^7.7.1", "@xterm/xterm": "^5.5.0", "@yume-chan/adb": "^2.6.0", "@yume-chan/adb-credential-web": "^2.1.0", @@ -22,6 +23,7 @@ "firebase": "^12.14.0", "idb": "^8.0.3", "mode-watcher": "^1.1.0", + "semver": "^7.8.4", "usb": "^2.17.0", "uuid": "^13.0.0", "xterm-addon-fit": "^0.8.0", @@ -54,7 +56,7 @@ "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", "storybook": "^10.3.5", - "svelte": "^5.55.2", + "svelte": "^5.56.3", "svelte-adapter-bun": "^1.0.1", "svelte-check": "^4.4.6", "svelte-sonner": "^1.1.0", @@ -504,6 +506,8 @@ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="], @@ -834,7 +838,7 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], @@ -862,7 +866,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], "svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="], @@ -1040,9 +1044,15 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="], + "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], "svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="], diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte index fa67d85..d668515 100644 --- a/src/lib/components/AnnouncementDialog.svelte +++ b/src/lib/components/AnnouncementDialog.svelte @@ -3,7 +3,7 @@ import { GlobalEventBus } from '$lib/core/utils/eventBus'; import { XIcon } from '@lucide/svelte/icons'; - export interface AnnouncementPayload { + interface AnnouncementPayload { title?: string; subtitle?: string; message: string; diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 8e71838..3e9bd9f 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -18,6 +18,7 @@ PlusCircle, ImageUp, Video, + MonitorPlay, Sun, Moon } from '@lucide/svelte/icons'; @@ -126,6 +127,12 @@ url: '/tools/adv-upload', icon: Video, requirePerm: '' + }, + { + title: 'Main & Brewing Video', + url: '/tools/video-mainpage', + icon: MonitorPlay, + requirePerm: '' } ] }, @@ -143,6 +150,12 @@ url: '/departments', icon: DollarSign, requirePerm: 'document.write.*' + }, + { + title: 'Price', + url: '/departments', + icon: FileSpreadsheet, + requirePerm: 'document.write.*' } ] } @@ -236,7 +249,13 @@ onclick={(e) => { if (nav.title === 'Sheet') { e.preventDefault(); - referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet'); + referenceFromPage.set( + sub.title === 'PriceSlot' + ? 'priceslot' + : sub.title === 'Price' + ? 'price' + : 'sheet' + ); goto(sub.url); } }} diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 419a0ff..8f500cd 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -192,7 +192,7 @@ async function connectWithRetry( 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. diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 5b3f579..6548d57 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -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([]); @@ -298,26 +301,76 @@ const handlers: Record 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 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 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 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 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); } diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 6b10414..90ce2d5 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -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([]); @@ -23,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) { } } +function waitForWsAuthReady(timeoutMs = 10000): Promise { + 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 { + 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; } diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index 6c114f5..4c6bec0 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -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 { return await sendCommandRequest('sheet', { country: country, @@ -19,27 +27,179 @@ export async function requestCatalogs(country: string): Promise { }); } -export async function requestPriceSlots(country: string): Promise { +/** + * 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 { return await sendCommandRequest('sheet', { country: country, - param: 'priceslot' + catalog: catalog, + catalog_name: catalogName, + param: 'add/catalog' }); } +export async function requestPriceSlots(country: string): Promise { + setPendingPriceSlotsCountry(country); + resetPriceSlotsCountry(country); + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function requestPriceSlot(country: string, slotNumber: number): Promise { + setPendingPriceSlotsCountry(country); + return requestPriceSlotOption(country, `PriceSlot${slotNumber}`); +} + +async function requestPriceSlotOption(country: string, option: string): Promise { + 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 { + 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 { - 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 { + 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 { + 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 { + 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 { @@ -188,9 +348,13 @@ export async function requestGenLayout(country: string): Promise { * 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 { +export async function requestSheetPrice( + country: string, + productCodes: string[], + force = false +): Promise { // 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 { + 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 }); diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index 88f930f..4fd8116 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -25,16 +25,304 @@ export interface PriceSlotProduct { row_index?: number; } +export interface PriceSlotServiceRow { + row_index?: number; + cells: Record; +} + export interface PriceSlot { slot: number; name: string; description: string; + kind?: 'price' | 'service'; + header?: string[]; products: PriceSlotProduct[]; + serviceRows?: PriceSlotServiceRow[]; } export const priceSlots = writable>({}); +export const priceSlotNamespaces = writable>({}); export const priceSlotsLoading = writable(false); export const priceSlotsError = writable(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>((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(); + 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(); + 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 = { 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; - 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; + // 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; + 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 = { +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 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>({}); // Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates) -export const sheetPriceAllRows = writable>>({}); +export const sheetPriceAllRows = writable< + Record> +>({}); // 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 = {}; // 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); } diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 7a71e7a..aae54f3 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -19,6 +19,7 @@ const ENABLE_WS_DEBUG: boolean = false; export const socketConnectionOfflineCount = writable(0); export const socketAlreadySendHeartbeat = writable(0); export const socketStore = writable(null); +export const wsAuthReady = writable(false); export const sharedKey = writable(null); @@ -54,6 +55,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise { }); } +export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise { + 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); }); diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts index 0803966..7cbe33f 100644 --- a/src/lib/core/utils/crypto.ts +++ b/src/lib/core/utils/crypto.ts @@ -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 }; } diff --git a/src/routes/(authed)/departments/+page.svelte b/src/routes/(authed)/departments/+page.svelte index 870ccc4..4601a64 100644 --- a/src/routes/(authed)/departments/+page.svelte +++ b/src/routes/(authed)/departments/+page.svelte @@ -28,6 +28,8 @@ if (refPage === 'priceslot') { await goto(`/sheet/priceslot/${cnt}`); + } else if (refPage === 'price') { + await goto(`/sheet/price/${cnt}`); } else if (refPage === 'sheet') { await goto(`/sheet/overview/${cnt}`); } else { @@ -38,7 +40,7 @@ // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); @@ -51,7 +53,7 @@ setTimeout(() => { // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte index a542a01..355e3a8 100644 --- a/src/routes/(authed)/recipe/material/+page.svelte +++ b/src/routes/(authed)/recipe/material/+page.svelte @@ -1,6 +1,6 @@ - @@ -461,21 +941,93 @@ {/if} -
- +
+
+ Recipe Source +
+
+ + + +
+ + + + Load Recipe From Server + Select a country to load material data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -501,31 +1053,60 @@ - {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - Create or update one MaterialSetting entry. The JSON preview shows the payload - before saving to Android. + Create or update one MaterialSetting entry. Server-loaded data is read-only until + ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - {#if existingMaterial} + {#if duplicateMaterialOnCreate} +
+ Material ID {form.id} already exists. Choose another ID before creating. +
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial} +
+ Material ID {form.id} already exists. Choose another ID before saving. +
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting. + Editing Material ID {editingMaterialId}.
{/if} +
+ + +
+
- + +

+ Generated from the highest existing ID in this type + 1. +

@@ -548,11 +1129,11 @@
- +
@@ -584,23 +1165,7 @@
-
-
- - -
+
@@ -620,21 +1185,6 @@

-
-
- - -
-
- - -
-
-
@@ -688,12 +1238,18 @@
- Cancel -
@@ -715,14 +1271,46 @@ + + + + Select Material Type + + Choose the type for the new material. Material ID will be generated from existing IDs in + that type + 1. + + + +
+ {#each selectableMaterialTypeOptions as option} + + {/each} +
+ + + + +
+
+
Existing Materials - - Use Edit to update a material, or Delete to remove it after confirmation. - +
- Loading materials from Android... + Loading materials...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
+ + + + Load Recipe From Server + Select a country to load topping data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -593,23 +889,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'} - - Switch between list items and groups. Edit/Delete actions are explicit per row. - +
- +
@@ -617,12 +913,14 @@
{#if activeTab === 'list'} - Add Topping {:else} - Add Group {/if}
@@ -631,19 +929,19 @@ {#if loading}
- Loading toppings from Android... + Loading toppings...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'} {#if filteredToppingList.length === 0}
No topping list items found.
{:else}