Add ZIP upload for saved webpage import

For Cloudflare-protected sites, users can now:
1. Save the page from their browser (Webpage, Complete)
2. ZIP the HTML and assets folder
3. Upload the ZIP in the wizard
4. Server extracts to temp folder, Playwright scans local copy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-13 07:02:51 -08:00
parent 1438267af6
commit 8aeca335fd
2 changed files with 264 additions and 76 deletions

View file

@ -0,0 +1,149 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfset response = structNew()>
<cfset response["OK"] = false>
<cfset response["MESSAGE"] = "">
<cfset response["URL"] = "">
<cftry>
<!--- Temp directory for extracted saved pages --->
<cfset tempBaseDir = expandPath("/temp/menu-import")>
<!--- Create temp directory if it doesn't exist --->
<cfif NOT directoryExists(tempBaseDir)>
<cfdirectory action="create" directory="#tempBaseDir#" mode="755">
</cfif>
<!--- Check if ZIP file was uploaded --->
<cfif NOT structKeyExists(form, "zipFile") OR form.zipFile EQ "">
<cfset response["MESSAGE"] = "No ZIP file uploaded">
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<!--- Generate unique folder name --->
<cfset uniqueId = lCase(replace(createUUID(), "-", "", "all"))>
<cfset extractDir = "#tempBaseDir#/#uniqueId#">
<!--- Upload the ZIP file --->
<cffile action="upload"
filefield="zipFile"
destination="#tempBaseDir#/"
nameconflict="makeunique"
mode="644"
result="uploadResult">
<!--- Validate it's a ZIP file --->
<cfset uploadedFile = "#tempBaseDir#/#uploadResult.serverFile#">
<cfset fileExt = lCase(uploadResult.clientFileExt)>
<cfif fileExt NEQ "zip">
<cffile action="delete" file="#uploadedFile#">
<cfset response["MESSAGE"] = "Only ZIP files are accepted">
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<!--- Create extraction directory --->
<cfdirectory action="create" directory="#extractDir#" mode="755">
<!--- Extract the ZIP file --->
<cfzip action="unzip" file="#uploadedFile#" destination="#extractDir#" overwrite="true">
<!--- Delete the uploaded ZIP --->
<cffile action="delete" file="#uploadedFile#">
<!--- Find the main HTML file --->
<cfset htmlFile = "">
<cfset htmlFiles = []>
<!--- First, look for HTML files directly in the extract directory --->
<cfdirectory action="list" directory="#extractDir#" name="topFiles" filter="*.htm*" type="file">
<cfloop query="topFiles">
<cfset arrayAppend(htmlFiles, { "name": topFiles.name, "path": "#extractDir#/#topFiles.name#", "depth": 0 })>
</cfloop>
<!--- Also check one level deep (common for "Save Page As" which creates folder_files alongside .html) --->
<cfdirectory action="list" directory="#extractDir#" name="subDirs" type="dir">
<cfloop query="subDirs">
<cfif subDirs.name NEQ "." AND subDirs.name NEQ "..">
<cfset subDirPath = "#extractDir#/#subDirs.name#">
<cfdirectory action="list" directory="#subDirPath#" name="subFiles" filter="*.htm*" type="file">
<cfloop query="subFiles">
<cfset arrayAppend(htmlFiles, { "name": subFiles.name, "path": "#subDirPath#/#subFiles.name#", "depth": 1 })>
</cfloop>
</cfif>
</cfloop>
<!--- Find the best HTML file (prefer index.html, then top-level, then by size) --->
<cfif arrayLen(htmlFiles) EQ 0>
<!--- Clean up and error --->
<cfdirectory action="delete" directory="#extractDir#" recurse="true">
<cfset response["MESSAGE"] = "No HTML files found in ZIP">
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfabort>
</cfif>
<!--- Priority: index.html at top level, then any index.html, then top-level html, then first found --->
<cfset htmlFile = "">
<cfloop array="#htmlFiles#" index="hf">
<cfif lCase(hf.name) EQ "index.html" AND hf.depth EQ 0>
<cfset htmlFile = hf>
<cfbreak>
</cfif>
</cfloop>
<cfif htmlFile EQ "">
<cfloop array="#htmlFiles#" index="hf">
<cfif lCase(hf.name) EQ "index.html">
<cfset htmlFile = hf>
<cfbreak>
</cfif>
</cfloop>
</cfif>
<cfif htmlFile EQ "">
<cfloop array="#htmlFiles#" index="hf">
<cfif hf.depth EQ 0>
<cfset htmlFile = hf>
<cfbreak>
</cfif>
</cfloop>
</cfif>
<cfif htmlFile EQ "">
<cfset htmlFile = htmlFiles[1]>
</cfif>
<!--- Build the URL path --->
<cfset relativePath = replace(htmlFile.path, extractDir, "")>
<cfset relativePath = replace(relativePath, "\", "/", "all")>
<cfif left(relativePath, 1) NEQ "/">
<cfset relativePath = "/" & relativePath>
</cfif>
<!--- Determine the server hostname for the URL --->
<cfset serverHost = cgi.HTTP_HOST>
<cfset protocol = cgi.HTTPS EQ "on" ? "https" : "http">
<cfset response["OK"] = true>
<cfset response["MESSAGE"] = "ZIP extracted successfully">
<cfset response["URL"] = "#protocol#://#serverHost#/temp/menu-import/#uniqueId##relativePath#">
<cfset response["FOLDER"] = uniqueId>
<cfset response["FILE"] = htmlFile.name>
<cfset response["FILE_COUNT"] = arrayLen(htmlFiles)>
<cfoutput>#serializeJSON(response)#</cfoutput>
<cfcatch type="any">
<cfset response["OK"] = false>
<cfset response["MESSAGE"] = "Error: #cfcatch.message#">
<cfif len(cfcatch.detail)>
<cfset response["MESSAGE"] = response["MESSAGE"] & " - #cfcatch.detail#">
</cfif>
<cfoutput>#serializeJSON(response)#</cfoutput>
</cfcatch>
</cftry>

View file

@ -816,15 +816,16 @@
<div style="flex:1;height:1px;background:var(--gray-300);"></div>
</div>
<!-- HTML File Upload -->
<p style="margin:0 0 12px;color:var(--gray-500);font-size:14px;">If the website blocks our crawler, save the menu page and upload it here</p>
<input type="file" id="htmlFileInput" accept=".html,.htm,.mhtml,.txt" style="display:none;" onchange="handleHtmlFileUpload(event)">
<button class="btn btn-secondary" onclick="document.getElementById('htmlFileInput').click()" style="min-width:160px;">
<!-- Saved Page Upload -->
<p style="margin:0 0 12px;color:var(--gray-500);font-size:14px;">If the website blocks our crawler, save the menu page from your browser and upload it here</p>
<input type="file" id="savedPageInput" accept=".html,.htm,.mhtml,.txt,.zip" style="display:none;" onchange="handleSavedPageUpload(event)">
<button class="btn btn-secondary" onclick="document.getElementById('savedPageInput').click()" style="min-width:180px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
Upload HTML File
Upload Saved Page
</button>
<p style="margin:12px 0 0;color:var(--gray-400);font-size:12px;">HTML file or ZIP (Save Page As > Webpage, Complete)</p>
</div>
</div>
@ -1385,37 +1386,76 @@
switchImportTab('upload');
}
// Handle uploaded HTML file
async function handleHtmlFileUpload(event) {
// Handle uploaded saved page (HTML or ZIP)
async function handleSavedPageUpload(event) {
const file = event.target.files[0];
if (!file) return;
// Read the file content
const reader = new FileReader();
reader.onload = async function(e) {
const htmlContent = e.target.result;
const isZip = file.name.toLowerCase().endsWith('.zip');
// Hide upload section, show conversation
document.getElementById('uploadSection').style.display = 'none';
// Hide upload section, show conversation
document.getElementById('uploadSection').style.display = 'none';
addMessage('ai', `
<div style="display:flex;align-items:center;gap:12px;">
<div class="loading-spinner"></div>
<span>Analyzing saved page: ${file.name}...</span>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">Extracting menu data from HTML content.</p>
`);
addMessage('ai', `
<div style="display:flex;align-items:center;gap:12px;">
<div class="loading-spinner"></div>
<span>Analyzing saved page: ${file.name}...</span>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">${isZip ? 'Uploading and extracting ZIP file...' : 'Extracting menu data from HTML content.'}</p>
`);
let result = null;
try {
let result = null;
try {
if (isZip) {
// Upload ZIP file to server for extraction
const formData = new FormData();
formData.append('zipFile', file);
const uploadResponse = await fetch(`${config.apiBaseUrl}/setup/uploadSavedPage.cfm`, {
method: 'POST',
body: formData
});
const uploadResult = await uploadResponse.json();
if (!uploadResult.OK) {
throw new Error(uploadResult.MESSAGE || 'Failed to upload ZIP file');
}
console.log('ZIP uploaded, extracted URL:', uploadResult.URL);
// Update loading message
document.getElementById('conversation').innerHTML = '';
addMessage('ai', `
<div style="display:flex;align-items:center;gap:12px;">
<div class="loading-spinner"></div>
<span>Scanning extracted page with browser...</span>
</div>
<p style="font-size:13px;color:var(--gray-500);margin-top:8px;">Using Playwright to render the saved page and extract menu data.</p>
`);
// Now analyze the extracted URL with Playwright
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: uploadResult.URL })
});
const responseText = await response.text();
if (!responseText || responseText.trim().length === 0) {
throw new Error('Server returned empty response');
}
result = JSON.parse(responseText);
} else {
// Read HTML file content and send directly
const htmlContent = await file.text();
console.log('Sending HTML content, length:', htmlContent.length);
const response = await fetch(`${config.apiBaseUrl}/setup/analyzeMenuUrl.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: htmlContent })
});
console.log('Response status:', response.status, response.statusText);
const responseText = await response.text();
console.log('Raw response (first 500 chars):', responseText.substring(0, 500));
@ -1429,67 +1469,66 @@
console.error('JSON parse error. Full response:', responseText);
throw new Error('Invalid JSON response from server. Check console for details.');
}
console.log('=== HTML FILE IMPORT RESPONSE ===');
console.log('File:', file.name);
console.log('Full response:', result);
console.log('=================================');
if (!result.OK) {
throw new Error(result.MESSAGE || 'Failed to analyze HTML file');
}
// Store extracted data
config.extractedData = result.DATA;
// Store image mappings for matching uploaded images to items
config.imageMappings = result.DATA.imageMappings || [];
console.log('Image mappings from HTML:', config.imageMappings.length);
// Remove loading message and start conversation flow
document.getElementById('conversation').innerHTML = '';
// Check if any items have imageUrl - if so, offer image upload matching
const itemsWithImages = (config.extractedData.items || []).filter(item => item.imageUrl).length;
if (itemsWithImages > 0) {
showImageMatchingStep();
} else if (config.businessId && config.menuId) {
// In add-menu mode, skip business info and header
showCategoriesStep();
} else {
showBusinessInfoStep();
}
} catch (error) {
console.error('HTML analysis error:', error);
console.error('Full result object:', result);
document.getElementById('conversation').innerHTML = '';
let debugInfo = '';
if (result && result.debug) {
debugInfo = `<p style="font-size:12px;color:var(--gray-500);margin-top:8px;">Debug: hasHtml=${result.debug.hasHtmlKey}, htmlLen=${result.debug.htmlLength}, url="${result.debug.urlValue}"</p>`;
}
addMessage('ai', `
<p>Sorry, I encountered an error analyzing that file:</p>
<p style="color: var(--danger);">${error.message}</p>
${debugInfo}
<div class="action-buttons">
<button class="btn btn-primary" onclick="retryUrlAnalysis()">Try Again</button>
<button class="btn btn-secondary" onclick="switchToFileUpload()">Upload Images Instead</button>
</div>
`);
}
};
reader.onerror = function() {
showToast('Failed to read file', 'error');
};
console.log('=== SAVED PAGE IMPORT RESPONSE ===');
console.log('File:', file.name);
console.log('Full response:', result);
console.log('==================================');
reader.readAsText(file);
if (!result.OK) {
throw new Error(result.MESSAGE || 'Failed to analyze saved page');
}
// Store extracted data
config.extractedData = result.DATA;
// Store image mappings for matching uploaded images to items
config.imageMappings = result.DATA.imageMappings || [];
console.log('Image mappings from saved page:', config.imageMappings.length);
// Remove loading message and start conversation flow
document.getElementById('conversation').innerHTML = '';
// Check if any items have imageUrl - if so, offer image upload matching
const itemsWithImages = (config.extractedData.items || []).filter(item => item.imageUrl).length;
if (itemsWithImages > 0) {
showImageMatchingStep();
} else if (config.businessId && config.menuId) {
// In add-menu mode, skip business info and header
showCategoriesStep();
} else {
showBusinessInfoStep();
}
} catch (error) {
console.error('Saved page analysis error:', error);
console.error('Full result object:', result);
document.getElementById('conversation').innerHTML = '';
let debugInfo = '';
if (result && result.debug) {
debugInfo = `<p style="font-size:12px;color:var(--gray-500);margin-top:8px;">Debug: hasHtml=${result.debug.hasHtmlKey}, htmlLen=${result.debug.htmlLength}, url="${result.debug.urlValue}"</p>`;
}
addMessage('ai', `
<p>Sorry, I encountered an error analyzing that file:</p>
<p style="color: var(--danger);">${error.message}</p>
${debugInfo}
<div class="action-buttons">
<button class="btn btn-primary" onclick="retryUrlAnalysis()">Try Again</button>
<button class="btn btn-secondary" onclick="switchToFileUpload()">Upload Images Instead</button>
</div>
`);
}
// Reset the input so the same file can be selected again
event.target.value = '';
}
// Legacy alias for backwards compatibility
function handleHtmlFileUpload(event) {
handleSavedPageUpload(event);
}
// Show step to upload images from saved webpage subfolder and match to items
function showImageMatchingStep() {
const itemCount = config.extractedData.items ? config.extractedData.items.length : 0;