create branch dev and commit code
This commit is contained in:
parent
3b70cc9fe8
commit
ea68fa5cc4
44 changed files with 12421 additions and 214 deletions
284
src/lib/core/services/androidRecipeExportService.ts
Normal file
284
src/lib/core/services/androidRecipeExportService.ts
Normal 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;
|
||||
}
|
||||
251
src/lib/core/services/sheetService.ts
Normal file
251
src/lib/core/services/sheetService.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue