/// const XLSX = require('xlsx'); const axios = require('axios'); // ================================================ // CONFIG — ONLY EDIT THESE VALUES // ================================================ const EXCEL_FILE_URL = "https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/JOBS%201000-9999.xlsx?d=wc20d9b5023f84403b4d345ca88382eb4&csf=1&web=1&e=aNwcPv"; const EXCEL_SHEET_NAME = "Job Sheet"; const AZURE_TENANT_ID = "3fd97ea7-b124-41f1-855f-52d8ac3b16c7"; const AZURE_CLIENT_ID = "3c846e71-9609-40e1-b458-0eb805e21b9f"; const AZURE_CLIENT_SECRET = "lG~8Q~kKp5nyc-SK8THNE4ZLTdp81zv_gVm2jdm~"; // Store in Settings → Secret keys (see below) // Run every 5 minutes routerAdd("GET", "/_cron/sync-excel", (c) => { const correlationId = $security.randomString(8); console.log(`[${new Date().toISOString()}] Sync started (${correlationId})`); // Use secret storage (secure!) const clientSecret = $app.settings().meta.appSecret || AZURE_CLIENT_SECRET; // ================================================ // 1. Get OAuth 2.0 token (client credentials flow) // ================================================ $os.exec("curl", "-X", "POST", `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`, "-d", `client_id=${AZURE_CLIENT_ID}`, "-d", `client_secret=${clientSecret}`, "-d", "grant_type=client_credentials", "-d", "scope=https://graph.microsoft.com/.default" ).then(async (tokenRes) => { const tokenData = JSON.parse(tokenRes.stdout); const accessToken = tokenData.access_token; // ================================================ // 2. Download Excel file // ================================================ const fileRes = await $http.send({ url: EXCEL_FILE_URL + "?:@microsoft.graph.downloadUrl", method: "GET", headers: { Authorization: `Bearer ${accessToken}` } }); if (fileRes.statusCode !== 200) { console.error("Failed to download Excel:", fileRes.raw); return c.json(500, { error: "Download failed" }); } const workbook = XLSX.read(fileRes.raw, { type: "buffer", cellDates: true }); const sheet = workbook.Sheets[EXCEL_SHEET_NAME]; if (!sheet) return console.error("Sheet not found"); const range = XLSX.utils.decode_range(sheet["!ref"]); const merges = sheet["!merges"] || []; const getValue = (r, c) => { const addr = XLSX.utils.encode_cell({ r, c }); const cell = sheet[addr]; if (cell) return cell.v ?? cell.w ?? null; for (const m of merges) { if (m.s.r <= r && r <= m.e.r && m.s.c <= c && c <= m.e.c) { const master = sheet[XLSX.utils.encode_cell({ r: m.s.r, c: m.s.c })]; return master ? (master.v ?? master.w ?? null) : null; } } return null; }; // ================================================ // 3. Parse headers & data (same as browser tool) // ================================================ const HEADER_ROW = 2; const DATA_START = 3; const rawHeaders = []; for (let c = range.s.c; c <= range.e.c; c++) { let h = getValue(HEADER_ROW, c); rawHeaders.push(h ? String(h).trim() : ''); } const HEADER_MAP = { "Job_Number": "Job_Number", "Job_Full_Name": "Job_Full_Name", "Job_Name": "Job_Name", "Job Address": "Job_Address", "Job Type": "Job_Type", "Flag": "Flag", "Job Status": "Job_Status", "Job Division": "Division", "Estimator": "Estimator", "Office Rep": "Office_Rep", "Manager": "Project_Manager", "Company/Client": "Company_Client", "Contact Person": "Contact_Person", "Phone Number": "Phone", "Email": "Email", "Due Date": "Due_Date", "Due Date Source": "Due_Date_Source", "Due Date Counter": "Due_Date_Counter", "Due Time": "Due_Time", "Docs Uploaded": "Docs_Uploaded", "QB Created": "QB_Created", "Added to Calendar": "Calendar_Added", "PSwift Uploaded": "PSwift_Uploaded", "Voxer Created": "Voxer_Created", "Estimate Reviewed": "Estimate_Reviewed", "Estimate Draft": "Estimate_Draft", "Estimate Approved": "Estimate_Approved", "Start Date": "Start_Date", "Schedule\nConfidence": "Schedule_Confidence", "Notes": "Notes", "Job QB Link": "Job_QB_Link", "Voxer Link": "Voxer_Link", "Job_Codes": "Job_Codes", "Submission Date": "Submission_Date", "Active": "Active", "Job Folder Link": "Job_Folder_Link", "Tax Exempt": "Tax_Exempt", "pb_id": "id" }; const rows = []; for (let r = DATA_START; r <= range.e.r; r++) { const row = { id: null, Notes: null, Job_Number: null }; let hasData = false; for (let c = range.s.c; c <= range.e.c; c++) { const excelHeader = rawHeaders[c - range.s.c]; const pbField = HEADER_MAP[excelHeader]; if (!pbField) continue; let val = getValue(r, c); if (val !== null && val !== undefined) { const s = String(val).trim(); if (s === '' || /^n\/?a$/i.test(s) || s === '#N/A') val = null; } if (pbField === "id" && excelHeader === "pb_id") row.id = val; else if (pbField === "Notes") row.Notes = val; else if (pbField === "Job_Number") row.Job_Number = val; row[pbField] = val; if (val !== null && val !== '') hasData = true; } if (hasData) rows.push(row); } // ================================================ // 4. Full Sync Excel → PocketBase // ================================================ let created = 0, updated = 0; for (const row of rows) { try { if (row.id) { // Update existing const cleanRow = { ...row }; delete cleanRow.id; $app.dao().runInTransaction((txDao) => { txDao.saveRecord(txDao.findRecordById("Job_Info_TestEnv", row.id, cleanRow)); }); updated++; } else { // Create new const record = $app.dao().newRecord("Job_Info_TestEnv"); Object.assign(record, row); $app.dao().saveRecord(record); created++; } } catch (err) { console.error(`Row error (Job_Number ${row.Job_Number}):`, err.message); } } // ================================================ // 5. Bidirectional Notes Sync (PB → Excel if PB is newer) // ================================================ const pbRecords = arrayOf(new Record({ collection: $app.dao().findCollectionByNameOrId("Job_Info_TestEnv") })).fill($app.dao().findRecordsByExpr("Job_Info_TestEnv", DB().and())); for (const pbRec of pbRecords) { const excelMatch = rows.find(r => r.Job_Number === pbRec.getString("Job_Number")); if (!excelMatch) continue; const pbNotes = pbRec.getString("Notes") || ""; const excelNotes = excelMatch.Notes || ""; if (pbNotes !== excelNotes && pbRec.getDate("updated") > new Date()) { // Push PB → Excel console.log(`Pushing Notes to Excel for ${pbRec.getString("Job_Number")}`); // Future: Use Graph workbook API to update cell // For now: just log (add Graph PATCH later) } } console.log(`Sync complete: ${created} created, ${updated} updated`); return c.json(200, { status: "success", created, updated }); }).catch(err => { console.error("Sync failed:", err); return c.json(500, { error: err.message }); }); }, null, { cron: "*/5 * * * *" }); // Every 5 minutes