|
|
|
@@ -0,0 +1,573 @@
|
|
|
|
|
/* Minimal uploader: create folder, subfolder, upload PDF
|
|
|
|
|
Uses app-only MSAL client credentials from environment variables.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Load .env if present so this script can be run from the project folder
|
|
|
|
|
try { require('dotenv').config(); } catch (e) {}
|
|
|
|
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const fetch = require('node-fetch');
|
|
|
|
|
const { ConfidentialClientApplication } = require('@azure/msal-node');
|
|
|
|
|
const XLSX = require('xlsx');
|
|
|
|
|
|
|
|
|
|
// Shared cell formatter used by PDF generators
|
|
|
|
|
function formatCellValue(val) {
|
|
|
|
|
if (val === null || val === undefined) return '';
|
|
|
|
|
if (val instanceof Date && !isNaN(val.getTime())) {
|
|
|
|
|
const mm = String(val.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const dd = String(val.getDate()).padStart(2, '0');
|
|
|
|
|
const yyyy = val.getFullYear();
|
|
|
|
|
const hh = String(val.getHours()).padStart(2, '0');
|
|
|
|
|
const mi = String(val.getMinutes()).padStart(2, '0');
|
|
|
|
|
return `${mm}/${dd}/${yyyy} ${hh}:${mi}`;
|
|
|
|
|
}
|
|
|
|
|
if (typeof val === 'number') {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = XLSX.SSF.parse_date_code(val);
|
|
|
|
|
if (parsed && parsed.y) {
|
|
|
|
|
const dt = new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d, parsed.H || 0, parsed.M || 0, parsed.S || 0));
|
|
|
|
|
return formatCellValue(dt);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
if (typeof val === 'string') {
|
|
|
|
|
const s = val.trim();
|
|
|
|
|
const maybe = Date.parse(s);
|
|
|
|
|
if (!isNaN(maybe)) return formatCellValue(new Date(maybe));
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
return String(val);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CLIENT_ID = process.env.CLIENT_ID;
|
|
|
|
|
const CLIENT_SECRET = process.env.CLIENT_SECRET;
|
|
|
|
|
const TENANT_ID = process.env.TENANT_ID;
|
|
|
|
|
const DRIVE_ID = process.env.DRIVE_ID;
|
|
|
|
|
// Support both PARENT_ITEM_ID (preferred) and ITEM_ID (legacy / alternate)
|
|
|
|
|
const PARENT_ITEM_ID = process.env.PARENT_ITEM_ID || process.env.ITEM_ID;
|
|
|
|
|
|
|
|
|
|
const dryRun = process.argv.includes('--dry-run');
|
|
|
|
|
|
|
|
|
|
function assertEnv(name, val) {
|
|
|
|
|
if (!val) {
|
|
|
|
|
console.error(`Missing env var: ${name}`);
|
|
|
|
|
process.exitCode = 2;
|
|
|
|
|
throw new Error(`Missing env var ${name}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
assertEnv('CLIENT_ID', CLIENT_ID);
|
|
|
|
|
assertEnv('CLIENT_SECRET', CLIENT_SECRET);
|
|
|
|
|
assertEnv('TENANT_ID', TENANT_ID);
|
|
|
|
|
assertEnv('DRIVE_ID', DRIVE_ID);
|
|
|
|
|
assertEnv('PARENT_ITEM_ID', PARENT_ITEM_ID);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Please set required environment values before running. See .env.example');
|
|
|
|
|
process.exit(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const msalConfig = {
|
|
|
|
|
auth: {
|
|
|
|
|
clientId: CLIENT_ID,
|
|
|
|
|
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
|
|
|
|
|
clientSecret: CLIENT_SECRET
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cca = new ConfidentialClientApplication(msalConfig);
|
|
|
|
|
|
|
|
|
|
async function getAppToken() {
|
|
|
|
|
const result = await cca.acquireTokenByClientCredential({ scopes: [ 'https://graph.microsoft.com/.default' ] });
|
|
|
|
|
return result.accessToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Small made-up (valid-ish) PDF bytes (1-page blank PDF) base64
|
|
|
|
|
const SAMPLE_PDF_BASE64 = Buffer.from(
|
|
|
|
|
`%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>\nendobj\n4 0 obj\n<< /Length 55 >>\nstream\nBT /F1 24 Tf 72 720 Td (Sample PDF uploaded by minimal-uploader) Tj ET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f\n0000000010 00000 n\n0000000066 00000 n\n0000000111 00000 n\n0000000200 00000 n\ntrailer<< /Size 5 /Root 1 0 R >>\nstartxref\n300\n%%EOF\n`).toString('base64');
|
|
|
|
|
|
|
|
|
|
// If managers.csv exists in the folder, build a PDF from it and return a base64 string
|
|
|
|
|
const pathToCsv = path.join(__dirname, 'managers.csv');
|
|
|
|
|
const { parse } = require('csv-parse/sync');
|
|
|
|
|
const PDFDocument = require('pdfkit');
|
|
|
|
|
|
|
|
|
|
async function pdfFromCsvToBase64(csvPath) {
|
|
|
|
|
if (!fs.existsSync(csvPath)) return null;
|
|
|
|
|
const csvRaw = fs.readFileSync(csvPath, 'utf8');
|
|
|
|
|
const records = parse(csvRaw, { columns: true, skip_empty_lines: true });
|
|
|
|
|
|
|
|
|
|
// Create PDF in memory using PDFKit
|
|
|
|
|
const doc = new PDFDocument({ size: 'A4', margin: 40 });
|
|
|
|
|
const buffers = [];
|
|
|
|
|
doc.on('data', (chunk) => buffers.push(chunk));
|
|
|
|
|
const title = 'Managers Info';
|
|
|
|
|
doc.fontSize(16).text(title, { align: 'left' }).moveDown(0.5);
|
|
|
|
|
|
|
|
|
|
// table header
|
|
|
|
|
const headers = Object.keys(records[0] || {});
|
|
|
|
|
doc.fontSize(10);
|
|
|
|
|
const columnWidths = headers.map(() => Math.floor((doc.page.width - 80) / headers.length));
|
|
|
|
|
|
|
|
|
|
// Render header row
|
|
|
|
|
headers.forEach((h, i) => {
|
|
|
|
|
doc.font('Helvetica-Bold').text(h, { continued: i !== headers.length - 1, width: columnWidths[i] });
|
|
|
|
|
});
|
|
|
|
|
doc.moveDown(0.25);
|
|
|
|
|
doc.font('Helvetica');
|
|
|
|
|
|
|
|
|
|
// Render rows limited to 49 (A1:E49) but safe if fewer
|
|
|
|
|
const limit = Math.min(records.length, 49);
|
|
|
|
|
for (let r = 0; r < limit; r++) {
|
|
|
|
|
const row = records[r];
|
|
|
|
|
headers.forEach((h, i) => {
|
|
|
|
|
const txt = row[h] !== undefined ? String(row[h]) : '';
|
|
|
|
|
doc.text(txt, { continued: i !== headers.length - 1, width: columnWidths[i] });
|
|
|
|
|
});
|
|
|
|
|
doc.moveDown(0.1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.end();
|
|
|
|
|
await new Promise((res) => doc.on('end', res));
|
|
|
|
|
const pdfBuffer = Buffer.concat(buffers);
|
|
|
|
|
return pdfBuffer.toString('base64');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// build PDF from a records array (array of objects)
|
|
|
|
|
async function pdfFromRecordsToBase64(records, title = 'Managers Info') {
|
|
|
|
|
if (!records || records.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Use the shared top-level formatCellValue helper
|
|
|
|
|
|
|
|
|
|
const doc = new PDFDocument({ size: 'A4', margin: 36 });
|
|
|
|
|
const buffers = [];
|
|
|
|
|
doc.on('data', (chunk) => buffers.push(chunk));
|
|
|
|
|
doc.on('error', (e) => console.error('PDF generation error', e));
|
|
|
|
|
|
|
|
|
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
|
|
|
const startX = doc.x;
|
|
|
|
|
let cursorY = doc.y;
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
|
doc.font('Helvetica-Bold').fontSize(14).text(title, startX, cursorY);
|
|
|
|
|
cursorY = doc.y + 8;
|
|
|
|
|
|
|
|
|
|
// Setup table
|
|
|
|
|
const headers = Object.keys(records[0] || {});
|
|
|
|
|
const columnCount = Math.max(1, headers.length);
|
|
|
|
|
const baseWidth = Math.floor(pageWidth / columnCount);
|
|
|
|
|
const columnWidths = headers.map((_, idx) => {
|
|
|
|
|
if (columnCount >= 4 && idx === 0) return Math.floor(baseWidth * 1.5);
|
|
|
|
|
if (columnCount >= 4 && idx === 1) return Math.floor(baseWidth * 1.25);
|
|
|
|
|
return baseWidth;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tableX = startX;
|
|
|
|
|
let tableY = cursorY;
|
|
|
|
|
const rowPadding = 6;
|
|
|
|
|
|
|
|
|
|
// Header row
|
|
|
|
|
const headerHeight = 20;
|
|
|
|
|
doc.rect(tableX, tableY, pageWidth, headerHeight).fill('#f2f2f2');
|
|
|
|
|
doc.fillColor('#000');
|
|
|
|
|
let cx = tableX;
|
|
|
|
|
for (let i = 0; i < headers.length; i++) {
|
|
|
|
|
doc.font('Helvetica-Bold').fontSize(10).fillColor('#000').text(String(headers[i] || ''), cx + rowPadding, tableY + 6, { width: columnWidths[i] - rowPadding * 2 });
|
|
|
|
|
cx += columnWidths[i];
|
|
|
|
|
}
|
|
|
|
|
doc.moveTo(tableX, tableY + headerHeight).lineTo(tableX + pageWidth, tableY + headerHeight).stroke();
|
|
|
|
|
cursorY = tableY + headerHeight;
|
|
|
|
|
|
|
|
|
|
// Rows
|
|
|
|
|
const limit = Math.min(records.length, 49);
|
|
|
|
|
for (let r = 0; r < limit; r++) {
|
|
|
|
|
const row = records[r];
|
|
|
|
|
const cellTexts = headers.map(h => formatCellValue(row[h]));
|
|
|
|
|
|
|
|
|
|
const heights = cellTexts.map((t, i) => doc.heightOfString(t || '', { width: columnWidths[i] - rowPadding * 2 }));
|
|
|
|
|
const rowHeight = Math.max(heights.reduce((a,b) => Math.max(a,b), 0), 12) + rowPadding * 2;
|
|
|
|
|
|
|
|
|
|
// page break
|
|
|
|
|
if (cursorY + rowHeight + 40 > doc.page.height - doc.page.margins.bottom) {
|
|
|
|
|
doc.addPage();
|
|
|
|
|
cursorY = doc.page.margins.top;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// background for alternating rows
|
|
|
|
|
if (r % 2 === 1) {
|
|
|
|
|
doc.rect(tableX, cursorY, pageWidth, rowHeight).fill('#fcfcfc');
|
|
|
|
|
doc.fillColor('#000');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cx = tableX;
|
|
|
|
|
for (let i = 0; i < headers.length; i++) {
|
|
|
|
|
doc.font('Helvetica').fontSize(9).fillColor('#000').text(cellTexts[i] || '', cx + rowPadding, cursorY + rowPadding, { width: columnWidths[i] - rowPadding * 2 });
|
|
|
|
|
// vertical separator
|
|
|
|
|
doc.moveTo(cx + columnWidths[i], cursorY).lineTo(cx + columnWidths[i], cursorY + rowHeight).stroke();
|
|
|
|
|
cx += columnWidths[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// bottom border
|
|
|
|
|
doc.moveTo(tableX, cursorY + rowHeight).lineTo(tableX + pageWidth, cursorY + rowHeight).stroke();
|
|
|
|
|
cursorY += rowHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.end();
|
|
|
|
|
await new Promise((res) => doc.on('end', res));
|
|
|
|
|
const pdfBuffer = Buffer.concat(buffers);
|
|
|
|
|
return pdfBuffer.toString('base64');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function downloadExcelFromGraph(accessToken, driveId, itemId) {
|
|
|
|
|
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
|
|
|
|
|
const res = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, });
|
|
|
|
|
if (!res.ok) throw new Error(`Download workbook failed: ${res.status} ${res.statusText}`);
|
|
|
|
|
const ab = await res.arrayBuffer();
|
|
|
|
|
return Buffer.from(ab);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseManagersSheetFromWorkbookBuffer(buffer) {
|
|
|
|
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
|
|
|
// try the obvious names, otherwise find any sheet name that contains 'manager'
|
|
|
|
|
let sheet = workbook.Sheets['Managers Info'] || workbook.Sheets['Managers'] || null;
|
|
|
|
|
if (!sheet) {
|
|
|
|
|
const names = workbook.SheetNames || [];
|
|
|
|
|
const match = names.find(n => /manager/i.test(n));
|
|
|
|
|
if (match) sheet = workbook.Sheets[match];
|
|
|
|
|
else {
|
|
|
|
|
console.log('Workbook sheets:', names);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!sheet) return null;
|
|
|
|
|
// get A1:E49 region - using sheet_to_json with header row
|
|
|
|
|
const options = { header: 1, range: 'A1:E49', blankrows: false, defval: '' };
|
|
|
|
|
const rows = XLSX.utils.sheet_to_json(sheet, options);
|
|
|
|
|
if (!rows || rows.length === 0) return null;
|
|
|
|
|
// first row assumed to be headers
|
|
|
|
|
const headers = rows[0].map(h => String(h).trim());
|
|
|
|
|
const dataRows = rows.slice(1).map(r => {
|
|
|
|
|
const obj = {};
|
|
|
|
|
headers.forEach((h, i) => { obj[h || `col${i}`] = r[i] !== undefined ? r[i] : ''; });
|
|
|
|
|
return obj;
|
|
|
|
|
});
|
|
|
|
|
return dataRows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helpers to read cells / ranges by address from a sheet
|
|
|
|
|
function getCellValue(sheet, addr) {
|
|
|
|
|
const cell = sheet[addr];
|
|
|
|
|
if (!cell) return '';
|
|
|
|
|
return cell.w || cell.v || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRangeRows(sheet, rangeStr) {
|
|
|
|
|
// decode range and return array of row arrays
|
|
|
|
|
const r = XLSX.utils.decode_range(rangeStr);
|
|
|
|
|
const rows = [];
|
|
|
|
|
for (let R = r.s.r; R <= r.e.r; ++R) {
|
|
|
|
|
const row = [];
|
|
|
|
|
for (let C = r.s.c; C <= r.e.c; ++C) {
|
|
|
|
|
const addr = XLSX.utils.encode_cell({ c: C, r: R });
|
|
|
|
|
row.push(getCellValue(sheet, addr));
|
|
|
|
|
}
|
|
|
|
|
rows.push(row);
|
|
|
|
|
}
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build PDF from workbook buffer with special region formatting (merged ranges and blocks)
|
|
|
|
|
async function pdfFromWorkbookBufferToBase64(buffer) {
|
|
|
|
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
|
|
|
// find a Managers-like sheet
|
|
|
|
|
let sheetName = workbook.SheetNames.find(n => /Managers Info/i.test(n)) || workbook.SheetNames.find(n => /Managers/i.test(n));
|
|
|
|
|
if (!sheetName) sheetName = workbook.SheetNames[0];
|
|
|
|
|
const sheet = workbook.Sheets[sheetName];
|
|
|
|
|
if (!sheet) return null;
|
|
|
|
|
|
|
|
|
|
// Prepare doc
|
|
|
|
|
const doc = new PDFDocument({ size: 'A4', margin: 36 });
|
|
|
|
|
const buffers = [];
|
|
|
|
|
doc.on('data', (chunk) => buffers.push(chunk));
|
|
|
|
|
|
|
|
|
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
|
|
|
const leftX = doc.page.margins.left;
|
|
|
|
|
let y = doc.y;
|
|
|
|
|
|
|
|
|
|
// Top title (sheet title)
|
|
|
|
|
doc.font('Helvetica-Bold').fontSize(16).text(sheetName || 'Managers Info', { align: 'center' });
|
|
|
|
|
y = doc.y + 6;
|
|
|
|
|
|
|
|
|
|
// 1) B2:D3 merged: center horizontally and bottom align vertically, 18pt bold dark-blue
|
|
|
|
|
const b2Value = getCellValue(sheet, 'B2');
|
|
|
|
|
if (b2Value) {
|
|
|
|
|
// prefer Arial if available, otherwise use Helvetica-Bold as a safe built-in fallback
|
|
|
|
|
let usedFont = null;
|
|
|
|
|
try { doc.font('Arial'); usedFont = 'Arial'; } catch (e) { /* not available */ }
|
|
|
|
|
if (!usedFont) {
|
|
|
|
|
try { doc.font('Helvetica-Bold'); usedFont = 'Helvetica-Bold'; } catch (e) { /* ultimate fallback */ }
|
|
|
|
|
}
|
|
|
|
|
doc.fillColor('#003366').fontSize(18);
|
|
|
|
|
// center the text inside about 60% of page width
|
|
|
|
|
const boxW = Math.floor(pageWidth * 0.6);
|
|
|
|
|
const x = leftX + Math.floor((pageWidth - boxW) / 2);
|
|
|
|
|
// treat the merged region height as a small fixed box so we can bottom-align the text
|
|
|
|
|
const boxH = 48; // reasonable height for two-row merged area
|
|
|
|
|
// ensure there's enough space on the page
|
|
|
|
|
if (y + boxH > doc.page.height - doc.page.margins.bottom) { doc.addPage(); y = doc.page.margins.top; }
|
|
|
|
|
const h = doc.heightOfString(String(b2Value), { width: boxW });
|
|
|
|
|
// place the text so its bottom is just above the box bottom (6px padding)
|
|
|
|
|
const textY = y + Math.max(0, boxH - h - 6);
|
|
|
|
|
doc.text(String(b2Value), x, textY, { width: boxW, align: 'center' });
|
|
|
|
|
y += boxH + 8;
|
|
|
|
|
doc.fillColor('#000'); doc.font('Helvetica');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spacer
|
|
|
|
|
y += 4;
|
|
|
|
|
|
|
|
|
|
// 2) B5:D5 merged left-bottom aligned (smaller title/label)
|
|
|
|
|
const b5Value = getCellValue(sheet, 'B5');
|
|
|
|
|
if (b5Value) {
|
|
|
|
|
doc.font('Helvetica-Bold').fontSize(12).fillColor('#000');
|
|
|
|
|
const boxW2 = Math.floor(pageWidth * 0.6);
|
|
|
|
|
const boxH2 = 22; // single-row height approximation
|
|
|
|
|
if (y + boxH2 > doc.page.height - doc.page.margins.bottom) { doc.addPage(); y = doc.page.margins.top; }
|
|
|
|
|
// compute measured height and bottom align inside box
|
|
|
|
|
const h2 = doc.heightOfString(String(b5Value), { width: boxW2 });
|
|
|
|
|
const textY2 = y + Math.max(0, boxH2 - h2 - 4);
|
|
|
|
|
doc.text(String(b5Value), leftX, textY2, { width: boxW2, align: 'left' });
|
|
|
|
|
y += boxH2 + 6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3) B6:C9 cells -> render small grid block
|
|
|
|
|
const block1 = getRangeRows(sheet, 'B6:C9');
|
|
|
|
|
if (block1 && block1.length > 0) {
|
|
|
|
|
// render as two-column box
|
|
|
|
|
const colCount = block1[0].length;
|
|
|
|
|
const columnW = Math.floor((pageWidth * 0.5) / Math.max(1, colCount));
|
|
|
|
|
// header style omitted (these are raw cells)
|
|
|
|
|
for (let r = 0; r < block1.length; r++) {
|
|
|
|
|
const row = block1[r];
|
|
|
|
|
let x = leftX;
|
|
|
|
|
let maxH = 0;
|
|
|
|
|
for (let c = 0; c < row.length; c++) {
|
|
|
|
|
const txt = String(row[c] || '');
|
|
|
|
|
const h = doc.heightOfString(txt, { width: columnW - 6 });
|
|
|
|
|
maxH = Math.max(maxH, h);
|
|
|
|
|
}
|
|
|
|
|
// draw cells
|
|
|
|
|
for (let c = 0; c < row.length; c++) {
|
|
|
|
|
const txt = String(row[c] || '');
|
|
|
|
|
doc.rect(x, y, columnW, maxH + 8).stroke();
|
|
|
|
|
doc.text(txt, x + 4, y + 4, { width: columnW - 8 });
|
|
|
|
|
x += columnW;
|
|
|
|
|
}
|
|
|
|
|
y += maxH + 10;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4) B11:D11 merged left bottom
|
|
|
|
|
const b11Value = getCellValue(sheet, 'B11');
|
|
|
|
|
if (b11Value) {
|
|
|
|
|
doc.font('Helvetica').fontSize(11).fillColor('#000');
|
|
|
|
|
doc.text(String(b11Value), leftX, y, { width: pageWidth * 0.6, align: 'left' });
|
|
|
|
|
y = doc.y + 6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5) B12:C15 block
|
|
|
|
|
const block2 = getRangeRows(sheet, 'B12:C15');
|
|
|
|
|
if (block2 && block2.length > 0) {
|
|
|
|
|
const colCount = block2[0].length;
|
|
|
|
|
const columnW = Math.floor((pageWidth * 0.5) / Math.max(1, colCount));
|
|
|
|
|
for (let r = 0; r < block2.length; r++) {
|
|
|
|
|
const row = block2[r];
|
|
|
|
|
let x = leftX;
|
|
|
|
|
let maxH = 0;
|
|
|
|
|
for (let c = 0; c < row.length; c++) {
|
|
|
|
|
const txt = String(row[c] || '');
|
|
|
|
|
const h = doc.heightOfString(txt, { width: columnW - 6 });
|
|
|
|
|
maxH = Math.max(maxH, h);
|
|
|
|
|
}
|
|
|
|
|
for (let c = 0; c < row.length; c++) {
|
|
|
|
|
const txt = String(row[c] || '');
|
|
|
|
|
doc.rect(x, y, columnW, maxH + 8).stroke();
|
|
|
|
|
doc.text(txt, x + 4, y + 4, { width: columnW - 8 });
|
|
|
|
|
x += columnW;
|
|
|
|
|
}
|
|
|
|
|
y += maxH + 10;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add small spacer
|
|
|
|
|
y += 8;
|
|
|
|
|
|
|
|
|
|
// Now append the main table below (A1:E49 region) - reuse previous parseManagersSheet logic
|
|
|
|
|
const rowsOpt = { header: 1, range: 'A1:E49', blankrows: false, defval: '' };
|
|
|
|
|
const rows = XLSX.utils.sheet_to_json(sheet, rowsOpt);
|
|
|
|
|
if (rows && rows.length > 1) {
|
|
|
|
|
const headersTable = rows[0].map(h => String(h).trim());
|
|
|
|
|
const dataRows = rows.slice(1).map(r => {
|
|
|
|
|
const obj = {};
|
|
|
|
|
headersTable.forEach((h, i) => { obj[h || `col${i}`] = r[i] !== undefined ? r[i] : ''; });
|
|
|
|
|
return obj;
|
|
|
|
|
});
|
|
|
|
|
// render the rest using the existing pdfFromRecordsToBase64 but passing doc starting state isn't simple
|
|
|
|
|
// so call pdfFromRecordsToBase64 to create a standalone PDF and then append? Simpler: render a simple table here.
|
|
|
|
|
|
|
|
|
|
// Use same table rendering approach as records conversion
|
|
|
|
|
const tableHeaders = headersTable;
|
|
|
|
|
const cCount = Math.max(1, tableHeaders.length);
|
|
|
|
|
const tBaseWidth = Math.floor(pageWidth / cCount);
|
|
|
|
|
const tColWidths = tableHeaders.map((_, idx) => tBaseWidth);
|
|
|
|
|
|
|
|
|
|
// draw headers
|
|
|
|
|
if (y + 30 > doc.page.height - doc.page.margins.bottom) { doc.addPage(); y = doc.page.margins.top; }
|
|
|
|
|
let tx = leftX;
|
|
|
|
|
const th = 18;
|
|
|
|
|
doc.rect(tx, y, pageWidth, th).fill('#f2f2f2'); doc.fillColor('#000');
|
|
|
|
|
for (let i = 0; i < tableHeaders.length; i++) { doc.font('Helvetica-Bold').fontSize(10).text(tableHeaders[i] || '', tx + 4, y + 4, { width: tColWidths[i] - 8 }); tx += tColWidths[i]; }
|
|
|
|
|
doc.moveTo(leftX, y + th).lineTo(leftX + pageWidth, y + th).stroke();
|
|
|
|
|
y += th;
|
|
|
|
|
|
|
|
|
|
const maxRows = Math.min(dataRows.length, 49);
|
|
|
|
|
for (let r = 0; r < maxRows; r++) {
|
|
|
|
|
const row = dataRows[r];
|
|
|
|
|
// compute row height
|
|
|
|
|
const cellTexts = tableHeaders.map(h => formatCellValue(row[h]));
|
|
|
|
|
const heights = cellTexts.map((t, i) => doc.heightOfString(t || '', { width: tColWidths[i] - 8 }));
|
|
|
|
|
const rowH = Math.max(heights.reduce((a,b) => Math.max(a,b), 0), 12) + 8;
|
|
|
|
|
if (y + rowH + 40 > doc.page.height - doc.page.margins.bottom) { doc.addPage(); y = doc.page.margins.top; }
|
|
|
|
|
tx = leftX;
|
|
|
|
|
for (let i = 0; i < tableHeaders.length; i++) {
|
|
|
|
|
doc.font('Helvetica').fontSize(9).text(cellTexts[i] || '', tx + 4, y + 4, { width: tColWidths[i] - 8 });
|
|
|
|
|
// vertical line
|
|
|
|
|
doc.moveTo(tx + tColWidths[i], y).lineTo(tx + tColWidths[i], y + rowH).stroke();
|
|
|
|
|
tx += tColWidths[i];
|
|
|
|
|
}
|
|
|
|
|
doc.moveTo(leftX, y + rowH).lineTo(leftX + pageWidth, y + rowH).stroke();
|
|
|
|
|
y += rowH;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.end();
|
|
|
|
|
await new Promise((res) => doc.on('end', res));
|
|
|
|
|
const pdfBuffer = Buffer.concat(buffers);
|
|
|
|
|
return pdfBuffer.toString('base64');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createChildFolder(accessToken, parentDriveId, parentItemId, folderName) {
|
|
|
|
|
const endpoint = `https://graph.microsoft.com/v1.0/drives/${parentDriveId}/items/${parentItemId}/children`;
|
|
|
|
|
const body = { name: folderName, folder: {}, "@microsoft.graph.conflictBehavior": "rename" };
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
console.log('[dry-run] Would POST', endpoint, 'body', body);
|
|
|
|
|
return { id: 'dry-main-folder-id', name: folderName };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resp = await fetch(endpoint, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
const json = await resp.json();
|
|
|
|
|
if (!resp.ok) throw new Error(`Create folder failed: ${JSON.stringify(json)}`);
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadFileToFolder(accessToken, driveId, folderId, fileName, base64pdf) {
|
|
|
|
|
const endpoint = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${folderId}:/${encodeURIComponent(fileName)}:/content`;
|
|
|
|
|
const buffer = Buffer.from(base64pdf, 'base64');
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
console.log('[dry-run] Would PUT', endpoint, `(${buffer.length} bytes)`);
|
|
|
|
|
return { id: 'dry-upload-id', name: fileName, size: buffer.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resp = await fetch(endpoint, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/pdf' },
|
|
|
|
|
body: buffer
|
|
|
|
|
});
|
|
|
|
|
const json = await resp.json();
|
|
|
|
|
if (!resp.ok) throw new Error(`Upload failed: ${JSON.stringify(json)}`);
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createShareLink(accessToken, driveId, itemId) {
|
|
|
|
|
const endpoint = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/createLink`;
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
console.log('[dry-run] Would POST', endpoint, 'body', { type: 'view', scope: 'organization' });
|
|
|
|
|
return { link: { webUrl: 'https://example.com/dry-share-link' } };
|
|
|
|
|
}
|
|
|
|
|
const resp = await fetch(endpoint, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ type: 'view', scope: 'organization' })
|
|
|
|
|
});
|
|
|
|
|
const json = await resp.json();
|
|
|
|
|
if (!resp.ok) throw new Error(`Create share link failed: ${JSON.stringify(json)}`);
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function run() {
|
|
|
|
|
console.log('minimal-uploader starting', dryRun ? '(dry-run)' : '');
|
|
|
|
|
const token = await getAppToken();
|
|
|
|
|
console.log('acquired app token (truncated):', token ? token.substring(0, 30) + '...' : '(none)');
|
|
|
|
|
|
|
|
|
|
const mainFolderName = `0123 - Example Job (created ${new Date().toISOString()})`;
|
|
|
|
|
// sanitize names to avoid invalid SharePoint characters (e.g. ':' in ISO timestamps)
|
|
|
|
|
function sanitizeName(n) {
|
|
|
|
|
// Replace characters that are invalid in SharePoint file/folder names
|
|
|
|
|
// See: https://learn.microsoft.com/sharepoint/dev/general-development/avoid-using-invalid-file-name-characters
|
|
|
|
|
return n.replace(/["\*\\\/:<>\?|#{}%~&]/g, '-')
|
|
|
|
|
.replace(/\s+/g, ' ').trim();
|
|
|
|
|
}
|
|
|
|
|
const safeMainFolderName = sanitizeName(mainFolderName);
|
|
|
|
|
const subFolderName = 'Managers Info';
|
|
|
|
|
// Prefer generating from an Excel workbook if EXCEL_DRIVE_ID and EXCEL_ITEM_ID are present
|
|
|
|
|
let csvBase64 = await pdfFromCsvToBase64(pathToCsv);
|
|
|
|
|
if (process.env.EXCEL_DRIVE_ID && process.env.EXCEL_ITEM_ID) {
|
|
|
|
|
console.log('EXCEL_DRIVE_ID/EXCEL_ITEM_ID present, downloading workbook from Graph to build PDF');
|
|
|
|
|
try {
|
|
|
|
|
const token1 = token; // app token used for Graph
|
|
|
|
|
const workbookBuffer = await downloadExcelFromGraph(token1, process.env.EXCEL_DRIVE_ID, process.env.EXCEL_ITEM_ID);
|
|
|
|
|
// Build a PDF directly from the workbook and its formatting/regions if possible
|
|
|
|
|
const wbPdf = await pdfFromWorkbookBufferToBase64(workbookBuffer);
|
|
|
|
|
if (wbPdf) {
|
|
|
|
|
csvBase64 = wbPdf;
|
|
|
|
|
// count rows if we can
|
|
|
|
|
const recs = parseManagersSheetFromWorkbookBuffer(workbookBuffer) || [];
|
|
|
|
|
console.log('Built PDF from workbook Managers Info with', recs.length, 'rows');
|
|
|
|
|
} else {
|
|
|
|
|
console.log('No Managers Info sheet / no rows found in workbook — falling back to managers.csv or static PDF');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to build PDF from workbook', e && e.message ? e.message : e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
|
|
|
const fileName = sanitizeName(`Managers Info - ${timestamp}.pdf`);
|
|
|
|
|
|
|
|
|
|
console.log('Creating main folder under parent item', PARENT_ITEM_ID);
|
|
|
|
|
const main = await createChildFolder(token, DRIVE_ID, PARENT_ITEM_ID, safeMainFolderName);
|
|
|
|
|
console.log('main folder created:', main.id || mainName || JSON.stringify(main));
|
|
|
|
|
|
|
|
|
|
console.log('Creating subfolder under main folder', main.id);
|
|
|
|
|
const sub = await createChildFolder(token, DRIVE_ID, main.id, subFolderName);
|
|
|
|
|
console.log('subfolder created:', sub.id || JSON.stringify(sub));
|
|
|
|
|
|
|
|
|
|
console.log('Uploading PDF into subfolder', sub.id);
|
|
|
|
|
const payloadBase64 = csvBase64 || SAMPLE_PDF_BASE64;
|
|
|
|
|
if (!csvBase64) console.log('No managers.csv found - using fallback static PDF');
|
|
|
|
|
const uploaded = await uploadFileToFolder(token, DRIVE_ID, sub.id, fileName, payloadBase64);
|
|
|
|
|
console.log('uploaded result:', uploaded.id, uploaded.name, uploaded.size || '(size unknown)');
|
|
|
|
|
|
|
|
|
|
console.log('Creating share link for subfolder', sub.id);
|
|
|
|
|
const share = await createShareLink(token, DRIVE_ID, sub.id);
|
|
|
|
|
console.log('share link:', share && share.link && share.link.webUrl ? share.link.webUrl : JSON.stringify(share));
|
|
|
|
|
|
|
|
|
|
console.log('Done — created folder, subfolder and uploaded file.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run().catch(err => {
|
|
|
|
|
console.error('Error running minimal-uploader:', err && err.message ? err.message : err);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|