PageSourceSearch

https://topfood.app/assets/lib/menu_upload_validation-74657640.js

js topfood.app collected 2026-09-30 22:18:25 UTC 4,188 bytes, 95 lines download raw bytes

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.