Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
df882e2
feat(pipeline): add printer feature vectorization script
gativarshney Jun 23, 2026
a370869
feat(recommendations): implement printer compatibility similarity pip…
gativarshney Jun 23, 2026
b1abc78
feat(recommendations): surface compatible printer alternatives on pri…
gativarshney Jun 23, 2026
6a93780
feat(recommendations): add color-aware printer similarity features
gativarshney Jun 23, 2026
f3aab1f
feat(recommendations): add commandset-aware printer similarity
gativarshney Jun 23, 2026
73a9161
feat(recommendations): add PostScript and PCL language-aware similarity
gativarshney Jun 23, 2026
2d6f3b7
feat(recommendations): add resolution-aware printer similarity
gativarshney Jun 23, 2026
4d2a733
Merge branch 'OpenPrinting:master' into feat/printer-recommendations
gativarshney Aug 1, 2026
983f915
perf(recommendations): split recommendation data per printer
gativarshney Aug 1, 2026
403ba15
feat(ui): surface printer capabilities and recommendation confidence
gativarshney Aug 1, 2026
fa79393
build(foomatic): automate recommendation generation in CI
gativarshney Aug 7, 2026
e28f660
feat(recommendations): improve recommendation UX and accessibility
gativarshney Aug 7, 2026
bc25186
fix(foomatic): sanitize upstream HTML rendering
gativarshney Aug 7, 2026
9be7c7d
test(foomatic): add unit tests for recommendation pipeline
gativarshney Aug 7, 2026
856d780
docs(foomatic): add pipeline architecture and regeneration guides
gativarshney Aug 7, 2026
bac57a4
docs(foomatic): add contributor guide for extending the UI
gativarshney Aug 7, 2026
6d16b3c
docs(foomatic): add measured evidence of recommendation quality evolu…
gativarshney Aug 7, 2026
c654b5e
perf(recommendations): render similar printers without the full print…
gativarshney Aug 7, 2026
6c62b28
docs(foomatic): document similarity weights and confidence thresholds
gativarshney Aug 7, 2026
ae59b20
chore(foomatic): resolve final review and lint issues
gativarshney Aug 7, 2026
70e0e07
feat(recommendations): rank by IDF-weighted evidence-damped similarity
gativarshney Aug 7, 2026
4d44bf9
test(foomatic): extract scoring primitives into a tested module
gativarshney Aug 12, 2026
5de2a6c
fix(recommendations): replace unreachable exact-match badge with evid…
gativarshney Aug 12, 2026
9d54edf
docs(foomatic): align scoring docs with implementation and make evalu…
gativarshney Aug 12, 2026
15f65f5
feat(recommendations): surface similar printers from the printer page…
gativarshney Aug 12, 2026
ef0522a
Merge branch 'OpenPrinting:master' into feat/printer-recommendations
gativarshney Aug 12, 2026
2b72bc9
docs(foomatic): align documentation with final recommendation UI
gativarshney Aug 12, 2026
87be572
fix(foomatic): allow trusted Snapcraft embeds through HTML sanitization
gativarshney Aug 12, 2026
9f3bb67
fix(recommendations): exclude obsolete drivers from compatibility evi…
gativarshney Aug 13, 2026
7e27cc7
fix(recommendations): remove driver count from recommendation cards
gativarshney Aug 13, 2026
c278e0a
chore(recommendations): clean up implementation comments
gativarshney Aug 13, 2026
898064a
fix(recommendations): treat printers with only obsolete drivers as un…
gativarshney Aug 13, 2026
4a5420c
merge upstream/master into feat/printer-recommendations
gativarshney Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 4 additions & 5 deletions app/foomatic/printers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}

Expand Down
5 changes: 3 additions & 2 deletions components/foomatic/DriverPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
}
Expand Down Expand Up @@ -339,7 +340,7 @@ export default function DriverPageClient({ driverId }: DriverPageClientProps) {
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">Description</h3>
<div
className="prose prose-sm mt-4 max-w-none text-foreground dark:prose-invert"
dangerouslySetInnerHTML={{ __html: driver.comments }}
dangerouslySetInnerHTML={{ __html: sanitizeFoomaticHtml(driver.comments) }}
/>
</FoomaticCard>
) : driver.shortDescription ? (
Expand Down
1 change: 1 addition & 0 deletions components/foomatic/PpdViewerClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(".."))
}
Expand Down
202 changes: 125 additions & 77 deletions components/foomatic/PrinterPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,53 @@ 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
}

function LoadingState() {
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.5fr)]" role="status" aria-label="Loading printer details">
<FoomaticCard className="space-y-4 p-6">
<div className="h-8 w-36 animate-pulse rounded bg-muted" />
<div className="h-5 w-28 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded-xl bg-muted" />
</FoomaticCard>

