utils.js
1 import fs from 'node:fs/promises'; 2 import path from 'node:path'; 3 import { CliError, getErrorMessage } from '@jackwener/opencli/errors'; 4 export const PAPERREVIEW_DOMAIN = 'paperreview.ai'; 5 export const PAPERREVIEW_BASE_URL = `https://${PAPERREVIEW_DOMAIN}`; 6 export const MAX_PDF_BYTES = 10 * 1024 * 1024; 7 function asText(value) { 8 return value == null ? '' : String(value); 9 } 10 function trimOrEmpty(value) { 11 return asText(value).trim(); 12 } 13 function toErrorMessage(payload, fallback) { 14 if (payload && typeof payload === 'object') { 15 const detail = trimOrEmpty(payload.detail); 16 const message = trimOrEmpty(payload.message); 17 const error = trimOrEmpty(payload.error); 18 if (detail) 19 return detail; 20 if (message) 21 return message; 22 if (error) 23 return error; 24 } 25 const text = trimOrEmpty(payload); 26 return text || fallback; 27 } 28 export function buildReviewUrl(token) { 29 return `${PAPERREVIEW_BASE_URL}/review?token=${encodeURIComponent(token)}`; 30 } 31 export function parseYesNo(value, name) { 32 const normalized = trimOrEmpty(value).toLowerCase(); 33 if (normalized === 'yes') 34 return true; 35 if (normalized === 'no') 36 return false; 37 throw new CliError('ARGUMENT', `"${name}" must be either "yes" or "no".`); 38 } 39 export function normalizeVenue(value) { 40 return trimOrEmpty(value); 41 } 42 export function validateHelpfulness(value) { 43 const numeric = Number(value); 44 if (!Number.isInteger(numeric) || numeric < 1 || numeric > 5) { 45 throw new CliError('ARGUMENT', '"helpfulness" must be an integer from 1 to 5.'); 46 } 47 return numeric; 48 } 49 export async function readPdfFile(inputPath) { 50 const rawPath = trimOrEmpty(inputPath); 51 if (!rawPath) { 52 throw new CliError('ARGUMENT', 'A PDF path is required.', 'Provide a local PDF file path'); 53 } 54 const resolvedPath = path.resolve(rawPath); 55 const fileName = path.basename(resolvedPath); 56 if (!fileName.toLowerCase().endsWith('.pdf')) { 57 throw new CliError('ARGUMENT', 'The input file must end with .pdf.', 'Provide a PDF file path'); 58 } 59 let fileStat; 60 try { 61 fileStat = await fs.stat(resolvedPath); 62 } 63 catch (error) { 64 if (error?.code === 'ENOENT') { 65 throw new CliError('FILE_NOT_FOUND', `File not found: ${resolvedPath}`, 'Provide a valid PDF file path'); 66 } 67 throw new CliError('FILE_READ_ERROR', `Unable to inspect file: ${resolvedPath}`, 'Check file permissions and try again'); 68 } 69 if (!fileStat.isFile()) { 70 throw new CliError('FILE_NOT_FOUND', `Not a file: ${resolvedPath}`, 'Provide a valid PDF file path'); 71 } 72 if (fileStat.size < 100) { 73 throw new CliError('ARGUMENT', 'The PDF is too small. paperreview.ai requires at least 100 bytes.', 'Provide the final paper PDF'); 74 } 75 if (fileStat.size > MAX_PDF_BYTES) { 76 throw new CliError('FILE_TOO_LARGE', 'The PDF is larger than paperreview.ai\'s 10MB limit.', 'Compress the PDF or submit a smaller file'); 77 } 78 let buffer; 79 try { 80 buffer = await fs.readFile(resolvedPath); 81 } 82 catch { 83 throw new CliError('FILE_READ_ERROR', `Unable to read file: ${resolvedPath}`, 'Check file permissions and try again'); 84 } 85 return { 86 buffer, 87 fileName, 88 resolvedPath, 89 sizeBytes: buffer.byteLength, 90 }; 91 } 92 export async function requestJson(pathname, init = {}) { 93 let response; 94 try { 95 response = await fetch(`${PAPERREVIEW_BASE_URL}${pathname}`, init); 96 } 97 catch (error) { 98 throw new CliError('FETCH_ERROR', `Unable to reach paperreview.ai: ${getErrorMessage(error)}`, 'Check your network connection and try again'); 99 } 100 const rawText = await response.text(); 101 let payload = rawText; 102 if (rawText) { 103 try { 104 payload = JSON.parse(rawText); 105 } 106 catch { 107 payload = rawText; 108 } 109 } 110 return { response, payload }; 111 } 112 export function ensureSuccess(response, payload, fallback, hint) { 113 if (!response.ok) { 114 const code = response.status === 404 ? 'NOT_FOUND' : 'API_ERROR'; 115 throw new CliError(code, toErrorMessage(payload, fallback), hint); 116 } 117 } 118 export function ensureApiSuccess(payload, fallback, hint) { 119 if (!payload || typeof payload !== 'object' || payload.success !== true) { 120 throw new CliError('API_ERROR', toErrorMessage(payload, fallback), hint); 121 } 122 } 123 export function createUploadForm(urlData, pdfFile) { 124 const form = new FormData(); 125 for (const [key, value] of Object.entries(urlData.presigned_fields ?? {})) { 126 form.append(key, value); 127 } 128 form.append('file', new Blob([new Uint8Array(pdfFile.buffer)], { type: 'application/pdf' }), pdfFile.fileName); 129 return form; 130 } 131 export async function uploadPresignedPdf(presignedUrl, pdfFile, urlData) { 132 let response; 133 try { 134 response = await fetch(presignedUrl, { 135 method: 'POST', 136 body: createUploadForm(urlData, pdfFile), 137 }); 138 } 139 catch (error) { 140 throw new CliError('UPLOAD_ERROR', `S3 upload failed: ${getErrorMessage(error)}`, 'Try again in a moment'); 141 } 142 if (!response.ok) { 143 const body = await response.text(); 144 throw new CliError('UPLOAD_ERROR', body || `S3 upload failed with status ${response.status}.`, 'Try again in a moment'); 145 } 146 } 147 export function summarizeSubmission(options) { 148 const { pdfFile, email, venue, token, message, s3Key, dryRun = false, status } = options; 149 return { 150 status: status ?? (dryRun ? 'dry-run' : 'submitted'), 151 file: pdfFile.fileName, 152 file_path: pdfFile.resolvedPath, 153 size_bytes: pdfFile.sizeBytes, 154 email, 155 venue, 156 token: token ?? '', 157 review_url: token ? buildReviewUrl(token) : '', 158 message: message ?? '', 159 s3_key: s3Key ?? '', 160 }; 161 } 162 export function summarizeReview(token, payload, status = 'ready') { 163 const sections = payload?.sections ?? {}; 164 const availableSections = Object.keys(sections); 165 return { 166 status, 167 token, 168 review_url: buildReviewUrl(token), 169 title: trimOrEmpty(payload?.title), 170 venue: trimOrEmpty(payload?.venue), 171 submission_date: trimOrEmpty(payload?.submission_date), 172 numerical_score: payload?.numerical_score ?? '', 173 has_feedback: payload?.has_feedback ?? '', 174 available_sections: availableSections.join(', '), 175 section_count: availableSections.length, 176 summary: trimOrEmpty(sections.summary), 177 strengths: trimOrEmpty(sections.strengths), 178 weaknesses: trimOrEmpty(sections.weaknesses), 179 detailed_comments: trimOrEmpty(sections.detailed_comments), 180 questions: trimOrEmpty(sections.questions), 181 assessment: trimOrEmpty(sections.assessment), 182 content: trimOrEmpty(payload?.content), 183 sections, 184 }; 185 } 186 export function summarizeFeedback(options) { 187 const { token, helpfulness, criticalError, actionableSuggestions, comments, payload } = options; 188 return { 189 status: 'submitted', 190 token, 191 helpfulness, 192 critical_error: criticalError, 193 actionable_suggestions: actionableSuggestions, 194 additional_comments: comments, 195 message: trimOrEmpty(payload?.message) || 'Feedback submitted.', 196 }; 197 }