diff --git a/packages/print/README.md b/packages/print/README.md new file mode 100644 index 000000000..1a46e28bc --- /dev/null +++ b/packages/print/README.md @@ -0,0 +1,52 @@ +# Omnivore Print-to-PDF (macOS) + +This adds a local IPP “virtual printer” (running in Docker) that writes PDFs to a shared spool volume, then uploads each PDF into your Omnivore library as a File/PDF item via the normal GraphQL signed-upload flow. + +## Requirements + +- Self-hosting stack running via docker compose +- A dedicated watcher env file: + - `self-hosting/docker-compose/print-watcher/.env` (copy from `.env.example`) +- `print-server` runs on its own LAN IP (macvlan) for Bonjour/mDNS. You must set: + - `PRINT_MACVLAN_PARENT` (host interface name, e.g. `eth0`) + - `PRINT_SERVER_IP` (an unused LAN IP, e.g. `10.0.1.250`) + - Optional: `PRINT_MACVLAN_SUBNET`, `PRINT_MACVLAN_GATEWAY` + +## Start services + +From `self-hosting/docker-compose/` (canonical) or `self-hosting/docker-compose/self-build/`: + +```sh +COMPOSE_PROFILES=print docker compose up -d --build +``` + +This exposes IPP on: + +- `PRINT_SERVER_IP:631` (CUPS on the print server container’s LAN IP) +- Bonjour/mDNS advertisement (should appear automatically in macOS “Add Printer…”) + +## Add the printer in macOS + +Option A (preferred): use Bonjour discovery + +1. System Settings → Printers & Scanners → Add Printer… +2. Default tab → select `OmnivorePDF` (or similar) when it appears. + +Option B: manual IPP URL + +1. System Settings → Printers & Scanners → Add Printer… +2. IP tab +3. Address: `PRINT_SERVER_IP` +4. Protocol: `Internet Printing Protocol - IPP` +5. Queue: `printers/OmnivorePDF` + +When you print to this printer, PDFs should appear in Omnivore automatically. + +## Troubleshooting + +- Verify the services: + - `docker compose logs -f print-server` + - `docker compose logs -f print-watcher` +- If uploads fail, confirm: + - `OMNIVORE_API_KEY` is present in your compose `.env`. + - `OMNIVORE_GRAPHQL_ENDPOINT` is reachable from the watcher container (defaults to `http://api:8080/api/graphql`). diff --git a/packages/print/ippserver/Dockerfile b/packages/print/ippserver/Dockerfile new file mode 100644 index 000000000..71f50832b --- /dev/null +++ b/packages/print/ippserver/Dockerfile @@ -0,0 +1,24 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cups \ + cups-pdf \ + avahi-daemon \ + avahi-utils \ + libnss-mdns \ + dbus \ + ghostscript \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENV PRINT_SPOOL_DIR=/spool/incoming +ENV PRINTER_NAME=OmnivorePDF + +VOLUME ["/spool"] + +EXPOSE 631/tcp 5353/udp + +CMD ["/entrypoint.sh"] diff --git a/packages/print/ippserver/entrypoint.sh b/packages/print/ippserver/entrypoint.sh new file mode 100644 index 000000000..7afad25c8 --- /dev/null +++ b/packages/print/ippserver/entrypoint.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +SPOOL_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}" +PRINTER_NAME="${PRINTER_NAME:-OmnivorePDF}" + +mkdir -p "$SPOOL_DIR" + +# Configure cups-pdf output directory. +if [[ -f /etc/cups/cups-pdf.conf ]]; then + # Default config ships with "#Out ...". Un-comment (or replace) it so output goes + # into the shared spool volume. + sed -i -E "s|^[[:space:]]*#?[[:space:]]*Out[[:space:]]+.*$|Out ${SPOOL_DIR}|" /etc/cups/cups-pdf.conf || true +fi + +# Configure CUPS to listen on LAN + allow printing + advertise via DNS-SD. +if [[ -f /etc/cups/cupsd.conf ]]; then + tmp="$(mktemp)" + awk ' + BEGIN { in_root=0; found_printers=0 } + $0 == "Listen localhost:631" { print "Port 631"; next } + $0 == "" { + print "" + print " Order allow,deny" + print " Allow all" + print "" + in_root=1 + next + } + in_root { + if ($0 == "") in_root=0 + next + } + $0 == "" { found_printers=1 } + { print } + END { + if (!found_printers) { + print "" + print "" + print " Order allow,deny" + print " Allow all" + print "" + } + } + ' /etc/cups/cupsd.conf > "$tmp" + mv "$tmp" /etc/cups/cupsd.conf + + if ! grep -q '^ServerAlias \\*$' /etc/cups/cupsd.conf; then + echo 'ServerAlias *' >> /etc/cups/cupsd.conf + fi + if ! grep -q '^DefaultShared Yes$' /etc/cups/cupsd.conf; then + echo 'DefaultShared Yes' >> /etc/cups/cupsd.conf + fi + if ! grep -q '^Browsing On$' /etc/cups/cupsd.conf; then + echo 'Browsing On' >> /etc/cups/cupsd.conf + fi + if ! grep -q '^BrowseLocalProtocols dnssd$' /etc/cups/cupsd.conf; then + echo 'BrowseLocalProtocols dnssd' >> /etc/cups/cupsd.conf + fi +fi + +# Avahi requires a system dbus. +mkdir -p /run/dbus +dbus-daemon --system --fork + +# Ensure avahi runs without chroot (common in containers). +mkdir -p /run/avahi-daemon +avahi-daemon --no-chroot --daemonize || true + +# Start CUPS in the background to allow printer setup. +cupsd -f & +CUPSD_PID=$! + +# Wait briefly for cupsd socket. +for _ in $(seq 1 20); do + if lpstat -r >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# Create the PDF printer if missing. +if ! lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then + PPD="$(find /usr/share -maxdepth 6 -type f -iname '*cups-pdf*.ppd' -print -quit 2>/dev/null || true)" + if [[ -z "$PPD" ]]; then + PPD="$(find /usr/share -maxdepth 6 -type f -iname '*CUPS-PDF*.ppd' -print -quit 2>/dev/null || true)" + fi + + if [[ -n "$PPD" && -f "$PPD" ]]; then + lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -P "$PPD" -o printer-is-shared=true || true + else + # If no PPD is found, we can still create the queue but driver selection may vary by client. + lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -m raw -o printer-is-shared=true || true + fi + + cupsenable "${PRINTER_NAME}" || true + cupsaccept "${PRINTER_NAME}" || true +fi + +echo "CUPS ready. Printer: ${PRINTER_NAME}. Spool: ${SPOOL_DIR}" + +# Keep the container running. +wait "${CUPSD_PID}" diff --git a/packages/print/package.json b/packages/print/package.json new file mode 100644 index 000000000..62726d2fc --- /dev/null +++ b/packages/print/package.json @@ -0,0 +1,6 @@ +{ + "name": "@omnivore/print", + "private": true, + "version": "0.0.0", + "description": "Docker-based print-to-Omnivore pipeline assets (IPP server + watcher)." +} diff --git a/packages/print/watcher/Dockerfile b/packages/print/watcher/Dockerfile new file mode 100644 index 000000000..a210a86fe --- /dev/null +++ b/packages/print/watcher/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:3.19 + +RUN apk add --no-cache bash curl jq inotify-tools coreutils ca-certificates + +COPY omnivore-print-upload.sh /usr/local/bin/omnivore-print-upload +COPY omnivore-print-watch.sh /usr/local/bin/omnivore-print-watch + +RUN chmod +x /usr/local/bin/omnivore-print-upload /usr/local/bin/omnivore-print-watch + +ENV OMNIVORE_GRAPHQL_ENDPOINT=http://api:8080/api/graphql +ENV PRINT_SPOOL_DIR=/spool/incoming +ENV PRINT_PROCESSING_DIR=/spool/processing +ENV PRINT_DONE_DIR=/spool/done +ENV PRINT_FAILED_DIR=/spool/failed + +CMD ["/usr/local/bin/omnivore-print-watch"] + diff --git a/packages/print/watcher/omnivore-print-upload.sh b/packages/print/watcher/omnivore-print-upload.sh new file mode 100644 index 000000000..f2cb4fdee --- /dev/null +++ b/packages/print/watcher/omnivore-print-upload.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: omnivore-print-upload " >&2 + exit 2 +fi + +PDF_PATH="$1" + +if [[ ! -f "$PDF_PATH" ]]; then + echo "file not found: $PDF_PATH" >&2 + exit 2 +fi + +if [[ "${PDF_PATH,,}" != *.pdf ]]; then + echo "not a pdf: $PDF_PATH" >&2 + exit 2 +fi + +if [[ -z "${OMNIVORE_API_KEY:-}" ]]; then + echo "Missing OMNIVORE_API_KEY" >&2 + exit 2 +fi + +GRAPHQL_ENDPOINT="${OMNIVORE_GRAPHQL_ENDPOINT:-http://api:8080/api/graphql}" + +base="$(basename "$PDF_PATH")" +encoded_base="$(printf '%s' "$base" | jq -sRr @uri)" +file_url="file:///omnivore-print/${encoded_base}" + +UPLOAD_MUTATION='mutation UploadFileRequest($input: UploadFileRequestInput!) { uploadFileRequest(input:$input) { __typename ... on UploadFileRequestSuccess { id uploadSignedUrl createdPageId } ... on UploadFileRequestError { errorCodes } } }' +SAVE_MUTATION='mutation SaveFile($input: SaveFileInput!) { saveFile(input:$input) { __typename ... on SaveSuccess { url clientRequestId } ... on SaveError { errorCodes message } } }' + +graphql() { + local payload="$1" + curl -sS \ + -H "Content-Type: application/json" \ + -H "Omnivore-Authorization: ${OMNIVORE_API_KEY}" \ + -H "User-Agent: omnivore-print-watcher/1.0" \ + --data "$payload" \ + "$GRAPHQL_ENDPOINT" +} + +upload_payload="$( + jq -nc \ + --arg query "$UPLOAD_MUTATION" \ + --arg url "$file_url" \ + --arg contentType "application/pdf" \ + '{query:$query, variables:{input:{url:$url, contentType:$contentType, createPageEntry:true}}}' +)" + +upload_resp="$(graphql "$upload_payload")" + +if [[ "$(jq -r '.errors | length // 0' <<<"$upload_resp")" != "0" ]]; then + echo "uploadFileRequest graphql errors: $(jq -c '.errors' <<<"$upload_resp")" >&2 + exit 1 +fi + +upload_typename="$(jq -r '.data.uploadFileRequest.__typename // empty' <<<"$upload_resp")" +if [[ "$upload_typename" != "UploadFileRequestSuccess" ]]; then + echo "uploadFileRequest failed: $(jq -c '.data.uploadFileRequest' <<<"$upload_resp")" >&2 + exit 1 +fi + +upload_file_id="$(jq -r '.data.uploadFileRequest.id' <<<"$upload_resp")" +upload_signed_url="$(jq -r '.data.uploadFileRequest.uploadSignedUrl' <<<"$upload_resp")" +created_page_id="$(jq -r '.data.uploadFileRequest.createdPageId' <<<"$upload_resp")" + +curl -sS --fail \ + -X PUT \ + -H "Content-Type: application/pdf" \ + --data-binary @"$PDF_PATH" \ + "$upload_signed_url" >/dev/null + +save_payload="$( + jq -nc \ + --arg query "$SAVE_MUTATION" \ + --arg url "$file_url" \ + --arg source "print" \ + --arg clientRequestId "$created_page_id" \ + --arg uploadFileId "$upload_file_id" \ + '{query:$query, variables:{input:{url:$url, source:$source, clientRequestId:$clientRequestId, uploadFileId:$uploadFileId}}}' +)" + +save_resp="$(graphql "$save_payload")" + +if [[ "$(jq -r '.errors | length // 0' <<<"$save_resp")" != "0" ]]; then + echo "saveFile graphql errors: $(jq -c '.errors' <<<"$save_resp")" >&2 + exit 1 +fi + +save_typename="$(jq -r '.data.saveFile.__typename // empty' <<<"$save_resp")" +if [[ "$save_typename" != "SaveSuccess" ]]; then + echo "saveFile failed: $(jq -c '.data.saveFile' <<<"$save_resp")" >&2 + exit 1 +fi + +echo "uploaded ${base} -> pageId=${created_page_id}" + diff --git a/packages/print/watcher/omnivore-print-watch.sh b/packages/print/watcher/omnivore-print-watch.sh new file mode 100644 index 000000000..26c7e26cd --- /dev/null +++ b/packages/print/watcher/omnivore-print-watch.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +INCOMING_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}" +PROCESSING_DIR="${PRINT_PROCESSING_DIR:-/spool/processing}" +DONE_DIR="${PRINT_DONE_DIR:-/spool/done}" +FAILED_DIR="${PRINT_FAILED_DIR:-/spool/failed}" + +mkdir -p "$INCOMING_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$FAILED_DIR" + +unique_dest() { + local dir="$1" + local base="$2" + local dest="${dir}/${base}" + if [[ ! -e "$dest" ]]; then + echo "$dest" + return + fi + local ts + ts="$(date -u +%Y%m%dT%H%M%SZ)" + echo "${dir}/${ts}.$$-${base}" +} + +process_one() { + local src="$1" + if [[ ! -f "$src" ]]; then + return 0 + fi + if [[ "${src,,}" != *.pdf ]]; then + return 0 + fi + + local base processing done failed + base="$(basename "$src")" + processing="$(unique_dest "$PROCESSING_DIR" "$base")" + mv "$src" "$processing" + + if /usr/local/bin/omnivore-print-upload "$processing"; then + done="$(unique_dest "$DONE_DIR" "$(basename "$processing")")" + mv "$processing" "$done" + else + failed="$(unique_dest "$FAILED_DIR" "$(basename "$processing")")" + mv "$processing" "$failed" + fi +} + +while IFS= read -r -d '' f; do + process_one "$f" +done < <(find "$INCOMING_DIR" -type f \( -iname '*.pdf' -o -iname '*.PDF' \) -print0 2>/dev/null || true) + +echo "watching $INCOMING_DIR for printed PDFs..." + +inotifywait -m -r \ + -e close_write \ + -e moved_to \ + --format '%w%f' \ + "$INCOMING_DIR" | while read -r path; do + process_one "$path" + done