<div className="space-y-6">
{Array.from({ length: 2 }).map((_, index) => (
<FoomaticCard key={index} className="space-y-4 p-6">
<div className="h-7 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-56 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded-xl bg-muted" />
<div className="space-y-6" role="status" aria-label="Loading printer details">
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.5fr)]">
<FoomaticCard className="space-y-4 p-6">
<div className="h-8 w-36 animate-pulse rounded bg-muted" />
<div className="h-5 w-28 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded-xl bg-muted" />
</FoomaticCard>

<div className="space-y-6">
{Array.from({ length: 2 }).map((_, index) => (
<FoomaticCard key={index} className="space-y-4 p-6">
<div className="h-7 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-56 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded-xl bg-muted" />
</FoomaticCard>
))}
</div>
</div>

<div className="space-y-4">
<div className="h-7 w-44 animate-pulse rounded bg-muted" />
{Array.from({ length: 3 }).map((_, index) => (
<FoomaticCard key={index} className="p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex-1 space-y-3">
<div className="h-3.5 w-24 animate-pulse rounded bg-muted" />
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
<div className="flex gap-2">
<div className="h-6 w-20 animate-pulse rounded-full bg-muted" />
<div className="h-6 w-16 animate-pulse rounded-full bg-muted" />
</div>
</div>
<div className="h-9 w-28 animate-pulse rounded-md bg-muted" />
</div>
</FoomaticCard>
))}
</div>
Expand All @@ -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.")
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -170,19 +201,23 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
</div>
</div>

{printer.recommended_driver ? (
<FoomaticCard className="max-w-sm p-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Recommended driver
</p>
<Link
href={driverHref(printer.recommended_driver)}
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
>
{printer.recommended_driver.replace(/^driver\//, "")}
</Link>
</FoomaticCard>
) : null}
<div className="flex w-full max-w-sm flex-col gap-4 md:w-auto">
{printer.recommended_driver ? (
<FoomaticCard className="p-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Recommended driver
</p>
<Link
href={driverHref(printer.recommended_driver)}
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
>
{printer.recommended_driver.replace(/^driver\//, "")}
</Link>
</FoomaticCard>
) : null}

<SimilarPrintersTeaser printerId={printer.id} />
</div>
</div>
</div>
</section>
Expand Down Expand Up @@ -225,64 +260,74 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
<FoomaticStatusBadge status={status} />
</dd>
</div>
{printer.color !== undefined && printer.color !== "unknown" ? (
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Color
</dt>
<dd className="mt-2 text-sm text-foreground">
{printer.color ? "Color output" : "Monochrome only"}
</dd>
</div>
) : null}
{printer.duplex !== undefined && printer.duplex !== "unknown" ? (
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Duplex
</dt>
<dd className="mt-2 text-sm text-foreground">
{printer.duplex ? "Supported" : "Not supported"}
</dd>
</div>
) : null}
{printer.connectivity && printer.connectivity.length > 0 ? (
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Connectivity
</dt>
<dd className="mt-2 flex flex-wrap gap-2">
{printer.connectivity.map((item) => (
<FoomaticBadge key={item} className="border-border bg-accent/50 text-muted-foreground">
{item}
</FoomaticBadge>
))}
</dd>
</div>
) : null}
{printer.commandsets && printer.commandsets.length > 0 ? (
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Page description languages
</dt>
<dd className="mt-2 flex flex-wrap gap-2">
{printer.commandsets.map((item) => (
<FoomaticBadge key={item} className="border-border bg-accent/50 text-muted-foreground">
{item}
</FoomaticBadge>
))}
</dd>
</div>
) : null}
</dl>

{hasCapabilities ? (
<div className="mt-6 border-t border-border pt-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Capabilities
</h3>
<dl className="mt-4 space-y-4">
{printer.color !== undefined && printer.color !== "unknown" ? (
<div>
<dt className="text-xs font-medium text-muted-foreground">Color</dt>
<dd className="mt-1 text-sm text-foreground">
{printer.color ? "Color output" : "Monochrome only"}
</dd>
</div>
) : null}
{printer.duplex !== undefined && printer.duplex !== "unknown" ? (
<div>
<dt className="text-xs font-medium text-muted-foreground">Duplex</dt>
<dd className="mt-1 text-sm text-foreground">
{printer.duplex ? "Supported" : "Not supported"}
</dd>
</div>
) : null}
{printer.maxDpi != null ? (
<div>
<dt className="text-xs font-medium text-muted-foreground">Max resolution</dt>
<dd className="mt-1 text-sm text-foreground">{printer.maxDpi} dpi</dd>
</div>
) : null}
{printer.connectivity && printer.connectivity.length > 0 ? (
<div>
<dt className="text-xs font-medium text-muted-foreground">Connectivity</dt>
<dd className="mt-1 flex flex-wrap gap-2">
{printer.connectivity.map((item) => (
<FoomaticBadge key={item} className="border-border bg-accent/50 text-muted-foreground">
{item}
</FoomaticBadge>
))}
</dd>
</div>
) : null}
{printer.commandsets && printer.commandsets.length > 0 ? (
<div>
<dt className="text-xs font-medium text-muted-foreground">
Page description languages
</dt>
<dd className="mt-1 flex flex-wrap gap-2">
{printer.commandsets.map((item) => (
<FoomaticBadge key={item} className="border-border bg-accent/50 text-muted-foreground">
{item}
</FoomaticBadge>
))}
</dd>
</div>
) : null}
</dl>
</div>
) : null}

{printer.notes ? (
<div className="mt-8 border-t border-border pt-6">
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Notes
</h3>
<div
className="prose prose-sm mt-4 max-w-none text-foreground dark:prose-invert"
dangerouslySetInnerHTML={{ __html: printer.notes }}
dangerouslySetInnerHTML={{ __html: sanitizeFoomaticHtml(printer.notes) }}
/>
</div>
) : null}
Expand Down Expand Up @@ -382,7 +427,9 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
<div
className="prose prose-sm mt-5 max-w-none text-foreground dark:prose-invert"
dangerouslySetInnerHTML={{
__html: driver.comments || "No additional notes are available for this driver.",
__html: sanitizeFoomaticHtml(
driver.comments || "No additional notes are available for this driver."
),
}}
/>

Expand Down Expand Up @@ -410,6 +457,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
))}
</section>
</div>
<RecommendedPrintersSection printerId={printer.id} />
</FoomaticPageSection>
</main>
)
Expand Down
Loading
Loading