diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd1c674c..75f078b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,8 +76,13 @@ jobs: - name: Run linter run: ${{ steps.pm.outputs.runner }} lint + - name: Run unit tests + run: ${{ steps.pm.outputs.runner }} test + - name: Build Next.js run: ${{ steps.pm.outputs.runner }} build + env: + FOOMATIC_SKIP_SIMILARITY: "1" - name: Verify URL conventions run: ${{ steps.pm.outputs.runner }} verify:urls diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8ad31945..7e2cc77c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,10 @@ on: push: branches: [master] + # Weekly rebuild so recommendations pick up upstream foomatic-db changes. + schedule: + - cron: "0 2 * * 1" + workflow_dispatch: permissions: diff --git a/app/foomatic/printers/page.tsx b/app/foomatic/printers/page.tsx index 180ea524..83db8b8a 100644 --- a/app/foomatic/printers/page.tsx +++ b/app/foomatic/printers/page.tsx @@ -261,18 +261,17 @@ export default function HomePage() { if (selectedColorCapability !== "all") { result = result.filter((printer) => { - const type = printer.type?.toLowerCase() || "" - const model = typeof printer.model === "string" ? printer.model.toLowerCase() : "" + const color = printer.color if (selectedColorCapability === "color") { - return type.includes("color") || model.includes("color") + return color === true } if (selectedColorCapability === "monochrome") { - return type.includes("mono") || type.includes("dot-matrix") || model.includes("mono") + return color === false } - return true + return color === "unknown" || color === undefined }) } diff --git a/components/foomatic/DriverPageClient.tsx b/components/foomatic/DriverPageClient.tsx index 3e365904..58204b40 100644 --- a/components/foomatic/DriverPageClient.tsx +++ b/components/foomatic/DriverPageClient.tsx @@ -20,6 +20,7 @@ import { import { Button } from "@/components/ui/button" import { withBasePath } from "@/lib/foomatic/base-path" import { driverHref, printerHref } from "@/lib/foomatic/routes" +import { sanitizeFoomaticHtml } from "@/lib/foomatic/sanitize" import type { DriverRecord } from "@/lib/foomatic/types" interface DriverPageClientProps { @@ -83,7 +84,7 @@ export default function DriverPageClient({ driverId }: DriverPageClientProps) { setLoading(true) setError(null) - const response = await fetch(withBasePath(`/foomatic-db/drivers/${driverId}.json`)) + const response = await fetch(withBasePath(`/foomatic-db/drivers/${encodeURIComponent(driverId)}.json`)) if (!response.ok) { throw new Error("This driver entry could not be loaded.") } @@ -339,7 +340,7 @@ export default function DriverPageClient({ driverId }: DriverPageClientProps) {

