1// The digitizer's browser-side file check, shared by the two doors into an 2// import: the landing hero (landing_upload_controller.js) and the upload page 3// (menu_import_controller.js). They used to validate differently â the page 4// refused HEIC and would not mix a PDF with photos, while the hero accepted 5// both â so a file could pass one door and be turned away at the other for no 6// reason the owner could see (issue #130). 7// 8// Keep in step with MenuImportsController::ACCEPTED_CONTENT_TYPES, which is 9// what actually decides on the server. The page passes the server's own list 10// in; the hero, whose markup is edge-cached, falls back to this copy. 11 12export const ACCEPTED_TYPES = [ 13 "application/pdf", 14 "image/png", "image/jpeg", "image/jpg", "image/webp", 15 "image/heic", "image/heif", "image/gif", "image/tiff" 16] 17 18// Browsers leave `type` empty for HEIC on some platforms, and for TIFF on 19// others, so the extension is a real signal and not just a convenience. 20const ACCEPTED_EXTENSIONS = /\.(pdf|png|jpe?g|webp|heic|heif|gif|tiff?)$/i 21 22// UPLOAD_FAILED is not a refusal: the file passed every check here and the 23// direct upload to R2 then failed (issue #183). It is reported through the same 24// beacon because it is the same loss â an owner who brought a menu and left 25// without an import â and nothing else records it. 26export const REASON = { TYPE: "type", SIZE: "size", COUNT: "count", EMPTY: "empty", UPLOAD_FAILED: "upload_failed" } 27 28export function acceptedType(file, types = ACCEPTED_TYPES) { 29 if (types.includes(file.type)) return true 30 return ACCEPTED_EXTENSIONS.test(file.name) 31} 32 33// Returns { reason, files } for the first rule the selection breaks, or null. 34// `files` carries only the offending ones, so the caller can both name them to 35// the owner and report exactly what was refused. 36export function validate(files, { maxFiles, maxBytes, types = ACCEPTED_TYPES }) { 37 if (files.length === 0) return { reason: REASON.EMPTY, files: [] } 38 if (files.length > maxFiles) return { reason: REASON.COUNT, files: [] } 39 40 const unsupported = files.filter(file => !acceptedType(file, types)) 41 if (unsupported.length > 0) return { reason: REASON.TYPE, files: unsupported } 42 43 const oversized = files.filter(file => file.size > maxBytes) 44 if (oversized.length > 0) return { reason: REASON.SIZE, files: oversized } 45 46 return null 47} 48 49// Drops the files that are already chosen, and repeats within `files` itself. 50// The upload page adds each selection to the last one, where a file input would 51// have replaced it, so an owner who picks the same photos twice â because the 52// first pick seemed not to take â had every one counted twice and hit the 53// 10-file limit with five files on screen (issue #184). Name, size and 54// modification time is as close to "the same file" as the File API gets. 55export function withoutDuplicates(files, alreadyChosen = []) { 56 const seen = new Set(alreadyChosen.map(fileKey)) 57 return files.filter(file => { 58 const key = fileKey(file) 59 if (seen.has(key)) return false 60 seen.add(key) 61 return true 62 }) 63} 64 65function fileKey(file) { 66 return [file.name, file.size, file.lastModified].join("\u0000") 67} 68 69export function extensionOf(name) { 70 const match = /\.([A-Za-z0-9]+)$/.exec(name || "") 71 return match ? match[1].toLowerCase() : "" 72} 73 74// Tells the server about a file it will never see, so we can count the menus we 75// turn away (MenuImportRejectionsController). sendBeacon so it survives the 76// owner closing the tab straight after the error, and deliberately silent: an 77// analytics write must never be why someone cannot retry. 78export function reportRejection({ reason, files, origin, url = "/menu_imports/rejections" }) { 79 try { 80 if (!navigator.sendBeacon) return 81 82 const payload = { 83 reason, 84 origin: origin || null, 85 files: (files || []).map(file => ({ 86 content_type: file.type || null, 87 extension: extensionOf(file.name), 88 byte_size: file.size 89 })) 90 } 91 navigator.sendBeacon(url, new Blob([JSON.stringify(payload)], { type: "application/json" })) 92 } catch (error) { 93 // Nothing to do: the owner's retry matters, the measurement does not. 94 } 95}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.