create branch dev and commit code

This commit is contained in:
thanawat saiyota 2026-06-09 10:50:59 +07:00
parent 3b70cc9fe8
commit ea68fa5cc4
44 changed files with 12421 additions and 214 deletions

View file

@ -0,0 +1,284 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
export const ANDROID_RECIPE_EXPORT_PATH = '/mnt/sdcard/recipe_export_all.tsv';
const ANDROID_RECIPE_EXPORT_CACHE_KEY = 'android_recipe_export_payload_v1';
const ANDROID_RECIPE_EXPORT_DB_NAME = 'android_recipe_export_cache';
const ANDROID_RECIPE_EXPORT_STORE_NAME = 'payloads';
export type AndroidRecipeExportPayload = {
content: string;
exportedAt?: number;
source?: string;
fileSizeBytes?: number;
lineCount?: number;
message?: string;
};
export type AndroidRecipeExportRow = {
lineNumber: number;
cells: string[];
values: Record<string, string>;
};
export type AndroidRecipeExportData = {
headers: string[];
rows: AndroidRecipeExportRow[];
lineCount: number;
};
export const androidRecipeExportPayload = writable<AndroidRecipeExportPayload | null>(null);
let deviceExportLoadPromise: Promise<void> | null = null;
function openAndroidRecipeExportDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(ANDROID_RECIPE_EXPORT_DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function readCachedPayloadFromIndexedDb(): Promise<AndroidRecipeExportPayload | null> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readonly');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
const request = store.get(ANDROID_RECIPE_EXPORT_CACHE_KEY);
request.onsuccess = () => resolve((request.result as AndroidRecipeExportPayload) ?? null);
request.onerror = () => reject(request.error);
transaction.oncomplete = () => db.close();
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
async function writeCachedPayloadToIndexedDb(payload: AndroidRecipeExportPayload): Promise<void> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readwrite');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
store.put(payload, ANDROID_RECIPE_EXPORT_CACHE_KEY);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
async function deleteCachedPayloadFromIndexedDb(): Promise<void> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readwrite');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
store.delete(ANDROID_RECIPE_EXPORT_CACHE_KEY);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
export async function loadCachedAndroidRecipeExport(): Promise<AndroidRecipeExportPayload | null> {
if (!browser) return null;
try {
const payload = await readCachedPayloadFromIndexedDb();
if (!payload?.content) return null;
androidRecipeExportPayload.set(payload);
return payload;
} catch (error) {
console.error('failed to load cached android recipe export from IndexedDB', error);
}
try {
const cached = localStorage.getItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
if (!cached) return null;
const payload = JSON.parse(cached) as AndroidRecipeExportPayload;
if (!payload?.content) return null;
androidRecipeExportPayload.set(payload);
void writeCachedPayloadToIndexedDb(payload);
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
return payload;
} catch (error) {
console.error('failed to load legacy android recipe export cache', error);
return null;
}
}
export function saveAndroidRecipeExportPayload(payload: AndroidRecipeExportPayload) {
androidRecipeExportPayload.set(payload);
if (!browser) return;
void writeCachedPayloadToIndexedDb(payload).catch((error) => {
console.error('failed to cache android recipe export', error);
});
}
export function clearCachedAndroidRecipeExport() {
androidRecipeExportPayload.set(null);
if (!browser) return;
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
void deleteCachedPayloadFromIndexedDb().catch((error) => {
console.error('failed to clear android recipe export cache', error);
});
}
function normalizeHeader(value: string, index: number): string {
const header = value.trim().replace(/^\uFEFF/, '');
return header || `Column ${index + 1}`;
}
function splitTsvLine(line: string): string[] {
return line.split('\t').map((cell) => cell.trim());
}
function collectNonEmptyLines(raw: string, maxLines: number): string[] {
const lines: string[] = [];
let lineStart = 0;
for (let index = 0; index <= raw.length; index += 1) {
const isEnd = index === raw.length;
const char = raw[index];
if (!isEnd && char !== '\n') continue;
const lineEnd = index > lineStart && raw[index - 1] === '\r' ? index - 1 : index;
const line = raw.slice(lineStart, lineEnd);
if (line.trim().length > 0) {
lines.push(line);
if (lines.length >= maxLines) break;
}
lineStart = index + 1;
}
return lines;
}
function buildUniqueHeaders(rawHeaders: string[], maxColumns: number): string[] {
const headers = [...rawHeaders];
for (let i = headers.length; i < maxColumns; i += 1) {
headers.push(`Column ${i + 1}`);
}
const seen = new Map<string, number>();
return headers.map((header, index) => {
const normalized = normalizeHeader(header, index);
const count = seen.get(normalized) ?? 0;
seen.set(normalized, count + 1);
return count === 0 ? normalized : `${normalized} ${count + 1}`;
});
}
export function parseAndroidRecipeExport(
raw: string,
maxRows = Number.POSITIVE_INFINITY
): AndroidRecipeExportData {
const maxLines = Number.isFinite(maxRows) ? Math.max(1, maxRows + 1) : Number.MAX_SAFE_INTEGER;
const lines = collectNonEmptyLines(raw, maxLines);
if (lines.length === 0) {
return {
headers: [],
rows: [],
lineCount: 0
};
}
const parsedLines = lines.map(splitTsvLine);
const maxColumns = Math.max(...parsedLines.map((line) => line.length));
const headers = buildUniqueHeaders(parsedLines[0], maxColumns);
const rows = parsedLines.slice(1).map((cells, index) => {
const paddedCells = [...cells];
for (let cellIndex = paddedCells.length; cellIndex < headers.length; cellIndex += 1) {
paddedCells.push('');
}
const values = Object.fromEntries(
headers.map((header, cellIndex) => [header, paddedCells[cellIndex] ?? ''])
);
return {
lineNumber: index + 2,
cells: paddedCells,
values
};
});
return {
headers,
rows,
lineCount: lines.length
};
}
export async function pullAndroidRecipeExport(timeoutMs = 15000): Promise<string> {
const adb = await import('$lib/core/adb/adb');
const instance = adb.getAdbInstance();
if (!instance) {
throw new Error('ADB device is not connected');
}
const content = await adb.pull(ANDROID_RECIPE_EXPORT_PATH, timeoutMs);
if (content === undefined) {
throw new Error(`Unable to pull ${ANDROID_RECIPE_EXPORT_PATH}`);
}
return content;
}
export async function loadAndroidRecipeExportFromDevice(
meta: Partial<AndroidRecipeExportPayload> = {}
): Promise<void> {
if (deviceExportLoadPromise) return deviceExportLoadPromise;
deviceExportLoadPromise = (async () => {
const content = await pullAndroidRecipeExport(30000);
saveAndroidRecipeExportPayload({
content,
exportedAt: meta.exportedAt ?? Date.now(),
source: meta.source ?? ANDROID_RECIPE_EXPORT_PATH,
fileSizeBytes: meta.fileSizeBytes,
lineCount: meta.lineCount,
message: meta.message
});
})().finally(() => {
deviceExportLoadPromise = null;
});
return deviceExportLoadPromise;
}

View file

@ -0,0 +1,251 @@
import { sendCommandRequest, sendMessage } from '../handlers/ws_messageSender';
import { get } from 'svelte/store';
import { auth } from '../stores/auth';
import {
productCodesLoading,
hasSheetPriceBeenSent,
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
setPendingProductCodesCountry
} from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
export function requestCatalogs(country: string): boolean {
return sendCommandRequest('sheet', {
country: country,
param: 'catalogs'
});
}
export function enterRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'enter'
});
}
export function sendHeartbeat(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'heartbeat'
});
}
export function exitRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'exit'
});
}
export function requestCatalogMenu(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'catalog/menu'
});
}
export function updateMenu(country: string, catalog: string, content: any[]): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'update/menu'
});
}
export function addMenu(country: string, catalog: string, content: any[]): boolean {
console.log('[sheetService] Adding menu:', { country, catalog, content });
const sent = sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'add/menu'
});
console.log('[sheetService] Add menu sent:', sent);
return sent;
}
export function deleteMenu(country: string, catalog: string, targetIds: number[]): boolean {
const content = targetIds.map((id) => ({ target_id: id }));
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'delete/menu'
});
}
export function swapMenu(
country: string,
catalog: string,
swaps: { source_id: number; target_id: number }[]
): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: swaps,
param: 'swap/menu'
});
}
export function requestListMenu(country: string, boxid?: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
if (curr_user) {
user_info = {
displayName: curr_user.displayName,
email: curr_user.email,
uid: curr_user.uid
};
}
productCodesLoading.set(true);
setPendingProductCodesCountry(country);
console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
return sendMessage({
type: 'list_menu',
payload: {
user_info,
country,
boxid: boxid || undefined
}
});
}
export function requestGenLayout(country: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
if (curr_user) {
user_info = {
displayName: curr_user.displayName,
email: curr_user.email,
uid: curr_user.uid
};
}
setGenLayoutGenerating();
console.log('[sheetService] Sending gen-layout request for country:', country);
return sendMessage({
type: 'command',
payload: {
user_info,
srv_name: 'gen-service',
values: {
file_layout: 'sheet',
file_desc: 'sheet',
country: country,
param: 'new-inter-v3-multi-promotion-other_catalog-supra_app'
}
}
});
}
/**
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
export function requestSheetPrice(country: string, productCodes: string[]): boolean {
// Check if already sent
if (hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
if (!productCodes || productCodes.length === 0) {
console.warn('[sheetService] No product codes to request price for');
return false;
}
// Generate request_id (UUID v4)
const request_id = crypto.randomUUID();
// Store request_id and country in streamingRawData for tracking
streamingRawData.update((data) => ({
...data,
price: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
sheetPriceLoading.set(true);
// Convert to array of objects (backend expects objects, not strings)
const content = productCodes.map((code) => ({ product_code: code }));
console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id);
const sent = sendCommandRequest('sheet', {
country: country,
content: content,
param: 'price',
stream: true,
request_id
});
if (sent) {
markSheetPriceAsSent('price');
} else {
sheetPriceLoading.set(false);
}
return sent;
}
/**
* Update price data in sheet
* content: [{ row_index: number, cells: [{ value: string, coord: { row: number, col: number } }] }]
*/
export function updateSheetPrice(
country: string,
content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[]
): boolean {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to update');
return false;
}
console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length);
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/price'
});
}
/**
* Add new price rows to sheet (for product codes that don't exist in price sheet)
* content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }]
*/
export function addSheetPrice(
country: string,
content: { cells: string[] }[]
): boolean {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to add');
return false;
}
console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content);
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price'
});
}