Description

) : driver.shortDescription ? ( diff --git a/components/foomatic/PpdViewerClient.tsx b/components/foomatic/PpdViewerClient.tsx index caf67246..4feec5ab 100644 --- a/components/foomatic/PpdViewerClient.tsx +++ b/components/foomatic/PpdViewerClient.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button" import { withBasePath } from "@/lib/foomatic/base-path" import { ppdFilePath } from "@/lib/foomatic/routes" +// Restrict to known PPD output dirs and reject ".." to prevent path traversal. function isValidPpdPath(path: string | null) { return Boolean(path && (path.startsWith("/ppd/") || path.startsWith("/ppds/")) && !path.includes("..")) } diff --git a/components/foomatic/PrinterPageClient.tsx b/components/foomatic/PrinterPageClient.tsx index 21d53e1f..970f40c6 100644 --- a/components/foomatic/PrinterPageClient.tsx +++ b/components/foomatic/PrinterPageClient.tsx @@ -21,8 +21,12 @@ import { import { Button } from "@/components/ui/button" import { withBasePath } from "@/lib/foomatic/base-path" import { driverHref, ppdViewHref } from "@/lib/foomatic/routes" +import { sanitizeFoomaticHtml } from "@/lib/foomatic/sanitize" import type { Printer } from "@/lib/foomatic/types" import { calculateAccurateStatus } from "@/lib/foomatic/utils" +import RecommendedPrintersSection, { + SimilarPrintersTeaser, +} from "@/components/foomatic/RecommendedPrintersSection" interface PrinterPageClientProps { printerId: string @@ -30,19 +34,40 @@ interface PrinterPageClientProps { function LoadingState() { return ( -
- -
-
-
- - -
- {Array.from({ length: 2 }).map((_, index) => ( - -
-
-
+
+
+ +
+
+
+ + +
+ {Array.from({ length: 2 }).map((_, index) => ( + +
+
+
+ + ))} +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, index) => ( + +
+
+
+
+
+
+
+
+
+
+
))}
@@ -61,7 +86,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps) setLoading(true) setError(null) - const response = await fetch(withBasePath(`/foomatic-db/printers/${printerId}.json`)) + const response = await fetch(withBasePath(`/foomatic-db/printers/${encodeURIComponent(printerId)}.json`)) if (!response.ok) { throw new Error("This printer entry could not be loaded.") } @@ -120,6 +145,12 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps) } const status = calculateAccurateStatus(printer) + const hasCapabilities = + (printer.color !== undefined && printer.color !== "unknown") || + (printer.duplex !== undefined && printer.duplex !== "unknown") || + printer.maxDpi != null || + (printer.connectivity?.length ?? 0) > 0 || + (printer.commandsets?.length ?? 0) > 0 const drivers = [...(printer.drivers ?? [])].sort((left, right) => { if (left.id === printer.recommended_driver) return -1 if (right.id === printer.recommended_driver) return 1 @@ -170,19 +201,23 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
- {printer.recommended_driver ? ( - -

- Recommended driver -

- - {printer.recommended_driver.replace(/^driver\//, "")} - -
- ) : null} +
+ {printer.recommended_driver ? ( + +

+ Recommended driver +

+ + {printer.recommended_driver.replace(/^driver\//, "")} + +
+ ) : null} + + +
@@ -225,56 +260,66 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
- {printer.color !== undefined && printer.color !== "unknown" ? ( -
-
- Color -
-
- {printer.color ? "Color output" : "Monochrome only"} -
-
- ) : null} - {printer.duplex !== undefined && printer.duplex !== "unknown" ? ( -
-
- Duplex -
-
- {printer.duplex ? "Supported" : "Not supported"} -
-
- ) : null} - {printer.connectivity && printer.connectivity.length > 0 ? ( -
-
- Connectivity -
-
- {printer.connectivity.map((item) => ( - - {item} - - ))} -
-
- ) : null} - {printer.commandsets && printer.commandsets.length > 0 ? ( -
-
- Page description languages -
-
- {printer.commandsets.map((item) => ( - - {item} - - ))} -
-
- ) : null} + {hasCapabilities ? ( +
+

+ Capabilities +

+
+ {printer.color !== undefined && printer.color !== "unknown" ? ( +
+
Color
+
+ {printer.color ? "Color output" : "Monochrome only"} +
+
+ ) : null} + {printer.duplex !== undefined && printer.duplex !== "unknown" ? ( +
+
Duplex
+
+ {printer.duplex ? "Supported" : "Not supported"} +
+
+ ) : null} + {printer.maxDpi != null ? ( +
+
Max resolution
+
{printer.maxDpi} dpi
+
+ ) : null} + {printer.connectivity && printer.connectivity.length > 0 ? ( +
+
Connectivity
+
+ {printer.connectivity.map((item) => ( + + {item} + + ))} +
+
+ ) : null} + {printer.commandsets && printer.commandsets.length > 0 ? ( +
+
+ Page description languages +
+
+ {printer.commandsets.map((item) => ( + + {item} + + ))} +
+
+ ) : null} +
+
+ ) : null} + {printer.notes ? (

@@ -282,7 +327,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)

) : null} @@ -382,7 +427,9 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
@@ -410,6 +457,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps) ))}
+ ) diff --git a/components/foomatic/RecommendedPrintersSection.tsx b/components/foomatic/RecommendedPrintersSection.tsx new file mode 100644 index 00000000..8c14203d --- /dev/null +++ b/components/foomatic/RecommendedPrintersSection.tsx @@ -0,0 +1,311 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowDown } from "lucide-react" + +import { + FoomaticBadge, + FoomaticCard, + FoomaticStatusBadge, +} from "@/components/foomatic/shared" +import { withBasePath } from "@/lib/foomatic/base-path" +import { printerHref } from "@/lib/foomatic/routes" +import { confidenceTier } from "@/lib/foomatic/scoring" +import type { ConfidenceTone } from "@/lib/foomatic/scoring" + +// Display fields are embedded in each per-printer recommendation shard by +// compute-similarity.ts, so this section needs exactly one small fetch. +interface Recommendation { + id: string + score: number + sharedFeatures: string[] + manufacturer?: string + model?: string + status: string + type: string +} + +interface RecommendedPrintersSectionProps { + printerId: string +} + +// One shard fetch per printer id, shared between the hero teaser and the full +// section below so mounting both costs a single network request. A missing +// shard (404) resolves to an empty list; network failures clear the cache so +// the next mount retries instead of reusing a rejected promise. +const shardCache = new Map>() + +function getRecommendations(printerId: string): Promise { + let cached = shardCache.get(printerId) + + if (!cached) { + cached = fetch( + withBasePath(`/foomatic-db/recommendations/${encodeURIComponent(printerId)}.json`) + ) + .then((response) => (response.ok ? (response.json() as Promise) : [])) + .catch((error) => { + shardCache.delete(printerId) + throw error + }) + + shardCache.set(printerId, cached) + } + + return cached +} + +// Tier wording and thresholds live in lib/foomatic/scoring.ts next to the +// scoring model they interpret. The percentage is labelled "similarity": the +// score is not a probability that the printer will work. +const TONE_CLASSES: Record = { + high: "text-emerald-700 dark:text-emerald-400", + good: "text-sky-700 dark:text-sky-400", + moderate: "text-amber-700 dark:text-amber-400", + limited: "text-muted-foreground", +} + +function ConfidenceBadge({ score }: { score: number }) { + const tier = confidenceTier(score) + + return ( + + {tier.label} + + {Math.round(score * 100)}% similarity + + + ) +} + +function RecommendationSkeleton() { + return ( +