diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1b88bf..a3cd1dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wired scalafix into Mill build via mill-scalafix 0.6.0 plugin (`./mill __.fix`). - Updated scalafix OrganizeImports to coalesce all imports to wildcards with Scala 3 dialect. - Added RedundantSyntax scalafix rule. +- Added opt-in CLI flag `--license-aware-capabilities` (and `XLCR_LICENSE_AWARE_CAPABILITIES` for server mode) to enable runtime Aspose license-aware capability checks while keeping default fast probing. ### Removed - `core-spark` module and all Spark pipeline sources/tests/docs. diff --git a/README.md b/README.md index 9ee71954..4abd1278 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ xlcr split -i message.eml -d attachments/ # Show document metadata xlcr info -i document.pdf +# Show metadata/capabilities with runtime Aspose license checks (opt-in, slower) +xlcr info -i document.pdf --license-aware-capabilities + # List all supported conversions xlcr --backend-info @@ -147,6 +150,7 @@ docker compose up server | `--lo-restart-after` | `XLCR_LO_RESTART_AFTER` | `200` | Restart LO after N conversions | | `--lo-task-timeout` | `XLCR_LO_TASK_TIMEOUT` | `120000` | Conversion timeout (ms) | | `--lo-queue-timeout` | `XLCR_LO_QUEUE_TIMEOUT` | `30000` | Queue wait timeout (ms) | +| `--license-aware-capabilities` | `XLCR_LICENSE_AWARE_CAPABILITIES` | `false` | Use runtime Aspose license checks for `/capabilities`, `/info`, and convert/split preflight checks | ### LibreOffice Process Pooling @@ -158,6 +162,9 @@ xlcr server start --lo-instances 4 --lo-restart-after 100 # Or via environment variables XLCR_LO_INSTANCES=4 XLCR_LO_RESTART_AFTER=100 xlcr server start + +# Enable runtime license-aware capability checks (opt-in) +xlcr server start --license-aware-capabilities ``` Each instance runs as a separate LibreOffice process on a dedicated port (starting from 2002). JODConverter handles round-robin task distribution and automatic process restarts. Budget ~200-300MB RAM per instance. diff --git a/core/package.mill b/core/package.mill index ab6151ab..17bde5b8 100644 --- a/core/package.mill +++ b/core/package.mill @@ -18,9 +18,10 @@ trait CoreModule extends build.XLCRModule { def testResources = Task.Sources(moduleDir / "test" / "resources") override def mvnDeps = Seq( - // Logging + // Logging: ZIO-native logging with SLF4J2 bridge (captures Tika/POI/Aspose logs) mvn"org.slf4j:slf4j-api:2.0.17", - mvn"ch.qos.logback:logback-classic:1.5.32", + mvn"dev.zio::zio-logging:2.5.3", + mvn"dev.zio::zio-logging-slf4j2-bridge:2.5.3", mvn"org.apache.logging.log4j:log4j-to-slf4j:2.25.3", // Bridge Log4j2 (from Tika) to SLF4J // ZIO mvn"dev.zio::zio:2.1.24", diff --git a/core/resources/logback.xml b/core/resources/logback.xml deleted file mode 100644 index db016021..00000000 --- a/core/resources/logback.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - logs/application.log - true - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - diff --git a/core/src/main/scala/com/tjclp/xlcr/cli/Commands.scala b/core/src/main/scala/com/tjclp/xlcr/cli/Commands.scala index 62bf298b..a321ff5e 100644 --- a/core/src/main/scala/com/tjclp/xlcr/cli/Commands.scala +++ b/core/src/main/scala/com/tjclp/xlcr/cli/Commands.scala @@ -43,7 +43,8 @@ object Commands: case class InfoArgs( input: Path, extensionOnly: Boolean = false, - format: OutputFormat = OutputFormat.Text + format: OutputFormat = OutputFormat.Text, + licenseAwareCapabilities: Boolean = false ) /** Arguments for the server command (pure data — no HTTP imports) */ @@ -54,7 +55,8 @@ object Commands: loInstances: Option[Int] = None, loRestartAfter: Option[Int] = None, loTaskTimeout: Option[Long] = None, - loQueueTimeout: Option[Long] = None + loQueueTimeout: Option[Long] = None, + licenseAwareCapabilities: Boolean = false ) /** Output format for the info command */ @@ -140,6 +142,12 @@ object Commands: else OutputFormat.Text } + private val licenseAwareCapabilitiesFlag: Opts[Boolean] = + Opts.flag( + long = "license-aware-capabilities", + help = "Run runtime Aspose license checks when probing capabilities (slower, more accurate)" + ).orFalse + private val backendOpt: Opts[Option[Backend]] = Opts.option[String]( long = "backend", @@ -279,7 +287,9 @@ object Commands: )(splitOpts.map(CliCommand.Split.apply)) private val infoOpts: Opts[InfoArgs] = - (inputOpt, extensionOnlyFlag, outputFormatOpt).mapN(InfoArgs.apply) + (inputOpt, extensionOnlyFlag, outputFormatOpt, licenseAwareCapabilitiesFlag).mapN( + InfoArgs.apply + ) val infoCmd: Opts[CliCommand] = Opts.subcommand( @@ -353,7 +363,8 @@ object Commands: loInstancesOpt, loRestartAfterOpt, loTaskTimeoutOpt, - loQueueTimeoutOpt + loQueueTimeoutOpt, + licenseAwareCapabilitiesFlag ).mapN(ServerArgs.apply) private val serverStartCmd: Opts[CliCommand] = diff --git a/core/test/src/cli/CommandsSpec.scala b/core/test/src/cli/CommandsSpec.scala index 875f5c7f..0ec2d5b1 100644 --- a/core/test/src/cli/CommandsSpec.scala +++ b/core/test/src/cli/CommandsSpec.scala @@ -198,6 +198,26 @@ class CommandsSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Info command") } + it should "parse info command with --license-aware-capabilities" in { + val result = parse(Seq("info", "-i", "document.pdf", "--license-aware-capabilities")) + + result.isRight shouldBe true + result.toOption.get match + case CliCommand.Info(args) => + args.licenseAwareCapabilities shouldBe true + case _ => fail("Expected Info command") + } + + it should "parse server start with --license-aware-capabilities" in { + val result = parse(Seq("server", "start", "--license-aware-capabilities")) + + result.isRight shouldBe true + result.toOption.get match + case CliCommand.Server(args) => + args.licenseAwareCapabilities shouldBe true + case _ => fail("Expected Server command") + } + // ============================================================================ // Path Handling Tests // ============================================================================ diff --git a/scripts/xlcr-wrapper.sh b/scripts/xlcr-wrapper.sh index d2232060..abe2f615 100755 --- a/scripts/xlcr-wrapper.sh +++ b/scripts/xlcr-wrapper.sh @@ -45,39 +45,5 @@ MEMORY_OPTS=( "-Xmx2g" ) -# Show backend status if requested -if [[ "$1" == "--backend-info" ]]; then - echo "XLCR Backend Status:" - echo " JAR: $XLCR_JAR" - echo "" - - # Check for bundled license in JAR - if unzip -l "$XLCR_JAR" 2>/dev/null | grep -q "Aspose.Total.Java.lic"; then - echo " Aspose: Licensed (bundled in JAR)" - elif [[ -n "$ASPOSE_TOTAL_LICENSE_B64" ]]; then - echo " Aspose: Licensed (env ASPOSE_TOTAL_LICENSE_B64)" - elif [[ -f "Aspose.Total.Java.lic" ]]; then - echo " Aspose: Licensed (local file)" - else - echo " Aspose: Evaluation mode (no license found)" - fi - - # Check LibreOffice - if [[ -d "/Applications/LibreOffice.app" ]]; then - LO_VERSION=$(/Applications/LibreOffice.app/Contents/MacOS/soffice --version 2>/dev/null | head -1 || echo "version unknown") - echo " LibreOffice: Available (macOS app - $LO_VERSION)" - elif command -v soffice &> /dev/null; then - LO_VERSION=$(soffice --version 2>/dev/null | head -1 || echo "version unknown") - echo " LibreOffice: Available ($LO_VERSION)" - elif [[ -d "${LIBREOFFICE_HOME:-/usr/lib/libreoffice}" ]]; then - echo " LibreOffice: Available at ${LIBREOFFICE_HOME:-/usr/lib/libreoffice}" - else - echo " LibreOffice: Not found" - fi - - echo "" - # Continue to also show Java-side backend info -fi - # Run XLCR (filter out JVM module warnings) java "${JVM_OPTS[@]}" "${MEMORY_OPTS[@]}" ${JAVA_OPTS:-} -jar "$XLCR_JAR" "$@" 2> >(grep -v "^WARNING: package sun.misc not in java.base$" >&2) diff --git a/xlcr/src/main/aspose/com/tjclp/xlcr/cli/BackendWiring.scala b/xlcr/src/main/aspose/com/tjclp/xlcr/cli/BackendWiring.scala index 46f29244..c5185552 100644 --- a/xlcr/src/main/aspose/com/tjclp/xlcr/cli/BackendWiring.scala +++ b/xlcr/src/main/aspose/com/tjclp/xlcr/cli/BackendWiring.scala @@ -28,11 +28,20 @@ object BackendWiring: ): ZIO[Any, TransformError, Chunk[DynamicFragment]] = AsposeTransforms.split(input, options) - def asposeCanConvert(from: Mime, to: Mime): Boolean = - AsposeTransforms.canConvertLicensed(from, to) + def asposeCanConvert( + from: Mime, + to: Mime, + licenseAwareCapabilities: Boolean = false + ): Boolean = + if licenseAwareCapabilities then AsposeTransforms.canConvertLicensed(from, to) + else AsposeTransforms.canConvert(from, to) - def asposeCanSplit(mime: Mime): Boolean = - AsposeTransforms.canSplitLicensed(mime) + def asposeCanSplit( + mime: Mime, + licenseAwareCapabilities: Boolean = false + ): Boolean = + if licenseAwareCapabilities then AsposeTransforms.canSplitLicensed(mime) + else AsposeTransforms.canSplit(mime) def checkAsposeStatus(): Unit = if AsposeLicenseV2.licenseAvailable then diff --git a/xlcr/src/main/no-aspose/com/tjclp/xlcr/cli/BackendWiring.scala b/xlcr/src/main/no-aspose/com/tjclp/xlcr/cli/BackendWiring.scala index 79e5cfe1..ec9c1173 100644 --- a/xlcr/src/main/no-aspose/com/tjclp/xlcr/cli/BackendWiring.scala +++ b/xlcr/src/main/no-aspose/com/tjclp/xlcr/cli/BackendWiring.scala @@ -28,9 +28,16 @@ object BackendWiring: ): ZIO[Any, TransformError, Chunk[DynamicFragment]] = ZIO.fail(UnsupportedConversion(input.mime, input.mime)) - def asposeCanConvert(from: Mime, to: Mime): Boolean = false + def asposeCanConvert( + from: Mime, + to: Mime, + licenseAwareCapabilities: Boolean = false + ): Boolean = false - def asposeCanSplit(mime: Mime): Boolean = false + def asposeCanSplit( + mime: Mime, + licenseAwareCapabilities: Boolean = false + ): Boolean = false def checkAsposeStatus(): Unit = println(" Status: Not included in this build (no Aspose license detected at build time)") diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/cli/UnifiedTransforms.scala b/xlcr/src/main/scala/com/tjclp/xlcr/cli/UnifiedTransforms.scala index d75bf0ed..e5d3a2e3 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/cli/UnifiedTransforms.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/cli/UnifiedTransforms.scala @@ -80,14 +80,20 @@ object UnifiedTransforms: * When `backend` is None, returns true if ANY backend supports it. When a specific backend is * given, checks only that backend. */ - def canConvert(from: Mime, to: Mime, backend: Option[Backend] = None): Boolean = + def canConvert( + from: Mime, + to: Mime, + backend: Option[Backend] = None, + licenseAwareCapabilities: Boolean = false + ): Boolean = backend match - case Some(Backend.Aspose) => BackendWiring.asposeCanConvert(from, to) + case Some(Backend.Aspose) => + BackendWiring.asposeCanConvert(from, to, licenseAwareCapabilities) case Some(Backend.LibreOffice) => libreOfficeAvailable && LibreOfficeTransforms.canConvert(from, to) case Some(Backend.Xlcr) => XlcrTransforms.canConvert(from, to) case None => - BackendWiring.asposeCanConvert(from, to) || + BackendWiring.asposeCanConvert(from, to, licenseAwareCapabilities) || (libreOfficeAvailable && LibreOfficeTransforms.canConvert(from, to)) || XlcrTransforms.canConvert(from, to) @@ -127,13 +133,18 @@ object UnifiedTransforms: * When `backend` is None, returns true if ANY backend supports it. When a specific backend is * given, checks only that backend. */ - def canSplit(mime: Mime, backend: Option[Backend] = None): Boolean = + def canSplit( + mime: Mime, + backend: Option[Backend] = None, + licenseAwareCapabilities: Boolean = false + ): Boolean = backend match - case Some(Backend.Aspose) => BackendWiring.asposeCanSplit(mime) + case Some(Backend.Aspose) => + BackendWiring.asposeCanSplit(mime, licenseAwareCapabilities) case Some(Backend.LibreOffice) => libreOfficeAvailable && LibreOfficeTransforms.canSplit(mime) case Some(Backend.Xlcr) => XlcrTransforms.canSplit(mime) case None => - BackendWiring.asposeCanSplit(mime) || + BackendWiring.asposeCanSplit(mime, licenseAwareCapabilities) || (libreOfficeAvailable && LibreOfficeTransforms.canSplit(mime)) || XlcrTransforms.canSplit(mime) diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/cli/XlcrMain.scala b/xlcr/src/main/scala/com/tjclp/xlcr/cli/XlcrMain.scala index 41f62a38..494a9326 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/cli/XlcrMain.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/cli/XlcrMain.scala @@ -10,11 +10,9 @@ import com.tjclp.xlcr.output.* import com.tjclp.xlcr.server.* import com.tjclp.xlcr.types.* -import org.apache.tika.io.TikaInputStream -import org.apache.tika.metadata.* -import org.apache.tika.parser.AutoDetectParser -import org.apache.tika.sax.BodyContentHandler import zio.* +import zio.logging.* +import zio.logging.slf4j.bridge.Slf4jBridge /** * XLCR CLI entry point with compile-time transform discovery. @@ -65,6 +63,27 @@ object XlcrMain extends ZIOAppDefault: java.lang.System.setProperty("java.library.path", libDir + ":" + existing) } + // ZIO-native logging: stderr console logger + SLF4J2 bridge (captures Tika/POI/Aspose logs). + // XLCR_LOG_LEVEL env var controls root log level (default: WARN for quiet CLI operation). + private val logLevel: LogLevel = + java.lang.System.getenv("XLCR_LOG_LEVEL") match + case "TRACE" => LogLevel.Trace + case "DEBUG" => LogLevel.Debug + case "INFO" => LogLevel.Info + case "WARNING" => LogLevel.Warning + case "ERROR" => LogLevel.Error + case _ => LogLevel.Warning + + private val loggerConfig = ConsoleLoggerConfig( + LogFormat.default, + LogFilter.LogLevelByNameConfig(logLevel) + ) + + override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = + Runtime.removeDefaultLoggers >>> consoleErrLogger(loggerConfig) >+> Slf4jBridge.init( + loggerConfig.toFilter + ) + override def run: ZIO[ZIOAppArgs, Any, ExitCode] = for // No TransformInit.initialize() - zero startup overhead! @@ -113,7 +132,8 @@ object XlcrMain extends ZIOAppDefault: loInstances = args.loInstances, loRestartAfter = args.loRestartAfter, loTaskTimeout = args.loTaskTimeout, - loQueueTimeout = args.loQueueTimeout + loQueueTimeout = args.loQueueTimeout, + licenseAwareCapabilities = args.licenseAwareCapabilities ) Server.start(config).as(ExitCode.success) @@ -165,8 +185,10 @@ object XlcrMain extends ZIOAppDefault: Console.printLine(s"Wrote ${result.size} bytes to ${args.output}") ) - _ <- Console.printLine( - s"Successfully converted ${args.input.getFileName} to ${args.output.getFileName}" + _ <- ZIO.when(args.verbose)( + Console.printLine( + s"Successfully converted ${args.input.getFileName} to ${args.output.getFileName}" + ) ) yield ExitCode.success @@ -218,8 +240,10 @@ object XlcrMain extends ZIOAppDefault: // Create ZIP file (default) writeFragmentsToZip(args.output, fragments, args.verbose) - _ <- Console.printLine( - s"Successfully split ${args.input.getFileName} into ${fragments.size} parts" + _ <- ZIO.when(args.verbose)( + Console.printLine( + s"Successfully split ${args.input.getFileName} into ${fragments.size} parts" + ) ) yield ExitCode.success @@ -314,8 +338,11 @@ object XlcrMain extends ZIOAppDefault: ZIO.attemptBlocking(extractMetadata(inputChunk, args.input.getFileName.toString)) // Check available conversions and splittability - availableTargets = findAvailableConversions(inputMime) - canSplitFile = UnifiedTransforms.canSplit(inputMime) + availableTargets = findAvailableConversions(inputMime, args.licenseAwareCapabilities) + canSplitFile = UnifiedTransforms.canSplit( + inputMime, + licenseAwareCapabilities = args.licenseAwareCapabilities + ) // Output based on format _ <- args.format match @@ -412,7 +439,10 @@ object XlcrMain extends ZIOAppDefault: else if bytes < 1024 * 1024 * 1024 then f"${bytes / (1024.0 * 1024)}%.1f MB" else f"${bytes / (1024.0 * 1024 * 1024)}%.1f GB" - private def findAvailableConversions(from: Mime): List[Mime] = + private def findAvailableConversions( + from: Mime, + licenseAwareCapabilities: Boolean = false + ): List[Mime] = // Check common target formats val targets = List( Mime.pdf, @@ -429,7 +459,13 @@ object XlcrMain extends ZIOAppDefault: Mime.png, Mime.jpeg ) - targets.filter(target => target != from && UnifiedTransforms.canConvert(from, target)) + targets.filter(target => + target != from && UnifiedTransforms.canConvert( + from, + target, + licenseAwareCapabilities = licenseAwareCapabilities + ) + ) end findAvailableConversions // ============================================================================ @@ -440,15 +476,13 @@ object XlcrMain extends ZIOAppDefault: if data.isEmpty then Map.empty else try - val metadata = new Metadata() - metadata.set(HttpHeaders.CONTENT_LOCATION, filename) - val parser = new AutoDetectParser() - val handler = new BodyContentHandler(-1) - Using.resource(TikaInputStream.get(new java.io.ByteArrayInputStream(data.toArray))) { - stream => - parser.parse(stream, handler, metadata) + val mime = Mime.detectLazily(data, filename) + DocumentInfo.extractMetadataOnly(data.toArray, mime).map { case (k, v) => + k -> + (v match + case list: List[?] => list.mkString(", ") + case other => other.toString) } - metadata.names().toList.map(name => name -> metadata.get(name)).toMap catch case _: Throwable => Map.empty diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/Server.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/Server.scala index c78c8a8b..63ea97c0 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/Server.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/Server.scala @@ -73,7 +73,12 @@ object Server: _ <- ZIO.logInfo(" POST /info - Get document info") _ <- ZIO.logInfo(" GET /capabilities - List capabilities") _ <- ZIO.logInfo(" GET /health - Health check") - _ <- zio.http.Server.serve(Routes.all) + _ <- ZIO.logInfo( + s"Capability probing mode: ${ + if config.licenseAwareCapabilities then "license-aware" else "fast" + }" + ) + _ <- zio.http.Server.serve(Routes.all(config.licenseAwareCapabilities)) yield () program.forever.provide( diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/ServerConfig.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/ServerConfig.scala index 4e47da21..5ee236f6 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/ServerConfig.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/ServerConfig.scala @@ -17,6 +17,9 @@ package com.tjclp.xlcr.server * LibreOffice task execution timeout in ms (default: 120000) * @param loQueueTimeout * LibreOffice task queue timeout in ms (default: 30000) + * @param licenseAwareCapabilities + * Enable runtime Aspose license checks for capability probing and preflight checks (default: + * false) */ final case class ServerConfig( host: String = "0.0.0.0", @@ -25,7 +28,8 @@ final case class ServerConfig( loInstances: Int = 1, loRestartAfter: Int = 200, loTaskTimeout: Long = 120000L, // 2 minutes - loQueueTimeout: Long = 30000L // 30 seconds + loQueueTimeout: Long = 30000L, // 30 seconds + licenseAwareCapabilities: Boolean = false ) object ServerConfig: @@ -44,6 +48,8 @@ object ServerConfig: * - XLCR_LO_RESTART_AFTER: Restart after N conversions (default: 200) * - XLCR_LO_TASK_TIMEOUT: Task execution timeout in ms (default: 120000) * - XLCR_LO_QUEUE_TIMEOUT: Task queue timeout in ms (default: 30000) + * - XLCR_LICENSE_AWARE_CAPABILITIES: Enable runtime license-aware capability checks (default: + * false) */ def fromEnv: ServerConfig = ServerConfig( @@ -68,7 +74,11 @@ object ServerConfig: loQueueTimeout = sys.env .get("XLCR_LO_QUEUE_TIMEOUT") .flatMap(_.toLongOption) - .getOrElse(30000L) + .getOrElse(30000L), + licenseAwareCapabilities = sys.env + .get("XLCR_LICENSE_AWARE_CAPABILITIES") + .flatMap(parseBooleanEnv) + .getOrElse(false) ) /** @@ -83,7 +93,8 @@ object ServerConfig: loInstances: Option[Int] = None, loRestartAfter: Option[Int] = None, loTaskTimeout: Option[Long] = None, - loQueueTimeout: Option[Long] = None + loQueueTimeout: Option[Long] = None, + licenseAwareCapabilities: Boolean = false ): ServerConfig = val envConfig = fromEnv ServerConfig( @@ -93,7 +104,15 @@ object ServerConfig: loInstances = loInstances.getOrElse(envConfig.loInstances), loRestartAfter = loRestartAfter.getOrElse(envConfig.loRestartAfter), loTaskTimeout = loTaskTimeout.getOrElse(envConfig.loTaskTimeout), - loQueueTimeout = loQueueTimeout.getOrElse(envConfig.loQueueTimeout) + loQueueTimeout = loQueueTimeout.getOrElse(envConfig.loQueueTimeout), + licenseAwareCapabilities = + if licenseAwareCapabilities then true else envConfig.licenseAwareCapabilities ) end fromArgs + + private def parseBooleanEnv(value: String): Option[Boolean] = + value.trim.toLowerCase match + case "1" | "true" | "yes" | "on" => Some(true) + case "0" | "false" | "no" | "off" => Some(false) + case _ => None end ServerConfig diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/CapabilitiesRoutes.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/CapabilitiesRoutes.scala index f3d439aa..f9da17fa 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/CapabilitiesRoutes.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/CapabilitiesRoutes.scala @@ -26,21 +26,33 @@ object CapabilitiesRoutes: import Codecs.given - // Cache the capabilities response since it's static - private lazy val cachedCapabilities: CapabilitiesResponse = buildCapabilities() + // Cache both modes since these matrices are static per process. + private lazy val cachedFastCapabilities: CapabilitiesResponse = + buildCapabilities(licenseAwareCapabilities = false) - val routes: Routes[Any, Response] = Routes( - Method.GET / "capabilities" -> handler { (_: Request) => - ZIO.succeed(ResponseBuilder.json(cachedCapabilities.toJson)) - } - ) + private lazy val cachedLicenseAwareCapabilities: CapabilitiesResponse = + buildCapabilities(licenseAwareCapabilities = true) + + def routes: Routes[Any, Response] = + routes(licenseAwareCapabilities = false) + + def routes(licenseAwareCapabilities: Boolean): Routes[Any, Response] = + Routes( + Method.GET / "capabilities" -> handler { (_: Request) => + val capabilities = if licenseAwareCapabilities then + cachedLicenseAwareCapabilities + else + cachedFastCapabilities + ZIO.succeed(ResponseBuilder.json(capabilities.toJson)) + } + ) /** * Build the full capabilities matrix. * * Tests all combinations of input/output MIME types to find supported conversions. */ - private def buildCapabilities(): CapabilitiesResponse = + private def buildCapabilities(licenseAwareCapabilities: Boolean): CapabilitiesResponse = // Common document types to test val documentTypes: List[Mime] = List( // Text @@ -104,12 +116,18 @@ object CapabilitiesRoutes: for from <- documentTypes to <- outputTypes - if from != to && UnifiedTransforms.canConvert(from, to) + if from != to && UnifiedTransforms.canConvert( + from, + to, + licenseAwareCapabilities = licenseAwareCapabilities + ) yield ConversionCapability(from.value, to.value) // Find all splittable types val splits = documentTypes - .filter(m => UnifiedTransforms.canSplit(m)) + .filter(m => + UnifiedTransforms.canSplit(m, licenseAwareCapabilities = licenseAwareCapabilities) + ) .map { mime => // For most splits, output type is same as input // (sheets from XLSX, pages from PDF, etc.) diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/ConvertRoutes.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/ConvertRoutes.scala index 36e42308..7659285a 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/ConvertRoutes.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/ConvertRoutes.scala @@ -34,15 +34,21 @@ import zio.http.* */ object ConvertRoutes: - val routes: Routes[Any, Response] = Routes( + def routes: Routes[Any, Response] = + routes(licenseAwareCapabilities = false) + + def routes(licenseAwareCapabilities: Boolean): Routes[Any, Response] = Routes( Method.POST / "convert" -> handler { (request: Request) => - handleConvert(request).catchAll { error => + handleConvert(request, licenseAwareCapabilities).catchAll { error => ZIO.succeed(ResponseBuilder.error(error)) } } ) - private def handleConvert(request: Request): ZIO[Any, HttpError, Response] = + private def handleConvert( + request: Request, + licenseAwareCapabilities: Boolean + ): ZIO[Any, HttpError, Response] = for // Extract input content (handles ?detect=tika) content <- RequestHandler.extractContent(request) @@ -63,7 +69,12 @@ object ConvertRoutes: ) // Check if conversion is supported - _ <- ZIO.unless(UnifiedTransforms.canConvert(content.mime, targetMime, backend))( + _ <- ZIO.unless(UnifiedTransforms.canConvert( + content.mime, + targetMime, + backend, + licenseAwareCapabilities + ))( ZIO.fail(HttpError.unsupportedMediaType( s"Cannot convert ${content.mime.value} to ${targetMime.value}" + backend.fold("")(b => s" with backend ${b.toString.toLowerCase}") diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/InfoRoutes.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/InfoRoutes.scala index c93e2795..d9a5889e 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/InfoRoutes.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/InfoRoutes.scala @@ -31,15 +31,21 @@ object InfoRoutes: import Codecs.given - val routes: Routes[Any, Response] = Routes( + def routes: Routes[Any, Response] = + routes(licenseAwareCapabilities = false) + + def routes(licenseAwareCapabilities: Boolean): Routes[Any, Response] = Routes( Method.POST / "info" -> handler { (request: Request) => - handleInfo(request).catchAll { error => + handleInfo(request, licenseAwareCapabilities).catchAll { error => ZIO.succeed(ResponseBuilder.error(error)) } } ) - private def handleInfo(request: Request): ZIO[Any, HttpError, Response] = + private def handleInfo( + request: Request, + licenseAwareCapabilities: Boolean + ): ZIO[Any, HttpError, Response] = for // Resolve MIME consistently with /convert and /split: // header hint by default, Tika only when missing/generic or ?detect=tika. @@ -54,7 +60,10 @@ object InfoRoutes: .mapError(err => HttpError.internalError(s"Metadata extraction failed: ${err.getMessage}")) // Check split capability using request MIME semantics - canSplit = UnifiedTransforms.canSplit(content.mime) + canSplit = UnifiedTransforms.canSplit( + content.mime, + licenseAwareCapabilities = licenseAwareCapabilities + ) // Try to get fragment count if splittable (best effort) fragmentCount <- if canSplit then @@ -66,7 +75,7 @@ object InfoRoutes: ZIO.succeed(None) // Find available conversions using request MIME semantics - availableConversions = findAvailableConversions(content.mime) + availableConversions = findAvailableConversions(content.mime, licenseAwareCapabilities) // Coerce metadata values to strings for JSON metadata = metadataRaw.map { case (k, v) => @@ -90,7 +99,10 @@ object InfoRoutes: /** * Find all MIME types that this input can be converted to. */ - private def findAvailableConversions(inputMime: Mime): List[String] = + private def findAvailableConversions( + inputMime: Mime, + licenseAwareCapabilities: Boolean + ): List[String] = // Check against common output types val commonOutputs = List( Mime.plain, @@ -109,7 +121,13 @@ object InfoRoutes: ) commonOutputs - .filter(output => UnifiedTransforms.canConvert(inputMime, output)) + .filter(output => + UnifiedTransforms.canConvert( + inputMime, + output, + licenseAwareCapabilities = licenseAwareCapabilities + ) + ) .map(_.value) end findAvailableConversions end InfoRoutes diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/Routes.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/Routes.scala index c187d3c6..cbb5092b 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/Routes.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/Routes.scala @@ -99,8 +99,12 @@ object Routes: /** * All routes for the server. */ - val all: zio.http.Routes[Any, Response] = - healthRoutes ++ ConvertRoutes.routes ++ SplitRoutes.routes ++ - InfoRoutes - .routes ++ CapabilitiesRoutes.routes + def all: zio.http.Routes[Any, Response] = + all(licenseAwareCapabilities = false) + + def all(licenseAwareCapabilities: Boolean): zio.http.Routes[Any, Response] = + healthRoutes ++ ConvertRoutes.routes(licenseAwareCapabilities) ++ + SplitRoutes.routes(licenseAwareCapabilities) ++ + InfoRoutes.routes(licenseAwareCapabilities) ++ + CapabilitiesRoutes.routes(licenseAwareCapabilities) end Routes diff --git a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/SplitRoutes.scala b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/SplitRoutes.scala index ac33bf7b..ca73ccfd 100644 --- a/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/SplitRoutes.scala +++ b/xlcr/src/main/scala/com/tjclp/xlcr/server/routes/SplitRoutes.scala @@ -34,15 +34,21 @@ import zio.http.* */ object SplitRoutes: - val routes: Routes[Any, Response] = Routes( + def routes: Routes[Any, Response] = + routes(licenseAwareCapabilities = false) + + def routes(licenseAwareCapabilities: Boolean): Routes[Any, Response] = Routes( Method.POST / "split" -> handler { (request: Request) => - handleSplit(request).catchAll { error => + handleSplit(request, licenseAwareCapabilities).catchAll { error => ZIO.succeed(ResponseBuilder.error(error)) } } ) - private def handleSplit(request: Request): ZIO[Any, HttpError, Response] = + private def handleSplit( + request: Request, + licenseAwareCapabilities: Boolean + ): ZIO[Any, HttpError, Response] = for // Extract input content (handles ?detect=tika) content <- RequestHandler.extractContent(request) @@ -51,7 +57,11 @@ object SplitRoutes: backend <- RequestHandler.parseBackend(request) // Check if splitting is supported - _ <- ZIO.unless(UnifiedTransforms.canSplit(content.mime, backend))( + _ <- ZIO.unless(UnifiedTransforms.canSplit( + content.mime, + backend, + licenseAwareCapabilities + ))( ZIO.fail(HttpError.unsupportedMediaType( s"Cannot split ${content.mime.value}" + backend.fold("")(b => s" with backend ${b.toString.toLowerCase}")