Looped sync works, but is slow. No retry or resiliency built in.
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
console.log("Hello via Bun!");
|
|
||||||
-3739
File diff suppressed because one or more lines are too long
-198116
File diff suppressed because it is too large
Load Diff
+39
-26
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -24,9 +23,8 @@ const pbPassword = process.env.PB_PASSWORD;
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// EXCEL HEADER AND DATA ROW PARAMETERS
|
// 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; // headers on row 2 (index 1)
|
||||||
const HEADER_ROW_INDEX = 1;
|
const DATA_START_ROW_INDEX = 2; // data starts on row 3 (index 2)
|
||||||
const DATA_START_ROW_INDEX = 2;
|
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// LOGGING
|
// LOGGING
|
||||||
@@ -46,17 +44,15 @@ function log(message) {
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
function convertExcelDateToUTC(value) {
|
function convertExcelDateToUTC(value) {
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
// Excel serial date
|
|
||||||
const dateObj = XLSX.SSF.parse_date_code(value);
|
const dateObj = XLSX.SSF.parse_date_code(value);
|
||||||
if (!dateObj) return null;
|
if (!dateObj) return null;
|
||||||
const jsDate = new Date(Date.UTC(dateObj.y, dateObj.m - 1, dateObj.d, dateObj.H, dateObj.M, dateObj.S));
|
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") {
|
} else if (typeof value === "string") {
|
||||||
// Try parsing string date
|
|
||||||
const jsDate = new Date(value);
|
const jsDate = new Date(value);
|
||||||
return isNaN(jsDate.getTime()) ? value : jsDate.toISOString();
|
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
|
// MAIN SYNC LOGIC
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
(async () => {
|
async function runSync() {
|
||||||
try {
|
try {
|
||||||
const graphToken = await getGraphToken();
|
const graphToken = await getGraphToken();
|
||||||
const pb = await getPocketBaseClient();
|
const pb = await getPocketBaseClient();
|
||||||
|
|
||||||
// These IDs point to your Excel file in OneDrive/SharePoint
|
|
||||||
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
|
const driveId = "b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx";
|
||||||
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
|
const itemId = "01SPNXLDSQTMG4F6BDANCLJU2FZKEDQLVU";
|
||||||
|
|
||||||
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
|
const excelBuffer = await downloadExcelFromGraph(graphToken, driveId, itemId);
|
||||||
|
|
||||||
// Parse Excel
|
|
||||||
const workbook = XLSX.read(excelBuffer, { type: "buffer" });
|
const workbook = XLSX.read(excelBuffer, { type: "buffer" });
|
||||||
const worksheet = workbook.Sheets["Job_List_Range"];
|
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 headers = rows[HEADER_ROW_INDEX];
|
||||||
const dataRows = rows.slice(DATA_START_ROW_INDEX); // Row 3 onward
|
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) {
|
for (const row of dataRows) {
|
||||||
if (!row || row.length === 0) continue; // Skip empty rows
|
if (!row || row.length === 0) continue;
|
||||||
|
|
||||||
const record = {};
|
const record = {};
|
||||||
headers.forEach((header, i) => {
|
headers.forEach((header, i) => {
|
||||||
let cellValue = row[i];
|
let cellValue = row[i];
|
||||||
// ✅ Convert all date fields to UTC ISO format
|
|
||||||
if (header.toLowerCase().includes("date")) {
|
if (header.toLowerCase().includes("date")) {
|
||||||
cellValue = convertExcelDateToUTC(cellValue);
|
cellValue = convertExcelDateToUTC(cellValue);
|
||||||
}
|
}
|
||||||
record[header] = cellValue;
|
record[header] = cellValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobNumber = record["Job_Number"];
|
const jobNumber = (record["Job_Number"] || "").toString().trim();
|
||||||
if (!jobNumber) {
|
if (!jobNumber) {
|
||||||
log("⚠️ Skipped row with no Job_Number");
|
log("⚠️ Skipped row with no Job_Number");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if record exists
|
|
||||||
const existing = await pb.collection(pbCollection).getList(1, 1, {
|
const existing = await pb.collection(pbCollection).getList(1, 1, {
|
||||||
filter: `Job_Number="${jobNumber}"`,
|
filter: `Job_Number="${jobNumber}"`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing.items.length > 0) {
|
if (existing.items.length > 0) {
|
||||||
const id = existing.items[0].id;
|
const id = existing.items[0].id;
|
||||||
// Clear all fields except Job_Number
|
await pb.collection(pbCollection).update(id, record);
|
||||||
const clearedData = { Job_Number: jobNumber };
|
|
||||||
Object.assign(clearedData, record);
|
|
||||||
await pb.collection(pbCollection).update(id, clearedData);
|
|
||||||
log(`✅ Updated record for Job_Number: ${jobNumber}`);
|
log(`✅ Updated record for Job_Number: ${jobNumber}`);
|
||||||
} else {
|
} else {
|
||||||
await pb.collection(pbCollection).create(record);
|
await pb.collection(pbCollection).create(record);
|
||||||
log(`✅ Created new record for Job_Number: ${jobNumber}`);
|
log(`✅ Created new record for Job_Number: ${jobNumber}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
} catch (err) {
|
||||||
log(`❌ Fatal error: ${err.message}`);
|
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();
|
||||||
@@ -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.");
|
|
||||||
}
|
|
||||||
@@ -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.`);
|
|
||||||
@@ -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
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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}`);
|
|
||||||
//}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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"
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import qs from "qs";
|
|
||||||
import fs from "fs";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// CONFIG
|
|
||||||
// -----------------------------
|
|
||||||
dotenv.config();
|
|
||||||
// Load non‑sensitive 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);
|
|
||||||
})();
|
|
||||||
@@ -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
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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 — round‑trip succeeded");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
[dev]
|
[dev]
|
||||||
port = 3000
|
port = 5000
|
||||||
static = "./public"
|
static = "./public"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
console.log("Hello via Bun!");
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Auth Response</title>
|
<title>Auth Response</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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);
|
||||||
|
}}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "ESnext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user