/* 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.'); // return useful metadata so callers (like an API server) can report status return { main, sub, uploaded, share }; } // Export run so other modules (API server) can call it. module.exports = { run }; if (require.main === module) { // If executed directly from the CLI, run normally run().catch(err => { console.error('Error running minimal-uploader:', err && err.message ? err.message : err); process.exit(1); }); }