Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions core/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 0 additions & 20 deletions core/resources/logback.xml

This file was deleted.

19 changes: 15 additions & 4 deletions core/src/main/scala/com/tjclp/xlcr/cli/Commands.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand All @@ -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 */
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -353,7 +363,8 @@ object Commands:
loInstancesOpt,
loRestartAfterOpt,
loTaskTimeoutOpt,
loQueueTimeoutOpt
loQueueTimeoutOpt,
licenseAwareCapabilitiesFlag
).mapN(ServerArgs.apply)

private val serverStartCmd: Opts[CliCommand] =
Expand Down
20 changes: 20 additions & 0 deletions core/test/src/cli/CommandsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
34 changes: 0 additions & 34 deletions scripts/xlcr-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 13 additions & 4 deletions xlcr/src/main/aspose/com/tjclp/xlcr/cli/BackendWiring.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions xlcr/src/main/no-aspose/com/tjclp/xlcr/cli/BackendWiring.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
23 changes: 17 additions & 6 deletions xlcr/src/main/scala/com/tjclp/xlcr/cli/UnifiedTransforms.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Loading