Rocky_Mountain_Vending/scripts/sync-manuals-to-convex.ts

258 lines
6.9 KiB
TypeScript

import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { config as loadEnv } from "dotenv"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
import { scanManuals } from "../lib/manuals"
import {
getManualsAssetSource,
buildManualAssetUrl,
buildThumbnailAssetUrl,
} from "../lib/manuals-storage"
import { businessConfig } from "../lib/seo-config"
import { getSiteDomain } from "../lib/site-config"
import { selectManualsForSite } from "../lib/manuals-site-selection"
import {
canonicalizeTenantDomain,
resolveManualsTenantDomain,
tenantDomainVariants,
} from "../lib/manuals-tenant"
import {
getManualsFilesRoot,
getManualsThumbnailsRoot,
} from "../lib/manuals-paths"
import {
hasManualObjectStorageConfig,
manualAssetExists,
uploadManualAsset,
} from "../lib/manuals-object-storage"
loadEnv({ path: ".env.local" })
loadEnv({ path: ".env.staging", override: false })
type ManualUpsertInput = {
filename: string
path: string
manufacturer: string
category: string
size?: number
lastModified?: number
searchTerms?: string[]
commonNames?: string[]
thumbnailUrl?: string
manualUrl?: string
hasParts?: boolean
assetSource?: string
sourcePath?: string
sourceSite?: string
sourceDomain?: string
siteVisibility?: string[]
importBatch?: string
}
const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
function parseArgs(argv: string[]) {
const limitFlagIndex = argv.indexOf("--limit")
const limit =
limitFlagIndex >= 0
? Number.parseInt(argv[limitFlagIndex + 1] || "", 10)
: undefined
return {
dryRun: argv.includes("--dry-run"),
skipUpload: argv.includes("--skip-upload"),
limit: Number.isFinite(limit) && limit && limit > 0 ? limit : undefined,
}
}
function readConvexUrl() {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL
if (!convexUrl) {
throw new Error(
"NEXT_PUBLIC_CONVEX_URL is required to sync manuals into Convex."
)
}
return convexUrl
}
function canonicalSiteVisibility(siteDomain: string) {
const canonicalHost = canonicalizeTenantDomain(
new URL(businessConfig.website).hostname
)
return Array.from(
new Set([
...tenantDomainVariants(siteDomain),
...tenantDomainVariants(canonicalHost),
])
)
}
async function uploadSelectedAssets(
manuals: Awaited<ReturnType<typeof scanManuals>>,
dryRun: boolean
) {
const manualsRoot = getManualsFilesRoot()
const thumbnailsRoot = getManualsThumbnailsRoot()
const stats = {
manualsUploaded: 0,
manualsSkipped: 0,
thumbnailsUploaded: 0,
thumbnailsSkipped: 0,
}
for (const manual of manuals) {
const manualKey = manual.path
const manualPath = join(manualsRoot, manual.path)
const shouldSkipManual = await manualAssetExists("manuals", manualKey)
if (shouldSkipManual) {
stats.manualsSkipped += 1
} else if (dryRun) {
stats.manualsUploaded += 1
} else {
await uploadManualAsset("manuals", manualKey, await readFile(manualPath))
stats.manualsUploaded += 1
}
if (!manual.thumbnailUrl) {
continue
}
const thumbnailKey = manual.thumbnailUrl
const thumbnailPath = join(thumbnailsRoot, manual.thumbnailUrl)
const shouldSkipThumbnail = await manualAssetExists(
"thumbnails",
thumbnailKey
)
if (shouldSkipThumbnail) {
stats.thumbnailsSkipped += 1
} else if (dryRun) {
stats.thumbnailsUploaded += 1
} else {
await uploadManualAsset(
"thumbnails",
thumbnailKey,
await readFile(thumbnailPath)
)
stats.thumbnailsUploaded += 1
}
}
return stats
}
function normalizeManualForConvex(
manual: Awaited<ReturnType<typeof scanManuals>>[number],
importBatch: string,
siteDomain: string
) {
const manualUrl = buildManualAssetUrl(manual.path)
const thumbnailUrl = manual.thumbnailUrl
? buildThumbnailAssetUrl(manual.thumbnailUrl)
: undefined
return {
filename: manual.filename,
path: manual.path,
manufacturer: manual.manufacturer,
category: manual.category,
size: manual.size,
lastModified: manual.lastModified
? manual.lastModified.getTime()
: undefined,
searchTerms: manual.searchTerms,
commonNames: manual.commonNames,
thumbnailUrl,
manualUrl,
hasParts: Boolean(
manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))
),
assetSource: getManualsAssetSource(),
sourcePath: manual.path,
sourceSite: businessConfig.name,
sourceDomain: siteDomain,
siteVisibility: canonicalSiteVisibility(siteDomain),
importBatch,
} satisfies ManualUpsertInput
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const siteDomain = resolveManualsTenantDomain({
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
envSiteDomain: getSiteDomain(),
})
if (!siteDomain) {
throw new Error(
"Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN."
)
}
const allManuals = await scanManuals()
const selection = selectManualsForSite(allManuals, siteDomain)
const selectedManuals = args.limit
? selection.manuals.slice(0, args.limit)
: selection.manuals
const manualsWithThumbnails = selectedManuals.filter((manual) =>
Boolean(manual.thumbnailUrl)
)
const importBatch = new Date().toISOString()
const payload = selectedManuals.map((manual) =>
normalizeManualForConvex(manual, importBatch, siteDomain)
)
console.log("[manuals-sync] selection", {
siteDomain: selection.siteDomain,
scanned: selection.totalInput,
mdbEligible: selection.mdbEligible,
siteVisible: selection.siteVisible,
finalSelected: selection.finalCount,
selectedForRun: selectedManuals.length,
thumbnailsForRun: manualsWithThumbnails.length,
assetSource: getManualsAssetSource(),
})
if (!args.skipUpload) {
if (!hasManualObjectStorageConfig()) {
throw new Error("Manual object storage credentials are not configured.")
}
console.log("[manuals-sync] uploading selected manuals and thumbnails")
const uploadStats = await uploadSelectedAssets(selectedManuals, args.dryRun)
console.log("[manuals-sync] upload summary", uploadStats)
}
if (args.dryRun) {
console.log("[manuals-sync] dry run complete")
return
}
const convex = new ConvexHttpClient(readConvexUrl())
const batchSize = 100
let synced = 0
for (let index = 0; index < payload.length; index += batchSize) {
const slice = payload.slice(index, index + batchSize)
await convex.mutation(UPSERT_MANUALS, { manuals: slice })
synced += slice.length
console.log("[manuals-sync] upserted batch", {
synced,
total: payload.length,
})
}
console.log("[manuals-sync] completed", {
synced,
importBatch,
siteDomain,
})
}
main().catch((error) => {
console.error("[manuals-sync] failed", error)
process.exit(1)
})