Looped sync works, but is slow. No retry or resiliency built in.

This commit is contained in:
2025-12-05 08:00:58 -06:00
parent c3f470678a
commit 69ced6d180
31 changed files with 22741 additions and 206621 deletions
+4
View File
@@ -0,0 +1,4 @@
12/4/2025 breakthrough on sync. it's not perfect, but does work, just needs to be ran multiple times sometimes
to get all items synced. does take care of the date issues we were having before. there are still data
inconsistencies in the excel file, but this accounts for most of them. there is a version with and without
recurcive. runnung recurcive tests tonight.
+22447 -115
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
console.log("Hello via Bun!");
-3739
View File
File diff suppressed because one or more lines are too long
-198116
View File
File diff suppressed because it is too large Load Diff
+39 -26
View File
@@ -1,4 +1,3 @@
import fs from "fs";
import path from "path";
import axios from "axios";
@@ -24,9 +23,8 @@ const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// EXCEL HEADER AND DATA ROW PARAMETERS
// -----------------------------
// Zero-based: headers on row 2 (index 1), data starts on row 3 (index 2)
const HEADER_ROW_INDEX = 1;
const DATA_START_ROW_INDEX = 2;
const HEADER_ROW_INDEX = 1; // headers on row 2 (index 1)
const DATA_START_ROW_INDEX = 2; // data starts on row 3 (index 2)
// -----------------------------
// LOGGING
@@ -46,17 +44,15 @@ function log(message) {
// -----------------------------
function convertExcelDateToUTC(value) {
if (typeof value === "number") {
// Excel serial date
const dateObj = XLSX.SSF.parse_date_code(value);
if (!dateObj) return null;
const jsDate = new Date(Date.UTC(dateObj.y, dateObj.m - 1, dateObj.d, dateObj.H, dateObj.M, dateObj.S));
return jsDate.toISOString(); // UTC ISO format
return jsDate.toISOString();
} else if (typeof value === "string") {
// Try parsing string date
const jsDate = new Date(value);
return isNaN(jsDate.getTime()) ? value : jsDate.toISOString();
}
return value; // Return as-is if not a date
return value;
}
// -----------------------------
@@ -102,70 +98,87 @@ async function downloadExcelFromGraph(graphToken, driveId, itemId) {
// -----------------------------
// MAIN SYNC LOGIC
// -----------------------------
(async () => {
async function runSync() {
try {
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These IDs point to your Excel file in OneDrive/SharePoint
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
// Parse Excel
const workbook = XLSX.read(excelBuffer, { type: "buffer" });
const worksheet = workbook.Sheets["Job_List_Range"];
const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: true, defval: "" });
const headers = rows[HEADER_ROW_INDEX]; // Row 2 (index 1)
const dataRows = rows.slice(DATA_START_ROW_INDEX); // Row 3 onward
const headers = rows[HEADER_ROW_INDEX];
const dataRows = rows.slice(DATA_START_ROW_INDEX);
log(`✅ Found ${dataRows.length} data rows`);
log(`📊 Parsed ${dataRows.length} data rows, last Job_Number: ${dataRows[dataRows.length - 1]?.[headers.indexOf("Job_Number")]}`);
for (const row of dataRows) {
if (!row || row.length === 0) continue; // Skip empty rows
if (!row || row.length === 0) continue;
const record = {};
headers.forEach((header, i) => {
let cellValue = row[i];
// ✅ Convert all date fields to UTC ISO format
if (header.toLowerCase().includes("date")) {
cellValue = convertExcelDateToUTC(cellValue);
}
record[header] = cellValue;
});
const jobNumber = record["Job_Number"];
const jobNumber = (record["Job_Number"] || "").toString().trim();
if (!jobNumber) {
log("⚠️ Skipped row with no Job_Number");
continue;
}
try {
// Check if record exists
const existing = await pb.collection(pbCollection).getList(1, 1, {
filter: `Job_Number="${jobNumber}"`,
});
if (existing.items.length > 0) {
const id = existing.items[0].id;
// Clear all fields except Job_Number
const clearedData = { Job_Number: jobNumber };
Object.assign(clearedData, record);
await pb.collection(pbCollection).update(id, clearedData);
await pb.collection(pbCollection).update(id, record);
log(`✅ Updated record for Job_Number: ${jobNumber}`);
} else {
await pb.collection(pbCollection).create(record);
log(`✅ Created new record for Job_Number: ${jobNumber}`);
}
} catch (err) {
log(`❌ Error processing Job_Number ${jobNumber}: ${err.message}`);
log(`❌ Error processing Job_Number ${jobNumber}: ${JSON.stringify(err, null, 2)}`);
}
}
log("✅ Sync complete");
// ✅ Add timestamp at sync completion
const timestamp = new Date().toISOString();
log(`✅ Sync complete at ${timestamp}`);
} catch (err) {
log(`❌ Fatal error: ${err.message}`);
} finally {
// Schedule next run after 30 minutes
const delayMinutes = 30;
const delayMs = delayMinutes * 60 * 1000;
// Countdown logger every 5 minutes
let remaining = delayMinutes;
const interval = setInterval(() => {
remaining -= 5;
if (remaining > 0) {
log(`⏳ Next sync in ${remaining} minutes...`);
} else {
clearInterval(interval);
}
}, 5 * 60 * 1000);
setTimeout(runSync, delayMs);
}
})();
}
// -----------------------------
// START LOOP
// -----------------------------
runSync();
-339
View File
@@ -1,339 +0,0 @@
// excelsync.js
// Excel → CSV → PocketBase sync by Job_Number.
// Raw strings only (true CSV), no coercion. Progress logs + robust error logging.
import { FIELD_CONFIG } from "./fieldConfig.js";
import fs from "fs";
import path from "path";
import axios from "axios";
import qs from "qs";
import * as XLSX from "xlsx";
import dotenv from "dotenv";
import PocketBase from "pocketbase";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
const logfile = path.resolve(process.cwd(), "excelsync.log");
try {
fs.appendFileSync(logfile, message + "\n", "utf8");
} catch (e) {
console.error("Log write failed:", e?.message || e);
}
}
// -----------------------------
// GRAPH TOKEN + DOWNLOAD
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer",
});
return Buffer.from(res.data);
}
// -----------------------------
// POCKETBASE LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// Header mapping
// -----------------------------
function buildHeaderMapping(rawHeaders) {
const excelHeaderToConfig = Object.values(FIELD_CONFIG).reduce((map, cfg) => {
if (cfg.excelHeader) {
const key = String(cfg.excelHeader).trim();
map[key] = cfg;
map[key.toLowerCase()] = cfg;
}
return map;
}, {});
return rawHeaders.map(raw => {
const clean = String(raw || "").trim();
const cfg = excelHeaderToConfig[clean] || excelHeaderToConfig[clean.toLowerCase()];
return cfg ? { pbField: cfg.pbField, isMapped: true } : { pbField: "", isMapped: false };
});
}
// -----------------------------
// Excel → CSV string
// -----------------------------
const HEADER_ROW_INDEX = 2;
const DATA_START_ROW_INDEX = 3;
function excelToCSV(buffer) {
const workbook = XLSX.read(buffer, { type: "buffer" });
const sheet = workbook.Sheets["Job Sheet"];
if (!sheet) throw new Error('Sheet named "Job 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(m.s)];
return master ? (master.v ?? master.w ?? null) : null;
}
}
return null;
};
const rawHeaders = [];
for (let c = range.s.c; c <= range.e.c; c++) {
const val = getValue(HEADER_ROW_INDEX, c);
rawHeaders.push(val ? String(val).trim() : "");
}
const headerMapping = buildHeaderMapping(rawHeaders);
const pbHeaders = headerMapping.filter(h => h.isMapped).map(h => h.pbField);
if (!pbHeaders.includes("Job_Number")) {
throw new Error("Job_Number must be present in mapped headers.");
}
const rows = [];
for (let r = DATA_START_ROW_INDEX; r <= range.e.r; r++) {
const rowVals = [];
let hasData = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const v = getValue(r, c);
if (v !== null && v !== "") hasData = true;
rowVals.push(v === null ? "" : String(v));
}
if (!hasData) continue;
rows.push(rowVals);
}
const escapeCSV = (s) => {
const needsQuotes = /[",\n\r]/.test(s);
const withDoubles = s.replace(/"/g, '""');
return needsQuotes ? `"${withDoubles}"` : withDoubles;
};
const mappedColIndexes = headerMapping.map((h, idx) => (h.isMapped ? idx : -1)).filter(idx => idx !== -1);
const headerLine = pbHeaders.map(h => escapeCSV(h)).join(",");
const csvLines = [headerLine];
for (const row of rows) {
const mappedValues = mappedColIndexes.map(idx => escapeCSV(row[idx] || ""));
csvLines.push(mappedValues.join(","));
}
const csv = csvLines.join("\n");
log(`🧾 Built CSV with ${pbHeaders.length} columns and ${rows.length} data rows`);
return { csv, headers: pbHeaders };
}
// -----------------------------
// Parse CSV string → array of objects
// -----------------------------
function parseCSV(csv) {
const lines = csv.split(/\r?\n/).filter(l => l.length > 0);
if (lines.length === 0) return { headers: [], rows: [] };
const parseLine = (line) => {
const out = [];
let cur = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; }
else { inQuotes = false; }
} else { cur += ch; }
} else {
if (ch === '"') inQuotes = true;
else if (ch === ",") { out.push(cur); cur = ""; }
else { cur += ch; }
}
}
out.push(cur);
return out;
};
const headers = parseLine(lines[0]).map(h => h.trim());
const rows = [];
for (let i = 1; i < lines.length; i++) {
const cols = parseLine(lines[i]);
const obj = {};
for (let c = 0; c < headers.length; c++) {
obj[headers[c]] = cols[c] ?? "";
}
rows.push(obj);
}
return { headers, rows };
}
// -----------------------------
// Error logging helper
// -----------------------------
// -----------------------------
// Error logging helper
// -----------------------------
function logPocketBaseError(err, context) {
log(`${context}`);
log(`Error message: ${err.message || "no message"}`);
// Dump everything we can from the error object
try {
log(`Error dump: ${JSON.stringify(err, Object.getOwnPropertyNames(err), 2)}`);
} catch {
log("⚠️ Could not stringify error object");
}
// Axios attaches the server's JSON body here
if (err.response) {
log(`Response status: ${err.response.status} ${err.response.statusText || ""}`);
if (err.response.data) {
try {
log(`Server response data: ${JSON.stringify(err.response.data, null, 2)}`);
} catch {
log("⚠️ Could not stringify response.data");
}
}
}
// PocketBase client sometimes attaches .data separately
if (err.data) {
try {
log(`Server details: ${JSON.stringify(err.data, null, 2)}`);
} catch {}
}
}
// -----------------------------
// RESYNC: by Job_Number
// -----------------------------
// -----------------------------
// RESYNC: by Job_Number
// -----------------------------
async function resyncPocketBase(pb, csvRows) {
log(`🔄 Starting resync for ${csvRows.length} rows...`);
let existing = [];
try {
existing = await pb.collection(pbCollection).getFullList();
} catch (err) {
logPocketBaseError(err, "Failed to load existing records");
}
const jobMap = {};
for (const rec of existing) {
jobMap[rec.Job_Number] = rec.id;
}
const allowedFields = new Set(
Object.values(FIELD_CONFIG).map(cfg => cfg.pbField).filter(Boolean)
);
for (let i = 0; i < csvRows.length; i++) {
const row = csvRows[i];
// Progress every 250 records
if (i > 0 && i % 250 === 0) {
log(`⏳ Progress: processed ${i} of ${csvRows.length} records...`);
}
const jobNumber = row.Job_Number;
if (!jobNumber || jobNumber === "") continue;
// Build payload with only allowed fields
const payload = {};
for (const [k, v] of Object.entries(row)) {
if (k === "Job_Number") continue;
if (allowedFields.has(k)) payload[k] = v;
}
// Helper to log errors with server response
const logPocketBaseError = (err, context) => {
log(`${context}`);
log(`Error message: ${err.message || "no message"}`);
try {
log(`Error dump: ${JSON.stringify(err, Object.getOwnPropertyNames(err), 2)}`);
} catch {}
if (err.response) {
log(`Response status: ${err.response.status} ${err.response.statusText || ""}`);
if (err.response.data) {
try {
log(`Server response data: ${JSON.stringify(err.response.data, null, 2)}`);
} catch {
log("⚠️ Could not stringify response.data");
}
} else {
log("⚠️ No response.data present");
}
} else {
log("⚠️ No err.response present");
}
if (err.data) {
try {
log(`Server details: ${JSON.stringify(err.data, null, 2)}`);
} catch {}
}
};
if (jobMap[jobNumber]) {
const id = jobMap[jobNumber];
try {
await pb.collection(pbCollection).update(id, payload);
} catch (err) {
logPocketBaseError(err, `Update failed for id=${id} Job_Number=${jobNumber}`);
}
} else {
const createPayload = { Job_Number: jobNumber, ...payload };
try {
await pb.collection(pbCollection).create(createPayload);
} catch (err) {
logPocketBaseError(err, `Create failed for Job_Number=${jobNumber}`);
}
}
}
log("✅ Resync complete.");
}
-370
View File
@@ -1,370 +0,0 @@
// excelsync.js
import fs from "fs";
import axios from "axios";
import qs from "qs";
import * as XLSX from "xlsx";
import dotenv from "dotenv";
import { serve } from "bun";
import PocketBase from "pocketbase"
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const sharepointFileUrl = CONFIG.sharepointFileUrl; // production Excel file URL
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection || "Job_Info_TestEnv";
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
if (!tenantId || !clientId || !clientSecret || !sharepointFileUrl || !pbUrl || !pbEmail || !pbPassword) {
console.error("❌ Missing required config/env values");
process.exit(1);
}
console.log({
tenantId,
clientId,
clientSecret: clientSecret ? clientSecret.slice(0,4) + "..." : null,
sharepointFileUrl,
pbUrl,
pbEmail,
pbPassword: pbPassword ? "***" : null
});
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
fs.appendFileSync("src/excelsync.log", message + "\n");
}
// -----------------------------
// GRAPH TOKEN
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// GRAPH DOWNLOADER HELPER
// -----------------------------
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer", // important: get raw binary
});
return Buffer.from(res.data); // convert to Buffer for XLSX
}
// -----------------------------
// POCKETBASE LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// EXCEL PARSER
// -----------------------------
const HEADER_ROW_INDEX = 2; // Row 3
const DATA_START_ROW_INDEX = 3; // Row 4
function parseExcelFile(input, headerMap) {
let data;
// In case of data from graph
if (Buffer.isBuffer(input)) {
data = input;
log(`Reading Excel file from Graph buffer`);
// In case of file path (Not used at this time)
} else {
data = fs.readFileSync(input);
log(`Reading Excel file: ${input}`);
}
const workbook = XLSX.read(data, { type: "buffer", cellDates: true });
const sheet = workbook.Sheets["Job Sheet"]; // explicitly use Job Sheet
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;
};
// Read headers
const rawHeaders = [];
for (let c = range.s.c; c <= range.e.c; c++) {
let h = getValue(HEADER_ROW_INDEX, c);
rawHeaders.push(h ? String(h).trim() : "");
}
log(`Found ${rawHeaders.length} headers in Row 3`);
log(`Headers: ${rawHeaders.join(" | ")}`);
// Compare Excel headers against HEADER_MAP
const mappedHeaders = [];
const unmappedHeaders = [];
for (const h of rawHeaders) {
if (headerMap[h]) {
mappedHeaders.push([h, headerMap[h]]);
} else {
unmappedHeaders.push([h, ""]);
}
}
// Output as CSV-style rows
console.log("\n📑 Header mapping preview (CSV style):");
console.log("ExcelHeader,PocketBaseField");
for (const [excel, pb] of mappedHeaders) {
console.log(`${excel},${pb}`);
}
for (const [excel, pb] of unmappedHeaders) {
console.log(`${excel},${pb}`);
}
// Parse data rows
const rows = [];
for (let r = DATA_START_ROW_INDEX; r <= range.e.r; r++) {
const row = {};
let hasData = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const excelHeader = rawHeaders[c - range.s.c];
const pbField = headerMap[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;
}
row[pbField] = val;
if (val !== null && val !== "") hasData = true;
}
if (hasData) rows.push(row);
}
log(`Loaded ${rows.length} real jobs`);
if (rows[0]) {
log(`First job preview: Job_Number=${rows[0].Job_Number}, pb_id=${rows[0].id}`);
}
return rows;
}
// -----------------------------
// HEADER MAP
// -----------------------------
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",
"Tax_Exempt": "Tax_Exempt",
"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_Number",
"Email": "Email",
"Due Date": "Due_Date",
"Due Date Source": "Due_Date_Source",
"Due Date Counter": "Due_Date_Counter",
"Due Time": "Due_Time",
"Tax_Exempt_Docs_Uploaded": "Tax_Exempt_Docs_Uploaded",
"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_Confidence": "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",
"pb_id": "id"
};
// -----------------------------
// MAIN
// -----------------------------
let parsedRows = [];
(async () => {
try {
log("🔵 Starting Excel Sync…");
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These can be update if the Excel File changes
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
parsedRows = parseExcelFile(excelBuffer, HEADER_MAP);
// Run enhanced test (safe, no writes)
runEnhancedTest(parsedRows, graphToken, pb);
// At this stage you have:
// - graphToken (for SharePoint updates later)
// - pb client (for PB updates)
// - rows[] mapped to PB fields
// Sync to PocketBase
// await syncRowsToPocketBase(parsedRows, pb);
log("✅ First part complete: tokens + Excel parsing ready");
} catch (err) {
log("❌ Sync failed: " + (err.message || err));
process.exit(1);
}
})();
// -----------------------------
// ENHANCED TEST HARNESS
// -----------------------------
function runEnhancedTest(rows, graphToken, pb) {
log("🔎 Running enhanced test (no writes)…");
// 1. Verify tokens
log(`Graph token length: ${graphToken.length}`);
log(`PocketBase token length: ${pb.authStore.token.length}`);
// 2. Verify Excel parsing
log(`Parsed ${rows.length} rows from Excel`);
if (rows.length > 0) {
const sample = rows[0];
log("Sample row preview:");
log(` Job_Number: ${sample.Job_Number}`);
log(` Job_Full_Name: ${sample.Job_Full_Name}`);
log(` Active: ${sample.Active}`);
log(` pb_id: ${sample.id}`);
log(` Notes: ${sample.Notes}`);
}
// 3. Verify header mapping
const mappedFields = Object.values(HEADER_MAP);
const sampleKeys = Object.keys(rows[0] || {});
const missingFields = mappedFields.filter(f => !sampleKeys.includes(f));
if (missingFields.length > 0) {
log(`⚠️ Missing mapped fields in sample row: ${missingFields.join(", ")}`);
} else {
log("✅ All mapped fields present in sample row");
}
log("✅ Enhanced test complete — no writes performed");
}
// -------------------------
// Export to xlsx - Excel
// ---------------------------
function exportExcelforDL(parsedRows) {
if (!parsedRows || parsedRows.length === 0) {
throw new Error("⚠️ No rows to export");
}
const worksheet = XLSX.utils.json_to_sheet(parsedRows);
// Ensure Job_Number is the first column
const cols = Object.keys(parsedRows[0] || {});
if (cols.includes("Job_Number")) {
const ordered = ["Job_Number", ...cols.filter(c => c !== "Job_Number")];
const remapped = parsedRows.map(r =>
ordered.reduce((acc, key) => { acc[key] = r[key]; return acc; }, {})
);
const ws2 = XLSX.utils.json_to_sheet(remapped);
Object.assign(worksheet, ws2);
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "ParsedData");
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
}
function isParsedReady() {
return Array.isArray(parsedRows) && parsedRows.length > 0;
}
const PORT = 3000;
serve({
port: PORT,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/") {
const html = `
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>ExcelSync Export</title></head>
<body>
<h1>ExcelSync Export</h1>
<p>Click below to download the parsed Excel file:</p>
<button onclick="window.location.href='/download'">Download ParsedOutput.xlsx</button>
</body>
</html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
if (url.pathname === "/download") {
try {
if (!isParsedReady()) {
return new Response("⚠️ Parsed rows are not ready yet. Try again in a moment.", { status: 409 });
}
const buffer = exportExcelforDL(parsedRows);
return new Response(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": "attachment; filename=ParsedOutput.xlsx"
}
});
} catch (err) {
return new Response("❌ Failed to generate Excel file: " + err.message, { status: 500 });
}
}
return new Response("Visit / to open the export page.");
}
});
// Prompt on startup
log(`🚀 ExcelSync export server running at http://localhost:${PORT}/`);
log(`👉 Open this URL in your browser to verify and download the parsed Excel file.`);
-375
View File
@@ -1,375 +0,0 @@
//notes - works to get excel downloaded bypass downloader with "false"
// excelsync.js
import fs from "fs";
import axios from "axios";
import qs from "qs";
import * as XLSX from "xlsx";
import dotenv from "dotenv";
import { serve } from "bun";
import PocketBase from "pocketbase"
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const sharepointFileUrl = CONFIG.sharepointFileUrl; // production Excel file URL
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection || "Job_Info_TestEnv";
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
if (!tenantId || !clientId || !clientSecret || !sharepointFileUrl || !pbUrl || !pbEmail || !pbPassword) {
console.error("❌ Missing required config/env values");
process.exit(1);
}
console.log({
tenantId,
clientId,
clientSecret: clientSecret ? clientSecret.slice(0,4) + "..." : null,
sharepointFileUrl,
pbUrl,
pbEmail,
pbPassword: pbPassword ? "***" : null
});
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
fs.appendFileSync("src/excelsync.log", message + "\n");
}
// -----------------------------
// GRAPH TOKEN
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// GRAPH DOWNLOADER HELPER
// -----------------------------
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer", // important: get raw binary
});
return Buffer.from(res.data); // convert to Buffer for XLSX
}
// -----------------------------
// POCKETBASE LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// EXCEL PARSER
// -----------------------------
const HEADER_ROW_INDEX = 2; // Row 3
const DATA_START_ROW_INDEX = 3; // Row 4
function parseExcelFile(input, headerMap) {
let data;
// In case of data from graph
if (Buffer.isBuffer(input)) {
data = input;
log(`Reading Excel file from Graph buffer`);
// In case of file path (Not used at this time)
} else {
data = fs.readFileSync(input);
log(`Reading Excel file: ${input}`);
}
const workbook = XLSX.read(data, { type: "buffer", cellDates: true });
const sheet = workbook.Sheets["Job Sheet"]; // explicitly use Job Sheet
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;
};
// Read headers
const rawHeaders = [];
for (let c = range.s.c; c <= range.e.c; c++) {
let h = getValue(HEADER_ROW_INDEX, c);
rawHeaders.push(h ? String(h).trim() : "");
}
log(`Found ${rawHeaders.length} headers in Row 3`);
log(`Headers: ${rawHeaders.join(" | ")}`);
// Compare Excel headers against HEADER_MAP
const mappedHeaders = [];
const unmappedHeaders = [];
for (const h of rawHeaders) {
if (headerMap[h]) {
mappedHeaders.push([h, headerMap[h]]);
} else {
unmappedHeaders.push([h, ""]);
}
}
// Output as CSV-style rows
console.log("\n📑 Header mapping preview (CSV style):");
console.log("ExcelHeader,PocketBaseField");
for (const [excel, pb] of mappedHeaders) {
console.log(`${excel},${pb}`);
}
for (const [excel, pb] of unmappedHeaders) {
console.log(`${excel},${pb}`);
}
// Parse data rows
const rows = [];
for (let r = DATA_START_ROW_INDEX; r <= range.e.r; r++) {
const row = {};
let hasData = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const excelHeader = rawHeaders[c - range.s.c];
const pbField = headerMap[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;
}
row[pbField] = val;
if (val !== null && val !== "") hasData = true;
}
if (hasData) rows.push(row);
}
log(`Loaded ${rows.length} real jobs`);
if (rows[0]) {
log(`First job preview: Job_Number=${rows[0].Job_Number}, pb_id=${rows[0].id}`);
}
return rows;
}
// -----------------------------
// HEADER MAP
// -----------------------------
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",
"Tax_Exempt": "Tax_Exempt",
"Job Status": "Job_Status",
"Job Division": "Job_Division",
"Estimator": "Estimator",
"Office Rep": "Office_Rep",
"Manager": "Project_Manager",
"Company/Client": "Company_Client",
"Contact Person": "Contact_Person",
"Phone Number": "Phone_Number",
"Email": "Email",
"Due Date": "Due_Date",
"Due Date Source": "Due_Date_Source",
"Due Date Counter": "Due_Date_Counter",
"Due Time": "Due_Time",
"Tax_Exempt_Docs_Uploaded": "Tax_Exempt_Docs_Uploaded",
"Docs Uploaded": "Docs_Uploaded",
"QB Created": "QB_Created",
"Added to Calendar": "Added_To_Calendar",
"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_Confidence": "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",
"pb_id": "id"
};
// -----------------------------
// MAIN
// -----------------------------
let parsedRows = [];
(async () => {
try {
log("🔵 Starting Excel Sync…");
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These can be update if the Excel File changes
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
parsedRows = parseExcelFile(excelBuffer, HEADER_MAP);
// Run enhanced test (safe, no writes)
runEnhancedTest(parsedRows, graphToken, pb);
// At this stage you have:
// - graphToken (for SharePoint updates later)
// - pb client (for PB updates)
// - rows[] mapped to PB fields
// Sync to PocketBase
//await syncRowsToPocketBase(parsedRows, pb);
log("✅ First part complete: tokens + Excel parsing ready");
} catch (err) {
log("❌ Sync failed: " + (err.message || err));
process.exit(1);
}
})();
// -----------------------------
// ENHANCED TEST HARNESS
// -----------------------------
function runEnhancedTest(rows, graphToken, pb) {
log("🔎 Running enhanced test (no writes)…");
// 1. Verify tokens
log(`Graph token length: ${graphToken.length}`);
log(`PocketBase token length: ${pb.authStore.token.length}`);
// 2. Verify Excel parsing
log(`Parsed ${rows.length} rows from Excel`);
if (rows.length > 0) {
const sample = rows[0];
log("Sample row preview:");
log(` Job_Number: ${sample.Job_Number}`);
log(` Job_Full_Name: ${sample.Job_Full_Name}`);
log(` Active: ${sample.Active}`);
log(` pb_id: ${sample.id}`);
log(` Notes: ${sample.Notes}`);
}
// 3. Verify header mapping
const mappedFields = Object.values(HEADER_MAP);
const sampleKeys = Object.keys(rows[0] || {});
const missingFields = mappedFields.filter(f => !sampleKeys.includes(f));
if (missingFields.length > 0) {
log(`⚠️ Missing mapped fields in sample row: ${missingFields.join(", ")}`);
} else {
log("✅ All mapped fields present in sample row");
}
log("✅ Enhanced test complete — no writes performed");
}
// -------------------------
// Export to xlsx - Excel
// ---------------------------
// Toggle this flag to enable/disable the export server
const ENABLE_EXPORT = true; // set to false to bypass download
function exportExcelforDL(parsedRows) {
if (!parsedRows || parsedRows.length === 0) {
throw new Error("⚠️ No rows to export");
}
const worksheet = XLSX.utils.json_to_sheet(parsedRows);
// Ensure Job_Number is the first column
const cols = Object.keys(parsedRows[0] || {});
if (cols.includes("Job_Number")) {
const ordered = ["Job_Number", ...cols.filter(c => c !== "Job_Number")];
const remapped = parsedRows.map(r =>
ordered.reduce((acc, key) => { acc[key] = r[key]; return acc; }, {})
);
const ws2 = XLSX.utils.json_to_sheet(remapped);
Object.assign(worksheet, ws2);
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "ParsedData");
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
}
if (ENABLE_EXPORT) {
const PORT = 3000;
serve({
port: PORT,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/") {
const html = `
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>ExcelSync Export</title></head>
<body>
<h1>ExcelSync Export</h1>
<p>Click below to download the parsed Excel file:</p>
<button onclick="window.location.href='/download'">Download ParsedOutput.xlsx</button>
</body>
</html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
if (url.pathname === "/download") {
try {
const buffer = exportExcelforDL(parsedRows);
return new Response(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": "attachment; filename=ParsedOutput.xlsx"
}
});
} catch (err) {
return new Response("❌ Failed to generate Excel file: " + err.message, { status: 500 });
}
}
return new Response("Visit / to open the export page.");
}
});
log(`🚀 ExcelSync export server running at http://localhost:${PORT}/`);
log(`👉 Open this URL in your browser to verify and download the parsed Excel file.`);
} else {
log("⏭️ Export server bypassed (ENABLE_EXPORT=false)");
}
// -----------------------------
// FINAL SYNC FUNCTION
// -----------------------------
+172
View File
@@ -0,0 +1,172 @@
//not perfect, but does run. sometimes has to be run multiple times to fully sync.
import fs from "fs";
import path from "path";
import axios from "axios";
import qs from "qs";
import dotenv from "dotenv";
import PocketBase from "pocketbase";
import XLSX from "xlsx";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// EXCEL HEADER AND DATA ROW PARAMETERS
// -----------------------------
// Zero-based: headers on row 2 (index 1), data starts on row 3 (index 2)
const HEADER_ROW_INDEX = 1;
const DATA_START_ROW_INDEX = 2;
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
const logfile = path.resolve(process.cwd(), "excelsync.log");
try {
fs.appendFileSync(logfile, message + "\n", "utf8");
} catch (e) {
console.error("Log write failed:", e?.message || e);
}
}
// -----------------------------
// DATE CONVERSION HELPER
// -----------------------------
function convertExcelDateToUTC(value) {
if (typeof value === "number") {
// Excel serial date
const dateObj = XLSX.SSF.parse_date_code(value);
if (!dateObj) return null;
const jsDate = new Date(Date.UTC(dateObj.y, dateObj.m - 1, dateObj.d, dateObj.H, dateObj.M, dateObj.S));
return jsDate.toISOString(); // UTC ISO format
} else if (typeof value === "string") {
// Try parsing string date
const jsDate = new Date(value);
return isNaN(jsDate.getTime()) ? value : jsDate.toISOString();
}
return value; // Return as-is if not a date
}
// -----------------------------
// POCKETBASE TOKEN ACQUISITION
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// GRAPH TOKEN ACQUISITION
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// GRAPH EXCEL DOWNLOAD HELPER
// -----------------------------
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer",
});
return Buffer.from(res.data);
}
// -----------------------------
// MAIN SYNC LOGIC
// -----------------------------
(async () => {
try {
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These IDs point to your Excel file in OneDrive/SharePoint
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
// Parse Excel
const workbook = XLSX.read(excelBuffer, { type: "buffer" });
const worksheet = workbook.Sheets["Job_List_Range"];
const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const headers = rows[HEADER_ROW_INDEX]; // Row 2 (index 1)
const dataRows = rows.slice(DATA_START_ROW_INDEX); // Row 3 onward
log(`✅ Found ${dataRows.length} data rows`);
for (const row of dataRows) {
if (!row || row.length === 0) continue; // Skip empty rows
const record = {};
headers.forEach((header, i) => {
let cellValue = row[i];
// ✅ Convert all date fields to UTC ISO format
if (header.toLowerCase().includes("date")) {
cellValue = convertExcelDateToUTC(cellValue);
}
record[header] = cellValue;
});
const jobNumber = record["Job_Number"];
if (!jobNumber) {
log("⚠️ Skipped row with no Job_Number");
continue;
}
try {
// Check if record exists
const existing = await pb.collection(pbCollection).getList(1, 1, {
filter: `Job_Number="${jobNumber}"`,
});
if (existing.items.length > 0) {
const id = existing.items[0].id;
// Clear all fields except Job_Number
const clearedData = { Job_Number: jobNumber };
Object.assign(clearedData, record);
await pb.collection(pbCollection).update(id, clearedData);
log(`✅ Updated record for Job_Number: ${jobNumber}`);
} else {
await pb.collection(pbCollection).create(record);
log(`✅ Created new record for Job_Number: ${jobNumber}`);
}
} catch (err) {
log(`❌ Error processing Job_Number ${jobNumber}: ${err.message}`);
}
}
log("✅ Sync complete");
} catch (err) {
log(`❌ Fatal error: ${err.message}`);
}
})();
-521
View File
@@ -1,521 +0,0 @@
//12/1/2025 Allows Excel download and conversion of headers mapped to pb headers.
//coersion added to match pb field types. however, may still have difficulty in assimilating certain date formats from excel to pb date fields.
//due to Excel storing as all caps for boolean fields
// excelsync.js
import { FIELD_CONFIG } from "./fieldConfig.js";
import fs from "fs";
import axios from "axios";
import qs from "qs";
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
import dotenv from "dotenv";
import { serve } from "bun";
import PocketBase from "pocketbase"
dotenv.config();
// -----------------------------
// CONFIG FOR TOKEN ACQUISITION GRAPH API + POCKETBASE
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
fs.appendFileSync("excelsync.log", message + "\n");
}
// -----------------------------
// HEADER DETECTOR
// -----------------------------
//Gets the last column letter in the table converting from 0 index to excel actual column in letter form.
function columnLetter(index) {
let letter = "";
while (index >= 0) {
letter = String.fromCharCode((index % 26) + 65) + letter;
index = Math.floor(index / 26) - 1;
}
return letter;
}
// -----------------------------
// GRAPH TOKEN ACQUISITION
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// GRAPH DOWNLOADER HELPER
// -----------------------------
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer", // important: get raw binary
});
return Buffer.from(res.data); // convert to Buffer for XLSX
}
// -----------------------------
// POCKETBASE LOGIN AND TOKEN ACQUISITION
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// Get Data From Excel File
// -----------------------------
const HEADER_ROW_INDEX = 2; // Row 3 in Excel
const DATA_START_ROW_INDEX = 3; // Row 4 in Excel → first data row
//clean and coerce Excel values to match PocketBase schema expectations
function cleanString(val) {
if (val == null) return "";
return String(val)
.replace(/\u00A0/g, " ") // normalize non-breaking spaces
.replace(/[\r\n\t]/g, " ") // strip control chars
.replace(/\s+/g, " ") // collapse whitespace
.trim();
}
function normalizeForPB(val, type) {
const cleaned = cleanString(val);
// Treat common placeholders as empty
const emptyTokens = ["n/a", "na", "null", "none"];
if (emptyTokens.includes(cleaned.toLowerCase())) {
switch (type) {
case "number": return 0; // PocketBase defaults to 0
case "bool": return null;
case "string": return null;
case "email": return null; // email empty
case "json": return null;
case "date": return null; // date empty
default: return null;
}
}
if (cleaned === "") {
switch (type) {
case "number": return 0;
case "bool": return null;
case "string": return null;
case "email": return null;
case "json": return null;
case "date": return null;
default: return null;
}
}
switch (type) {
case "bool":
case "boolean": {
const lower = cleaned.toLowerCase();
if (["true","yes","y","1"].includes(lower)) return true; // boolean true
if (["false","no","n","0"].includes(lower)) return false; // boolean false
return null;
}
case "number": {
const num = Number(cleaned);
return Number.isNaN(num) ? 0 : num;
}
case "date": {
const d = new Date(cleaned);
return isNaN(d.getTime()) ? null : d.toISOString();
}
case "string":
default:
return cleaned;
}
}
export function parseExcelFile(input) {
let data;
if (Buffer.isBuffer(input)) {
data = input;
log("Reading Excel file from Graph buffer");
} else {
data = fs.readFileSync(input);
log(`Reading Excel file: ${input}`);
}
const workbook = XLSX.read(data, { type: "buffer", cellDates: true });
const sheet = workbook.Sheets["Job Sheet"];
if (!sheet) throw new Error('Sheet named "Job Sheet" not found');
const range = XLSX.utils.decode_range(sheet["!ref"]);
const merges = sheet["!merges"] || [];
// Helper to get value from cell or merged region
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(m.s)];
return master ? (master.v ?? master.w ?? null) : null;
}
}
return null;
};
// 1. Read headers (Row 3)
const rawHeaders = [];
for (let c = range.s.c; c <= range.e.c; c++) {
const val = getValue(HEADER_ROW_INDEX, c);
rawHeaders.push(val ? String(val).trim() : "");
}
// 2. Build lookup map once
const excelHeaderToConfig = Object.values(FIELD_CONFIG).reduce((map, cfg) => {
if (cfg.excelHeader) {
const key = cfg.excelHeader.trim();
map[key] = cfg;
map[key.toLowerCase()] = cfg;
}
return map;
}, {});
// 3. Rich header mapping
const headerMapping = rawHeaders.map(raw => {
const clean = raw.trim();
const config = excelHeaderToConfig[clean] || excelHeaderToConfig[clean.toLowerCase()];
return config
? { original: clean, pbField: config.pbField, type: config.type, isMapped: true, config }
: { original: clean, pbField: "", type: "string", isMapped: false, isUnknown: true };
});
log(`Mapped ${headerMapping.filter(h => h.isMapped).length} columns`);
// 4. Parse data rows (starting Row 4)
const rows = [];
for (let r = DATA_START_ROW_INDEX; r <= range.e.r; r++) {
const values = [];
let hasData = false;
for (let c = range.s.c; c <= range.e.c; c++) {
const val = getValue(r, c);
values.push(val ?? null);
if (val !== null && val !== "") hasData = true;
}
if (!hasData) continue;
const rowObj = {};
headerMapping.forEach((map, i) => {
if (map.isMapped) {
rowObj[map.pbField] = normalizeForPB(values[i], map.type);
}
});
rows.push(rowObj);
}
return {
data: rows, // parsed row objects
headerMapping, // rich info about headers
rawHeaders, // original headers for debugging
totalRows: rows.length // count of parsed rows
};
}
// -----------------------------
// MAIN
// -----------------------------
let parsedRows = [];
let rawHeaders = [];
(async () => {
try {
log("🔵 Starting Excel Sync…");
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These can be update if the Excel File changes
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
const parsed = parseExcelFile(excelBuffer);
parsedRows = parsed.data;
rawHeaders = parsed.rawHeaders;
// UPDATE AND LOG CHANGES TO POCKETBASE
await syncRowsToPocketBase(pb, parsedRows);
log(rawHeaders)
log("✅ First part complete: tokens + Excel parsing ready");
} catch (err) {
log("❌ Sync failed: " + (err.message || err));
process.exit(1);
}
})();
// -------------------------
// Export to xlsx - Excel
// ---------------------------
// Toggle this flag to enable/disable the export server
const ENABLE_EXPORT = false; // set to false to bypass download
function exportExcelforDL(parsedRows) {
if (!parsedRows || parsedRows.length === 0) {
throw new Error("⚠️ No rows to export");
}
const worksheet = XLSX.utils.json_to_sheet(parsedRows);
// Ensure Job_Number is the first column
const cols = Object.keys(parsedRows[0] || {});
if (cols.includes("Job_Number")) {
const ordered = ["Job_Number", ...cols.filter(c => c !== "Job_Number")];
const remapped = parsedRows.map(r =>
ordered.reduce((acc, key) => { acc[key] = r[key]; return acc; }, {})
);
const ws2 = XLSX.utils.json_to_sheet(remapped);
Object.assign(worksheet, ws2);
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "ParsedData");
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
}
if (ENABLE_EXPORT) {
const PORT = 3000;
serve({
port: PORT,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/") {
const html = `
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>ExcelSync Export</title></head>
<body>
<h1>ExcelSync Export</h1>
<p>Click below to download the parsed Excel file:</p>
<button onclick="window.location.href='/download'">Download ParsedOutput.xlsx</button>
</body>
</html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
if (url.pathname === "/download") {
try {
const buffer = exportExcelforDL(parsedRows);
return new Response(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": "attachment; filename=ParsedOutput.xlsx"
}
});
} catch (err) {
return new Response("❌ Failed to generate Excel file: " + err.message, { status: 500 });
}
}
return new Response("Visit / to open the export page.");
}
});
log(`🚀 ExcelSync export server running at http://localhost:${PORT}/`);
log(`👉 Open this URL in your browser to verify and download the parsed Excel file.`);
} else {
log("⏭️ Export server bypassed (ENABLE_EXPORT=false)");
}
async function syncRowsToPocketBase(pb, parsedRows) {
let changes = 0;
for (const row of parsedRows) {
const jobNumber = row.Job_Number;
if (!jobNumber) continue;
try {
// Find existing record by Job_Number
const records = await pb.collection(pbCollection).getFullList({
filter: Number.isFinite(jobNumber)
? `Job_Number=${jobNumber}`
: `Job_Number="${jobNumber}"`,
batchSize: 1
});
if (records.length > 0) {
const record = records[0];
// Compare field-by-field
const updatePayload = {};
const changedFields = [];
for (const [key, value] of Object.entries(row)) {
// Look up the expected type from your header mapping
const type = headerMapping.find(h => h.pbField === key)?.type || "string";
if (!valuesEqual(record[key], value, type)) {
updatePayload[key] = value;
changedFields.push({
field: key,
before: record[key],
after: value
});
}
}
if (changedFields.length > 0) {
await pb.collection(pbCollection).update(record.id, updatePayload);
// Log detailed changes
const details = changedFields
.map(f => `${f.field}: "${f.before}" → "${f.after}"`)
.join("; ");
log(`🔄 Updated Job_Number=${jobNumber}, changes: ${details}`);
changes++;
}
} else {
await pb.collection(pbCollection).create(row);
log(` Created new record for Job_Number=${jobNumber}`);
changes++;
}
} catch (err) {
log(`❌ Sync failed for Job_Number=${jobNumber}: ${err.message}`);
}
}
log(`✅ Sync complete. Records added/updated: ${changes}`);
}
function valuesEqual(pbVal, excelVal, type) {
if (pbVal == null && excelVal == null) return true;
switch (type) {
case "date": {
const pbDate = new Date(pbVal);
const excelDate = new Date(excelVal);
return !isNaN(pbDate) && !isNaN(excelDate) && pbDate.getTime() === excelDate.getTime();
}
case "bool":
return Boolean(pbVal) === Boolean(excelVal);
case "number":
return Number(pbVal) === Number(excelVal);
default:
const normalize = v => (v ?? "").toString().replace(/\s+/g, " ").trim();
return normalize(pbVal) === normalize(excelVal);
}
}
// -----------------------------
// UPDATE NOTES CELLS IN SHAREPOINT (REAL-TIME)
// -----------------------------
//function getNotesColumnLetterFromParsedHeaders(rawHeaders) {
// const notesIndex = rawHeaders.findIndex(h => h.trim().toLowerCase() === "notes");
// if (notesIndex === -1) {
// log(`❌ 'Notes' header not found in HEADER_MAP keys: ${rawHeaders.join(", ")}`);
// throw new Error("Notes column not found");
// }
// return columnLetter(notesIndex);
//}
//await updateNotesInSharePoint(graphToken, driveId, itemId, "Job Sheet", parsedRows, pb, excelBuffer);
// Update only changed Notes cells using workbook session + PATCH.
// This version relies solely on DATA_START_ROW_INDEX for row mapping.
//async function updateNotesInSharePoint(graphToken, driveId, itemId, worksheetName, parsedRows, pb, workbookBuffer) {
// const wb = XLSX.read(workbookBuffer, { type: "buffer", cellDates: true });
// const sheet = wb.Sheets[worksheetName];
// if (!sheet) throw new Error(`Worksheet "${worksheetName}" not found`);
// Column detection aligned to HEADER_MAP (same mapping used by parseExcelFile)
//const notesColumnLetter = getNotesColumnLetterFromParsedHeaders(rawHeaders);
//og(`✅ Notes column resolved to ${notesColumnLetter} via rawHeaders`);
// Create a workbook session that persists changes
//const sessionRes = await axios.post(
// `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/createSession`,
// { persistChanges: true },
// { headers: { Authorization: `Bearer ${graphToken}` } }
// );
// const sessionId = sessionRes.data.id;
// let changes = 0;
// Loop parsed rows; map i to Excel row using DATA_START_ROW_INDEX (zero-based)
// for (let i = 0; i < parsedRows.length; i++) {
// const row = parsedRows[i];
// const jobNumber = row.Job_Number;
// if (jobNumber == null || jobNumber === "") continue;
// try {
// const records = await pb.collection(pbCollection).getFullList({
// filter: Number.isFinite(jobNumber) ? `Job_Number=${jobNumber}` : `Job_Number="${jobNumber}"`,
// batchSize: 1
// });
// if (records.length === 0) continue;
// Normalize notes for fair comparison
// const normalize = v => (v ?? "").toString().replace(/\s+/g, " ").trim();
// const pbNotes = normalize(records[0].Notes);
// const excelNotes = normalize(row.Notes);
// if (pbNotes !== excelNotes) {
// Excel uses 1-based row numbers; DATA_START_ROW_INDEX is zero-based
// const excelRowNumber = DATA_START_ROW_INDEX + i + 1;
// const cellAddress = `${notesColumnLetter}${excelRowNumber}`;
// log(`✏️ Notes change for Job_Number=${jobNumber}`);
// log(` Before: "${excelNotes}"`);
// log(` After : "${pbNotes}"`);
// log(`➡️ Writing to ${worksheetName}!${cellAddress}`);
// await axios.patch(
// `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/worksheets('${worksheetName}')/range(address='${cellAddress}')`,
// { values: [[pbNotes]] },
// {
// headers: {
// Authorization: `Bearer ${graphToken}`,
// "Content-Type": "application/json",
// "workbook-session-id": sessionId
// }
// }
// );
// row.Notes = pbNotes;
// changes++;
// }
// } catch (err) {
// log(`❌ Failed for Job_Number=${jobNumber}: ${err.message}`);
// }
// }
// await axios.post(
// `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/closeSession`,
// {},
// { headers: { Authorization: `Bearer ${graphToken}`, "workbook-session-id": sessionId } }
// );
// log(`✅ Notes-only sync complete. Changes applied: ${changes}`);
//}
-194
View File
@@ -1,194 +0,0 @@
// fieldConfig.js
export const FIELD_CONFIG = {
id: {
excelHeader: "pb_id",
pbField: "id",
type: "string",
required: true
},
Job_Number: {
excelHeader: "Job_Number",
pbField: "Job_Number",
type: "string"
},
Job_Full_Name: {
excelHeader: "Job_Full_Name",
pbField: "Job_Full_Name",
type: "string"
},
Job_Name: {
excelHeader: "Job_Name",
pbField: "Job_Name",
type: "string"
},
Job_Address: {
excelHeader: "Job Address",
pbField: "Job_Address",
type: "string"
},
Job_Type: {
excelHeader: "Job Type",
pbField: "Job_Type",
type: "string"
},
Job_Status: {
excelHeader: "Job Status",
pbField: "Job_Status",
type: "string"
},
Job_Division: {
excelHeader: "Job Division",
pbField: "Job_Division",
type: "string"
},
Estimator: {
excelHeader: "Estimator",
pbField: "Estimator",
type: "string"
},
Office_Rep: {
excelHeader: "Office Rep",
pbField: "Office_Rep",
type: "string"
},
Project_Manager: {
excelHeader: "Manager",
pbField: "Project_Manager",
type: "string"
},
Company_Client: {
excelHeader: "Company/Client",
pbField: "Company_Client",
type: "string"
},
Contact_Person: {
excelHeader: "Contact Person",
pbField: "Contact_Person",
type: "string"
},
Phone_Number: {
excelHeader: "Phone Number",
pbField: "Phone_Number",
type: "string"
},
Email: {
excelHeader: "Email",
pbField: "Email",
type: "string"
},
Due_Date: {
excelHeader: "Due Date",
pbField: "Due_Date",
type: "date"
},
Due_Date_Source: {
excelHeader: "Due Date Source",
pbField: "Due_Date_Source",
type: "string"
},
Due_Date_Counter: {
excelHeader: "Due Date Counter",
pbField: "Due_Date_Counter",
type: "string"
},
Due_Time: {
excelHeader: "Due Time",
pbField: "Due_Time",
type: "string"
},
Docs_Uploaded: {
excelHeader: "Docs Uploaded",
pbField: "Docs_Uploaded",
type: "bool"
},
QB_Created: {
excelHeader: "QB Created",
pbField: "QB_Created",
type: "bool"
},
Added_To_Calendar: {
excelHeader: "Added to Calendar",
pbField: "Added_To_Calendar",
type: "bool"
},
PSwift_Uploaded: {
excelHeader: "PSwift Uploaded",
pbField: "PSwift_Uploaded",
type: "bool"
},
Voxer_Created: {
excelHeader: "Voxer Created",
pbField: "Voxer_Created",
type: "bool"
},
Estimate_Reviewed: {
excelHeader: "Estimate Reviewed",
pbField: "Estimate_Reviewed",
type: "bool"
},
Estimate_Draft: {
excelHeader: "Estimate Draft",
pbField: "Estimate_Draft",
type: "bool"
},
Estimate_Approved: {
excelHeader: "Estimate Approved",
pbField: "Estimate_Approved",
type: "bool"
},
Start_Date: {
excelHeader: "Start Date",
pbField: "Start_Date",
type: "date"
},
Submission_Date: {
excelHeader: "Submission Date",
pbField: "Submission_Date",
type: "date"
},
Active: {
excelHeader: "Active",
pbField: "Active",
type: "bool"
},
Tax_Exempt: {
excelHeader: "Tax_Exempt",
pbField: "Tax_Exempt",
type: "bool"
},
Tax_Exempt_Docs_Uploaded: {
excelHeader: "Tax_Exempt_Docs_Uploaded",
pbField: "Tax_Exempt_Docs_Uploaded",
type: "bool"
},
Flag: {
excelHeader: "Flag",
pbField: "Flag",
type: "bool"
},
Notes: {
excelHeader: "Notes",
pbField: "Notes",
type: "string"
},
Job_QB_Link: {
excelHeader: "Job QB Link",
pbField: "Job_QB_Link",
type: "string"
},
Voxer_Link: {
excelHeader: "Voxer Link",
pbField: "Voxer_Link",
type: "string"
},
Job_Codes: {
excelHeader: "Job_Codes",
pbField: "Job_Codes",
type: "string"
},
Job_Folder_Link: {
excelHeader: "Job Folder Link",
pbField: "Job_Folder_Link",
type: "string"
}
};
-545
View File
@@ -1,545 +0,0 @@
[
{
autogeneratePattern: "[a-z0-9]{15}",
hidden: false,
id: "text3208210256",
max: 15,
min: 15,
name: "id",
pattern: "^[a-z0-9]+$",
presentable: false,
primaryKey: true,
required: true,
system: true,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2388997171",
max: 0,
min: 0,
name: "Job_Number",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2378820285",
max: 0,
min: 0,
name: "Job_Full_Name",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2158740783",
max: 0,
min: 0,
name: "Job_Name",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text3732958801",
max: 0,
min: 0,
name: "Job_Address",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1381428224",
max: 0,
min: 0,
name: "Job_Type",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1677020795",
max: 0,
min: 0,
name: "Job_Status",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2518113217",
max: 0,
min: 0,
name: "Job_Division",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2325867089",
max: 0,
min: 0,
name: "Estimator",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text3229868610",
max: 0,
min: 0,
name: "Office_Rep",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text899226661",
max: 0,
min: 0,
name: "Project_Manager",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text806559182",
max: 0,
min: 0,
name: "Company_Client",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1854315289",
max: 0,
min: 0,
name: "Contact_Person",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1956405909",
max: 0,
min: 0,
name: "Phone_Number",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text642995056",
max: 0,
min: 0,
name: "Email",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text4112652156",
max: 0,
min: 0,
name: "Due_Date_Source",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1362567553",
max: 0,
min: 0,
name: "Due_Date_Counter",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2050867814",
max: 0,
min: 0,
name: "Due_Time",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text811897623",
max: 0,
min: 0,
name: "Schedule_Confidence",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text3235547528",
max: 0,
min: 0,
name: "Notes",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text1270095775",
max: 0,
min: 0,
name: "Job_QB_Link",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text957490280",
max: 0,
min: 0,
name: "Voxer_Link",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2814497178",
max: 0,
min: 0,
name: "Job_Codes",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
autogeneratePattern: "",
hidden: false,
id: "text3795106522",
max: 0,
min: 0,
name: "Job_Folder_Link",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
hidden: false,
id: "bool545115032",
name: "Has_Attachments",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "json2488040839",
maxSize: 0,
name: "Attachment_List",
presentable: false,
required: false,
system: false,
type: "json",
}, {
cascadeDelete: false,
collectionId: "pbc_194787171",
hidden: false,
id: "relation2779200245",
maxSelect: 999,
minSelect: 0,
name: "Note_Ref",
presentable: false,
required: false,
system: false,
type: "relation",
}, {
hidden: false,
id: "json4080242417",
maxSize: 0,
name: "latest_note_summary",
presentable: false,
required: false,
system: false,
type: "json",
}, {
hidden: false,
id: "json2575510732",
maxSize: 0,
name: "note_thread_text",
presentable: false,
required: false,
system: false,
type: "json",
}, {
hidden: false,
id: "number3771328437",
max: null,
min: null,
name: "note_count",
onlyInt: false,
presentable: false,
required: false,
system: false,
type: "number",
}, {
hidden: false,
id: "date2500832881",
max: "",
min: "",
name: "last_note_at",
presentable: false,
required: false,
system: false,
type: "date",
}, {
autogeneratePattern: "",
hidden: false,
id: "text2569355707",
max: 0,
min: 0,
name: "Job_Number_Parent",
pattern: "",
presentable: false,
primaryKey: false,
required: false,
system: false,
type: "text",
}, {
hidden: false,
id: "date2051602006",
max: "",
min: "",
name: "Start_Date",
presentable: false,
required: false,
system: false,
type: "date",
}, {
hidden: false,
id: "date275762613",
max: "",
min: "",
name: "Submission_Date",
presentable: false,
required: false,
system: false,
type: "date",
}, {
hidden: false,
id: "bool1908819108",
name: "Flag",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool282225560",
name: "Tax_Exempt",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool1286797620",
name: "Active",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "date3208080729",
max: "",
min: "",
name: "Due_Date",
presentable: false,
required: false,
system: false,
type: "date",
}, {
hidden: false,
id: "bool1523500494",
name: "Docs_Uploaded",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool3561832989",
name: "Tax_Exempt_Docs_Uploaded",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool4143394575",
name: "Estimate_Approved",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool701636273",
name: "QB_Created",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool159074814",
name: "Added_To_Calendar",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool2443123173",
name: "PSwift_Uploaded",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool1028487862",
name: "Voxer_Created",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool2654168435",
name: "Estimate_Reviewed",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "bool1033433223",
name: "Estimate_Draft",
presentable: false,
required: false,
system: false,
type: "bool",
}, {
hidden: false,
id: "autodate2990389176",
name: "created",
onCreate: true,
onUpdate: false,
presentable: false,
system: false,
type: "autodate",
}, {
hidden: false,
id: "autodate3332085495",
name: "updated",
onCreate: true,
onUpdate: true,
presentable: false,
system: false,
type: "autodate",
}
],
indexes: [],
created: "2025-11-14 21:39:05.100Z",
updated: "2025-11-26 02:40:36.242Z",
system: false,
}
-26
View File
@@ -1,26 +0,0 @@
import dotenv from "dotenv";
import PocketBase from "pocketbase";
dotenv.config();
const pb = new PocketBase(process.env.PB_URL);
const email = process.env.PB_EMAIL;
const password = process.env.PB_PASSWORD;
if (!email || !password) {
throw new Error("Missing PB_EMAIL or PB_PASSWORD in .env");
}
try {
await pb.collection("users").authWithPassword(email, password);
} catch (err: any) {
console.error("Error response:", err.response);
}
const authData = await pb.collection("users").authWithPassword(email, password);
console.log("Valid:", pb.authStore.isValid);
console.log("Token:", pb.authStore.token);
console.log("User ID:", pb.authStore.record?.id);
pb.authStore.clear();
-77
View File
@@ -1,77 +0,0 @@
// sharepointDownload.js
import axios from "axios";
import qs from "qs";
import dotenv from "dotenv";
import fs from "fs";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
// Secret stays in .env
const clientSecret = process.env.CLIENT_SECRET;
// -----------------------------
// GRAPH TOKEN
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
console.log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// DOWNLOAD BY ITEM ID
// -----------------------------
// Example link: https://czflex.sharepoint.com/sites/Team/Shared%20Documents/General/JOBS%201000-9999.xlsx?d=wc20d9b5023f84403b4d345ca88382eb4
// The ?d=... part is the itemId
async function downloadFileById(graphToken, sitePath, itemId) {
const url = `https://graph.microsoft.com/v1.0/sites/${sitePath}/drive/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer",
});
console.log("✅ File downloaded, bytes:", res.data.byteLength);
return res.data;
}
// -----------------------------
// MAIN
// -----------------------------
(async () => {
try {
const graphToken = await getGraphToken();
// sitePath matches your site
const sitePath = "czflex.sharepoint.com:/sites/Team";
// itemId from your link (?d=...)
const itemId = "wc20d9b5023f84403b4d345ca88382eb4";
const buffer = await downloadFileById(graphToken, sitePath, itemId);
// At this point you can feed buffer into XLSX.read(buffer, { type: "buffer", cellDates: true })
// Example:
// const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
// const sheetNames = workbook.SheetNames;
// console.log("Sheets:", sheetNames);
} catch (err) {
console.error("❌ Error:", err.response?.data || err.message || err);
}
})();
"id": "czflex.sharepoint.com,aaaa83f5-b1bd-4133-b60c-507030425863,0a423385-151c-44d7-b692-d85ae4bdfc0b"
-260
View File
@@ -1,260 +0,0 @@
// excelsync.js
import { FIELD_CONFIG } from "./fieldConfig.js";
import fs from "fs";
import axios from "axios";
import qs from "qs";
import dotenv from "dotenv";
import PocketBase from "pocketbase";
dotenv.config();
// -----------------------------
// EXCEL PARSER
// -----------------------------
const HEADER_ROW_INDEX = 2; // 0-index → Row 3 actual
const DATA_START_ROW_INDEX = 3; // 0-index → Row 4 actual
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection || "Job_Info_TestEnv";
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
fs.appendFileSync("excelsync.log", message + "\n");
}
// -----------------------------
// GRAPH TOKEN
// -----------------------------
async function getGraphToken() {
log("🔵 Requesting Graph token...");
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// POCKETBASE LOGIN
// -----------------------------
async function getPocketBaseClient() {
log("🔵 Logging into PocketBase...");
const pb = new PocketBase(pbUrl);
// ⚠️ CHANGE "users" if your auth collection is different
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// CLEAN + COERCE
// -----------------------------
function cleanString(value) {
if (value == null) return "";
return String(value).replace(/\u00A0/g, " ").trim();
}
function coerceValue(fieldConfig, rawValue) {
switch (fieldConfig.type) {
case "string": return cleanString(rawValue);
case "bool": return ["true","1","yes","y"].includes(cleanString(rawValue).toLowerCase());
case "date":
if (typeof rawValue === "number") {
const origin = new Date(Date.UTC(1899, 11, 30));
return new Date(origin.getTime() + rawValue * 86400000).toISOString();
}
const d = new Date(cleanString(rawValue));
return isNaN(d.getTime()) ? null : d.toISOString();
case "number": return Number(cleanString(rawValue));
case "json": try { return JSON.parse(cleanString(rawValue)); } catch { return null; }
case "relation": return cleanString(rawValue);
default: return cleanString(rawValue);
}
}
// -----------------------------
// PAYLOAD BUILDER
// -----------------------------
export function buildPayload(headers, row) {
const payload = {};
headers.forEach((excelHeader, i) => {
const fieldConfig = Object.values(FIELD_CONFIG).find(f => f.excelHeader === excelHeader);
if (!fieldConfig) return;
payload[fieldConfig.pbField] = coerceValue(fieldConfig, row[i]);
});
return payload;
}
// -----------------------------
// DIFFERENCE LOGGER + UPDATE CHECK
// -----------------------------
function logDifferences(pbRecord, headers, row) {
const diffs = [];
headers.forEach((excelHeader, i) => {
const fieldConfig = Object.values(FIELD_CONFIG).find(f => f.excelHeader === excelHeader);
if (!fieldConfig) return;
const newValue = coerceValue(fieldConfig, row[i]);
const currentValue = coerceValue(fieldConfig, pbRecord[fieldConfig.pbField]);
if (newValue !== currentValue) {
diffs.push({ field: fieldConfig.pbField, excelHeader, current: currentValue, incoming: newValue });
}
});
if (diffs.length > 0) {
log(`Differences detected for record: ${pbRecord.id}`);
diffs.forEach(d => {
log(`Field: ${d.field} (Excel: ${d.excelHeader})\n Current: ${JSON.stringify(d.current)}\n Incoming: ${JSON.stringify(d.incoming)}\n`);
});
}
}
export function needsUpdate(pbRecord, headers, row) {
let hasDiff = false;
headers.forEach((excelHeader, i) => {
const fieldConfig = Object.values(FIELD_CONFIG).find(f => f.excelHeader === excelHeader);
if (!fieldConfig) return;
const newValue = coerceValue(fieldConfig, row[i]);
const currentValue = coerceValue(fieldConfig, pbRecord[fieldConfig.pbField]);
if (newValue !== currentValue) hasDiff = true;
});
if (hasDiff) logDifferences(pbRecord, headers, row);
return hasDiff;
}
// -----------------------------
// GRAPH HELPERS
// -----------------------------
async function getUsedRangeInfo(graphToken, driveId, itemId, worksheetName) {
log(`🔵 Requesting usedRange for ${worksheetName}`);
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/worksheets('${worksheetName}')/usedRange`;
const res = await axios.get(url, { headers: { Authorization: `Bearer ${graphToken}` } });
log(`✅ Used range address: ${res.data.address}`);
return res.data.address;
}
function extractLastColumn(address) {
const match = address.match(/:([A-Z]+)\d+$/);
if (!match) {
log("⚠️ Falling back to column Z, check sheet width");
return "Z";
}
return match[1];
}
async function fetchExcelRange(graphToken, driveId, itemId, worksheetName, range) {
log(`🔵 Fetching range ${range} from worksheet ${worksheetName}`);
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/worksheets('${worksheetName}')/range(address='${range}')`;
const res = await axios.get(url, { headers: { Authorization: `Bearer ${graphToken}` } });
log(`✅ Range ${range} returned ${res.data.values?.length || 0} rows`);
return res.data.values || [];
}
async function fetchAllRows(graphToken, driveId, itemId, worksheetName, blockSize = 500) {
const address = await getUsedRangeInfo(graphToken, driveId, itemId, worksheetName);
const lastCol = extractLastColumn(address);
let allRows = [];
let startRow = 1;
while (true) {
const endRow = startRow + blockSize - 1;
const range = `A${startRow}:${lastCol}${endRow}`;
const rows = await fetchExcelRange(graphToken, driveId, itemId, worksheetName, range);
if (!rows.length) break;
allRows = allRows.concat(rows);
if (rows.length < blockSize) break;
startRow = endRow + 1;
}
return allRows;
}
// -----------------------------
// MAIN
// -----------------------------
(async () => {
try {
log("🔵 Checking token acquisition and excel readiness");
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const worksheetName = "Job Sheet";
const rows = await fetchAllRows(graphToken, driveId, itemId, worksheetName);
log(`✅ Total rows fetched: ${rows.length}`);
if (!rows.length) {
log("⚠️ No rows returned from Graph. Check driveId, itemId, and worksheetName.");
return;
}
const headers = rows[HEADER_ROW_INDEX];
const dataRows = rows.slice(DATA_START_ROW_INDEX);
if (!headers || !Array.isArray(headers)) {
log("❌ Header row not found at expected index. Verify HEADER_ROW_INDEX alignment.");
return;
}
log(`✅ Headers detected: ${JSON.stringify(headers)}`);
log(`✅ Data rows to process: ${dataRows.length}`);
for (const row of dataRows) {
if (!row || row.length === 0) continue; // skip empty rows
const payload = buildPayload(headers, row);
// Ensure primary key exists before querying PocketBase
const jobNumber = payload.Job_Number;
if (!jobNumber || String(jobNumber).trim() === "") {
log("⚠️ Skipping row with missing Job_Number.");
continue;
}
let existing = null;
try {
existing = await pb.collection(pbCollection).getFirstListItem(`Job_Number="${jobNumber}"`);
} catch {
existing = null;
}
if (!existing) {
log(` Creating new record: ${jobNumber}`);
await pb.collection(pbCollection).create(payload);
} else {
if (needsUpdate(existing, headers, row)) {
log(`✏️ Updating record: ${existing.id} (${jobNumber})`);
await pb.collection(pbCollection).update(existing.id, payload);
} else {
log(`✅ No changes for record: ${existing.id} (${jobNumber})`);
}
}
}
log("✅ Sync complete, PocketBase updated!");
} catch (err) {
log("❌ Error, not ready for updating. " + (err?.message || err));
process.exit(1);
}
})();
-136
View File
@@ -1,136 +0,0 @@
// excelsync.js
import fs from "fs";
import axios from "axios";
import qs from "qs";
// may need for reading excel
//import * as XLSX from "xlsx";
import dotenv from "dotenv";
// may need to serve html to port
//import { serve } from "bun";
import PocketBase from "pocketbase"
dotenv.config(); // connects to .env for sensitive info
// -----------------------------
// EXCEL PARSER
// -----------------------------
const HEADER_ROW_INDEX = 2; // O index --> Row 3 Actual
const DATA_START_ROW_INDEX = 3; // 0 index --> Row 4 Actual
const RANGE_HEADER_ROW = 3 // Excel index headers
const RANGE_DATA_ROW = 4 // Excel index data
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
//const sharepointFileUrl = CONFIG.sharepointFileUrl; // production Excel file URL
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection || "Job_Info_TestEnv";
const pbEmail = CONFIG.pbEmail;
const clientSecret = process.env.CLIENT_SECRET;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// LOGGING
// -----------------------------
function log(message) {
console.log(message);
fs.appendFileSync("excelsync.log", message + "\n");
}
// -----------------------------
// GRAPH TOKEN
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// POCKETBASE LOGIN AND TOKEN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// HEADER MAP
// -----------------------------
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",
"Tax_Exempt": "Tax_Exempt",
"Job Status": "Job_Status",
"Job Division": "Job_Division",
"Estimator": "Estimator",
"Office Rep": "Office_Rep",
"Manager": "Project_Manager",
"Company/Client": "Company_Client",
"Contact Person": "Contact_Person",
"Phone Number": "Phone_Number",
"Email": "Email",
"Due Date": "Due_Date",
"Due Date Source": "Due_Date_Source",
"Due Date Counter": "Due_Date_Counter",
"Due Time": "Due_Time",
"Tax_Exempt_Docs_Uploaded": "Tax_Exempt_Docs_Uploaded",
"Docs Uploaded": "Docs_Uploaded",
"QB Created": "QB_Created",
"Added to Calendar": "Added_To_Calendar",
"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_Confidence": "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",
"pb_id": "id"
};
// -----------------------------
// MAIN
// -----------------------------
(async () => {
try {
log("🔵 Checking token acquisition and excel readiness");
const graphToken = await getGraphToken();
const pb = await getPocketBaseClient();
// These can be update if the Excel File changes
//const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
//const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU"
await exceltopb(graphToken, driveId, itemId, "Job Sheet", pb);
await pbtoupdate(pbCollection);
log("✅ Ready for updating!");
} catch (err) {
log("❌ Error, not ready for updating." + (err.message || err));
process.exit(1);
}
})();
-105
View File
@@ -1,105 +0,0 @@
import axios from "axios";
import qs from "qs";
import fs from "fs";
import dotenv from "dotenv";
// -----------------------------
// CONFIG
// -----------------------------
dotenv.config();
// Load nonsensitive values from config.json
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const testSharePointFileUrl = CONFIG.testSharePointFileUrl;
// Load secret only from .env
const clientSecret = process.env.CLIENT_SECRET;
// -----------------------------
// Validation
// -----------------------------
const missing = [];
if (!tenantId) missing.push("tenantId");
if (!clientId) missing.push("clientId");
if (!clientSecret) missing.push("clientSecret");
if (!testSharePointFileUrl) missing.push("testSharePointFileUrl");
if (missing.length > 0) {
console.error("❌ Missing values:", missing.join(", "));
process.exit(1);
} else {
console.log("✅ All required values present");
}
// -----------------------------
// 1. Get Graph Token (Client Credentials)
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
try {
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
console.log("✅ Token acquired");
// can use to test to see the full token as needed, just remove this comment// console.log("🔑 Access Token:", res.data.access_token);
return res.data.access_token;
} catch (err) {
console.error("❌ Failed to acquire token");
console.error(err.response?.data || err.message);
process.exit(1);
}
}
// -----------------------------
// 2. Convert SharePoint File URL → Drive Item ID
// -----------------------------
function normalizeSharePointUrl(fileUrl) {
return fileUrl
.replace("guestaccess.aspx", "")
.replace(/\?.*$/, ""); // remove ?query
}
// -----------------------------
// 3. Test GET file metadata
// -----------------------------
async function testFileAccess(token) {
const cleanUrl = normalizeSharePointUrl(testSharePointFileUrl);
console.log("🔎 Normalized URL:", cleanUrl);
const graphUrl =
`https://graph.microsoft.com/v1.0/shares/u!${Buffer.from(cleanUrl).toString("base64")}/driveItem`;
try {
const res = await axios.get(graphUrl, {
headers: { Authorization: `Bearer ${token}` },
});
console.log("✅ SharePoint file access test succeeded!");
console.log("File name:", res.data.name);
console.log("Drive Item ID:", res.data.id);
} catch (err) {
console.error("❌ SharePoint file access failed");
console.error(err.response?.data || err.message);
}
}
// -----------------------------
// MAIN
// -----------------------------
(async () => {
console.log("🔵 Testing SharePoint Access…");
const token = await getGraphToken();
await testFileAccess(token);
})();
-204
View File
@@ -1,204 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
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
+2 -1
View File
@@ -3,7 +3,7 @@
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
@@ -17,6 +17,7 @@
// Best practices
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
-73
View File
@@ -1,73 +0,0 @@
import dotenv from "dotenv";
import PocketBase from "pocketbase";
import fs from "fs";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const superEmail = process.env.Super_Email;
const superPassword = process.env.Super_Password;
// -----------------------------
// ADMIN LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.admins.authWithPassword(superEmail, superPassword);
console.log("✅ Admin token acquired");
return pb;
}
// -----------------------------
// FETCH SCHEMA & SAVE TO FILE
// -----------------------------
async function getAndSaveSchema(pb) {
const collection = await pb.collections.getOne(pbCollection);
// Debug: log the full collection object
console.log("📦 Collection object:", JSON.stringify(collection, null, 2));
// Log raw schema directly
console.log("📑 Raw schema:", collection.schema);
if (collection.schema && Array.isArray(collection.schema)) {
if (collection.schema.length === 0) {
console.warn("⚠️ Schema array is empty for collection:", pbCollection);
return {};
}
// Build schema map (fieldName → type)
const schemaMap = {};
for (const field of collection.schema) {
schemaMap[field.name] = field.type;
}
// Save full schema JSON for later use
fs.writeFileSync("schema.json", JSON.stringify(collection.schema, null, 2), "utf8");
console.log("✅ Schema saved to schema.json");
console.log("Schema map:", schemaMap);
return schemaMap;
} else {
console.error("❌ Schema property missing or not an array. Collection object:", collection);
return {};
}
}
// -----------------------------
// MAIN
// -----------------------------
(async () => {
try {
const pb = await getPocketBaseClient();
await getAndSaveSchema(pb);
} catch (err) {
console.error("❌ Error fetching schema:", err);
}
})();
-65
View File
@@ -1,65 +0,0 @@
import dotenv from "dotenv";
import PocketBase from "pocketbase";
import fs from "fs";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const superEmail = process.env.Super_Email;
const superPassword = process.env.Super_Password;
// -----------------------------
// ADMIN LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.admins.authWithPassword(superEmail, superPassword);
console.log("✅ Admin token acquired");
return pb;
}
// -----------------------------
// FETCH SCHEMA & SAVE TO FILE
// -----------------------------
async function getAndSaveSchema(pb) {
const collection = await pb.collections.getOne(pbCollection);
// Debug: log the full collection object so you can inspect it
console.log("📦 Collection object:", JSON.stringify(collection, null, 2));
if (!collection.schema) {
console.error("❌ No schema property found on collection object.");
return {};
}
// Build schema map (fieldName → type)
const schemaMap = {};
for (const field of collection.schema) {
schemaMap[field.name] = field.type;
}
// Save full schema JSON for later use
fs.writeFileSync("schema.json", JSON.stringify(collection.schema, null, 2), "utf8");
console.log("✅ Schema saved to schema.json");
console.log("Schema map:", schemaMap);
return schemaMap;
}
// -----------------------------
// MAIN
// -----------------------------
(async () => {
try {
const pb = await getPocketBaseClient();
await getAndSaveSchema(pb);
} catch (err) {
console.error("❌ Error fetching schema:", err);
}
})();
-145
View File
@@ -1,145 +0,0 @@
//works to update pb field
import dotenv from "dotenv";
import PocketBase from "pocketbase";
import fs from "fs";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const pbUrl = CONFIG.pbUrl;
const pbCollection = CONFIG.pbCollection;
const pbEmail = CONFIG.pbEmail;
const pbPassword = process.env.PB_PASSWORD;
// -----------------------------
// PB LOGIN
// -----------------------------
async function getPocketBaseClient() {
const pb = new PocketBase(pbUrl);
await pb.collection("users").authWithPassword(pbEmail, pbPassword);
console.log("✅ PocketBase token acquired");
return pb;
}
// -----------------------------
// UPDATE FROM JSON EXPORT
// -----------------------------
async function updateFromJSON(pb) {
const data = JSON.parse(fs.readFileSync("records.json", "utf8"));
const errors = [];
for (const record of data) {
try {
// Strip system fields from payload
const { id, created, updated, expand, ...payload } = record;
// Update by id
await pb.collection(pbCollection).update(id, payload);
} catch (err) {
errors.push({ id: record.id, error: err.message });
}
}
if (errors.length) {
console.error("❌ Errors during update:", errors);
} else {
console.log("✅ All records updated successfully");
}
return data;
}
// -----------------------------
// FETCH CURRENT RECORDS
// -----------------------------
async function getPocketBaseRecords(pb) {
return await pb.collection(pbCollection).getFullList();
}
// -----------------------------
// NORMALIZATION HELPERS
// -----------------------------
function normalizeValue(field, value) {
// Adjust based on common PB schema rules
if (value === undefined) return null;
// Example: treat text fields like Note_Ref as "" when empty
if (field.toLowerCase().includes("note") || field.toLowerCase().includes("ref")) {
return value === null ? "" : String(value);
}
// Numbers
if (typeof value === "number") return value;
if (value === null || value === "") return null;
// Booleans
if (typeof value === "boolean") return value;
// Dates
if (value instanceof Date) return value.toISOString();
if (Date.parse(value)) return new Date(value).toISOString();
return value;
}
// -----------------------------
// COMPARE ORIGINAL VS CURRENT
// -----------------------------
function compareRecords(original, current) {
const diffs = [];
for (const orig of original) {
const curr = current.find(r => r.id === orig.id);
if (!curr) {
diffs.push({ id: orig.id, issue: "Missing in current DB" });
continue;
}
const fields = Object.keys(orig).filter(
k => !["id", "created", "updated", "expand"].includes(k)
);
for (const f of fields) {
const origVal = normalizeValue(f, orig[f]);
const currVal = normalizeValue(f, curr[f]);
if (origVal !== currVal) {
diffs.push({
id: orig.id,
field: f,
original: origVal,
current: currVal
});
}
}
}
return diffs;
}
// -----------------------------
// MAIN TEST
// -----------------------------
(async () => {
const pb = await getPocketBaseClient();
// Step 1: Update from your existing export
const original = await updateFromJSON(pb);
// Step 2: Fetch current records
const current = await getPocketBaseRecords(pb);
// Step 3: Compare
const diffs = compareRecords(original, current);
if (diffs.length) {
console.error("❌ Differences found:");
for (const d of diffs) {
console.log(
`Record ${d.id}, field ${d.field}\n original: ${d.original}\n current : ${d.current}\n`
);
}
} else {
console.log("✅ No differences — roundtrip succeeded");
}
})();
+1 -1
View File
@@ -1,3 +1,3 @@
[dev]
port = 3000
port = 5000
static = "./public"
-1
View File
@@ -1 +0,0 @@
console.log("Hello via Bun!");
+4 -1
View File
@@ -1,6 +1,9 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Auth Response</title>
</head>
<body>
-91
View File
@@ -1,91 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Impersonation</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
#banner { display:none; background:#ffcc00; padding:1rem; margin-bottom:1rem; font-weight:bold; }
#form { display:flex; flex-direction:column; max-width:400px; }
label { margin:0.5rem 0 0.2rem; }
input { padding:0.5rem; font-size:1rem; }
button { margin-top:1rem; padding:0.5rem; font-size:1rem; }
</style>
</head>
<body>
<div id="banner">⚠️ Impersonation Mode Active</div>
<h1>Test Impersonation</h1>
<div id="form">
<label for="targetInput">User ID or Email:</label>
<input type="text" id="targetInput" placeholder="Enter user ID or email">
<button id="impersonateBtn">Impersonate</button>
<button id="returnBtn" style="display:none;">Return to Admin</button>
<button id="goMainBtn" style="display:none;">Go to Main App</button>
</div>
<script>
const banner = document.getElementById('banner');
const targetInput = document.getElementById('targetInput');
const impersonateBtn = document.getElementById('impersonateBtn');
const returnBtn = document.getElementById('returnBtn');
const goMainBtn = document.getElementById('goMainBtn');
let originalToken = localStorage.getItem('pb_original_token') || localStorage.getItem('pb_token') || '';
impersonateBtn.addEventListener('click', async () => {
const identifier = targetInput.value.trim();
if (!identifier) return alert('Enter a user ID or email');
if (!originalToken) return alert('You must be logged in as Admin/Superuser');
try {
const res = await fetch('http://localhost:8081/api/impersonate', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + originalToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ targetUserIdentifier: identifier })
});
if (!res.ok) return alert('Failed: ' + await res.text());
const data = await res.json();
localStorage.setItem('pb_token', data.token);
if (!localStorage.getItem('pb_original_token')) {
localStorage.setItem('pb_original_token', originalToken);
}
banner.style.display = 'block';
returnBtn.style.display = 'inline-block';
goMainBtn.style.display = 'inline-block';
impersonateBtn.disabled = true;
alert('Now impersonating ' + identifier);
} catch (err) { alert('Error: ' + err.message); }
});
returnBtn.addEventListener('click', () => {
const orig = localStorage.getItem('pb_original_token');
if (!orig) return alert('Original token not found');
localStorage.setItem('pb_token', orig);
localStorage.removeItem('pb_original_token');
banner.style.display = 'none';
returnBtn.style.display = 'none';
goMainBtn.style.display = 'none';
impersonateBtn.disabled = false;
alert('Returned to Admin session');
});
goMainBtn.addEventListener('click', () => window.location.href = '/index.html');
if (localStorage.getItem('pb_original_token') && localStorage.getItem('pb_token') !== localStorage.getItem('pb_original_token')) {
banner.style.display = 'block';
returnBtn.style.display = 'inline-block';
goMainBtn.style.display = 'inline-block';
impersonateBtn.disabled = true;
}
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
import fs from "fs";
import path from "path";
import axios from "axios";
import qs from "qs";
import dotenv from "dotenv";
import PocketBase from "pocketbase";
import XLSX from "xlsx";
dotenv.config();
// -----------------------------
// CONFIG
// -----------------------------
const CONFIG = JSON.parse(fs.readFileSync("config.json", "utf8"));
const tenantId = CONFIG.tenantId;
const clientId = CONFIG.clientId;
const clientSecret = process.env.CLIENT_SECRET;
// -----------------------------
// EXCEL HEADER AND DATA ROW PARAMETERS
// -----------------------------
const HEADER_ROW_INDEX = 1; // headers on row 2 (index 1)
const DATA_START_ROW_INDEX = 2; // data starts on row 3 (index 2)
// -----------------------------
// GRAPH TOKEN ACQUISITION
// -----------------------------
async function getGraphToken() {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = qs.stringify({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
});
const res = await axios.post(tokenUrl, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
log("✅ Graph token acquired");
return res.data.access_token;
}
// -----------------------------
// GRAPH EXCEL DOWNLOAD HELPER
// -----------------------------
async function downloadExcelFromGraph(graphToken, driveId, itemId) {
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${graphToken}` },
responseType: "arraybuffer",
});
return Buffer.from(res.data);
}
// -----------------------------
// MAIN SYNC LOGIC
// -----------------------------
async function runSync() {
try {
const graphToken = await getGraphToken();
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
} catch (error) {
console.error("❌ Error during sync:", error);
}}
+2 -1
View File
@@ -3,7 +3,7 @@
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"module": "ESnext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
@@ -17,6 +17,7 @@
// Best practices
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
File diff suppressed because it is too large Load Diff