Add minimal add-in playgrounds and orchestrator

This commit is contained in:
2025-12-07 21:26:10 -06:00
parent 40bcd939ff
commit ca95d296b5
54 changed files with 13165 additions and 26764 deletions
+8
View File
@@ -0,0 +1,8 @@
# Example .env for minimal-uploader (do not commit with real secrets)
CLIENT_SECRET=7aD8Q~d5K~_PzQv6KqDdrEnmyXHE60eVDpbcnaK_
TENANT_ID=3fd97ea7-b124-41f1-855f-52d8ac3b16c7
CLIENT_ID=3c846e71-9609-40e1-b458-0eb805e21b9f
DRIVE_ID=b!9YOqqr2xM0G2DFBwMEJYY4UzQgocFddEtpLYWuS9_AtkCKl2q8yjQJteQ7Ti4QSx
PARENT_ITEM_ID=01SPNXLDW3Y56HAWK5CNAJ4YJLLSQCUCJ7
# alias: ITEM_ID is also supported for compatibility with other folders in this repo
ITEM_ID=01SPNXLDW3Y56HAWK5CNAJ4YJLLSQCUCJ7
+28
View File
@@ -0,0 +1,28 @@
# Minimal Uploader
This small utility uses environment variables in your environment (.env or other) to create a folder under a configured parent item in a drive, create a subfolder, and upload a PDF file.
Important: It expects the following environment variables to be available (use your existing .env to supply these values):
- CLIENT_ID
- CLIENT_SECRET
- TENANT_ID
- DRIVE_ID
- PARENT_ITEM_ID
Usage:
1. Ensure your environment has the required variables populated (you can copy your existing .env file into this folder or run the script from the repo root where the env is defined).
2. Install dependencies:
npm install
3. Dry-run (simulate without hitting Graph):
npm run check
4. Create folder, subfolder and upload a sample PDF (one-shot):
npm start
This script performs the operation with the app-only client credential flow (it will use the exact values from the environment variables). The uploaded PDF is a small made-up payload; all other values are read from environment variables (exact by design).
+6
View File
@@ -0,0 +1,6 @@
Name,Role,Email,Office,Phone
Alice Smith,Manager,alice@example.com,NY,555-0100
Bob Jones,Supervisor,bob@example.com,LA,555-0101
Charlie Davis,Coordinator,charlie@example.com,AUS,555-0102
David Evans,Lead,david@example.com,NY,555-0103
Eve Johnson,Manager,eve@example.com,LA,555-0104
1 Name Role Email Office Phone
2 Alice Smith Manager alice@example.com NY 555-0100
3 Bob Jones Supervisor bob@example.com LA 555-0101
4 Charlie Davis Coordinator charlie@example.com AUS 555-0102
5 David Evans Lead david@example.com NY 555-0103
6 Eve Johnson Manager eve@example.com LA 555-0104
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "minimal-uploader",
"version": "0.1.0",
"private": true,
"main": "run.js",
"scripts": {
"start": "node run.js",
"check": "node run.js --dry-run",
"server": "node server.js"
},
"dependencies": {
"@azure/msal-node": "^1.13.0",
"dotenv": "^17.2.3",
"node-fetch": "^2.6.7",
"express": "^4.18.2",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"pdfkit": "^0.13.0",
"csv-parse": "^5.4.1"
,"xlsx": "^0.18.5"
}
}
+581
View File
@@ -0,0 +1,581 @@
/* 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);
});
}
+31
View File
@@ -0,0 +1,31 @@
/* Simple playground server: exposes /api/upload-managers which runs the run() exporter
from run.js and returns the JSON result. Meant for local development. */
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const path = require('path');
const { run } = require('./run');
const app = express();
app.use(cors());
app.use(bodyParser.json());
// health
app.get('/api/health', (req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// main endpoint — run the uploader flow (download workbook if env vars exist, create folders and upload PDF)
app.post('/api/upload-managers', async (req, res) => {
try {
const result = await run();
res.json({ ok: true, result });
} catch (err) {
console.error('Error in /api/upload-managers', err);
res.status(500).json({ ok: false, error: (err && err.message) ? err.message : String(err) });
}
});
const port = process.env.PLAYGROUND_PORT || 3002;
app.listen(port, () => console.log(`minimal-uploader-playground server listening on http://localhost:${port}`));