3 Commits

Author SHA1 Message Date
C4illin
bca82c6edc wip 2025-03-06 17:35:59 +01:00
C4illin
3da6734e10 dcraw almost working 2025-02-18 22:05:19 +01:00
C4illin
67fef4edfb wip 2025-02-18 22:05:19 +01:00
103 changed files with 4744 additions and 7138 deletions

View File

@@ -1 +0,0 @@
1.2.2

View File

@@ -1,69 +0,0 @@
FROM debian:trixie-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
curl \
unzip \
git \
ca-certificates \
build-essential \
assimp-utils \
calibre \
dasel \
dcraw \
dvisvgm \
ffmpeg \
ghostscript \
graphicsmagick \
imagemagick-7.q16 \
inkscape \
latexmk \
libheif-examples \
libjxl-tools \
libreoffice \
libva2 \
libvips-tools \
libemail-outlook-message-perl \
lmodern \
mupdf-tools \
pandoc \
poppler-utils \
potrace \
python3-numpy \
resvg \
texlive \
texlive-fonts-recommended \
texlive-latex-extra \
texlive-latex-recommended \
texlive-xetex \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
else \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
fi && \
unzip -j bun-linux-*.zip -d /usr/local/bin && \
rm bun-linux-*.zip && \
chmod +x /usr/local/bin/bun
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
else \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
fi && \
curl -L -o /tmp/vtracer.tar.gz "https://github.com/visioncortex/vtracer/releases/download/0.6.4/${VTRACER_ASSET}" && \
tar -xzf /tmp/vtracer.tar.gz -C /tmp/ && \
mv /tmp/vtracer /usr/local/bin/vtracer && \
chmod +x /usr/local/bin/vtracer && \
rm /tmp/vtracer.tar.gz
RUN mkdir -p data
ENV NODE_ENV=development
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
EXPOSE 3000
CMD ["bun", "run", "dev"]

View File

@@ -1,53 +0,0 @@
{
"name": "ConvertX",
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-vscode.vscode-json",
"ms-vscode.vscode-docker",
"oven.bun-vscode"
],
"settings": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.suggest.autoImports": true,
"tailwindCSS.includeLanguages": {
"typescript": "javascript",
"typescriptreact": "javascript"
},
"files.associations": {
"*.css": "tailwindcss"
},
"terminal.integrated.defaultProfile.linux": "bash"
}
}
},
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "ConvertX Application",
"onAutoForward": "notify"
}
},
"postCreateCommand": "bun install",
"remoteUser": "root",
"mounts": ["source=${localWorkspaceFolder}/data,target=/app/data,type=bind"],
"containerEnv": {
"JWT_SECRET": "jwt_secret_only_used_in_testing_for_easier_hot_reloading"
}
}

View File

@@ -2,24 +2,18 @@
.editorconfig
.env
.git
.gitignore
.github
.idea
.vscode
biome.json
CHANGELOG.md
compose.yaml
coverage*
data
docker-compose*
Dockerfile*
eslint.config.js
helm-charts
images
LICENSE
Makefile
node_modules
prettier.config.js
README.md
renovate.json
SECURITY.md

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: [C4illin] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,22 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: bug
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Checklist:**
- [ ] I am accessing ConvertX over HTTPS or have `HTTP_ALLOWED=true`

View File

@@ -1,26 +0,0 @@
---
name: Converter request
about: Suggest a converter for this project
title: "[Converter Request]"
labels: "converter request"
assignees: ""
---
**What file formats are missing?**
<!-- Provide an example of what you would like to convert -->
**What converter should be added**
<!-- It has to be free and preferably open source -->
**Are you willing to add it?**
<!-- Adding a converter is very easy just copy one of the existing and modify it -->
- [ ] Yes
- [ ] No
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

View File

@@ -1,13 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature Request]"
labels: enhancement
assignees: ""
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

20
.github/release.yml vendored
View File

@@ -1,20 +0,0 @@
changelog:
exclude:
authors:
- renovate[bot]
categories:
- title: Features
labels:
- feature
- title: Bug Fixes
labels:
- fix
- title: Miscellaneous Chores
labels:
- chore
- ci
- docs
- test
- title: Other Changes
labels:
- "*"

View File

@@ -1,31 +0,0 @@
name: Check Lint
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Run linting checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.2
- name: Install dependencies
run: bun install
- name: Run lint
run: bun run lint

View File

@@ -1,20 +0,0 @@
on:
pull_request_target:
types: [opened, edited]
name: conventional-release-labels
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: bcoe/conventional-release-labels@v1
with:
type_labels: |
{
"feat": "Feature",
"fix": "Fix",
"breaking": "Breaking",
"chore": "Chore",
"docs": "Docs",
"test": "Test",
"ci": "CI",
}

View File

@@ -1,173 +1,80 @@
name: Docker
# thanks to https://github.com/sredevopsorg/multi-arch-docker-github-workflow
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: ["main"]
tags: ["v*.*.*"]
branches: [ "main" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: ["main"]
branches: [ "main" ]
workflow_dispatch:
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_USERNAME: c4illin
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# The build job builds the Docker image for each platform specified in the matrix.
build:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
permissions:
contents: write
packages: write
attestations: write
id-token: write
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-24.04' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
name: Build Docker image for ${{ matrix.platform }}
steps:
- name: Prepare environment for current platform
# This step sets up the environment for the current platform being built.
# It replaces the '/' character in the platform name with '-' and sets it as an environment variable.
# This is useful for naming artifacts and other resources that cannot contain '/'.
# The environment variable PLATFORMS_PAIR will be used later in the workflow.
id: prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: downcase REPO
run: |
echo "REPO=${GITHUB_REPOSITORY@L}" >> "${GITHUB_ENV}"
- name: Docker meta default
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ env.REPO }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ matrix.platform }}
- name: Login to GitHub Container Registry
# here we only login to ghcr.io since the this only pushes internal images
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
env:
DOCKER_BUILDKIT: 1
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
outputs: type=image,name=ghcr.io/${{ env.REPO }},push-by-digest=true,name-canonical=true,oci-mediatypes=true
push: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
if: github.event.pull_request.head.repo.full_name == github.repository
name: Merge Docker manifests
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
packages: write
attestations: write
id-token: write
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Checkout repository
uses: actions/checkout@v4
- name: downcase REPO
run: |
echo "REPO=${GITHUB_REPOSITORY@L}" >> "${GITHUB_ENV}"
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ env.REPO }}
${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
# Workaround: https://github.com/docker/build-push-action/issues/461
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get execution timestamp with RFC3339 format
id: timestamp
run: |
echo "timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.IMAGE_NAME }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
--annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \
--annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \
--annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \
--annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \
$(printf 'ghcr.io/${{ env.REPO }}@sha256:%s ' *)
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Inspect image
run: |
docker buildx imagetools inspect 'ghcr.io/${{ env.REPO }}:${{ steps.meta.outputs.version }}'

View File

@@ -15,13 +15,13 @@ jobs:
dockerHubDescription:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.IMAGE_NAME }}
short-description: ${{ github.event.repository.description }}
enable-url-completion: true
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.IMAGE_NAME }}
short-description: ${{ github.event.repository.description }}
enable-url-completion: true

25
.github/workflows/release-please.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
# this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named
# `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# token: ${{ secrets.GITHUB_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs"
# for more options
release-type: node

View File

@@ -14,8 +14,8 @@ jobs:
packages: write
steps:
- name: Remove Docker Tag
uses: ArchieAtkinson/remove-dockertag-action@v0.0
with:
tag_name: master
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Remove Docker Tag
uses: ArchieAtkinson/remove-dockertag-action@v0.0
with:
tag_name: master
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,31 +0,0 @@
name: Check Tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.2
- name: Install dependencies
run: bun install
- name: Run tests
run: bun test

2
.gitignore vendored
View File

@@ -46,7 +46,7 @@ package-lock.json
/output
/db
/data
/dist
/Bruno
/tsconfig.tsbuildinfo
/public/generated.css
/dcraw

View File

@@ -1,233 +1,204 @@
# Changelog
## [0.14.1](https://github.com/C4illin/ConvertX/compare/v0.14.0...v0.14.1) (2025-06-04)
### Bug Fixes
- change to baseline build ([6ea3058](https://github.com/C4illin/ConvertX/commit/6ea3058e66262f7a14633bddcecd5573948f524a)), closes [#311](https://github.com/C4illin/ConvertX/issues/311)
## [0.14.0](https://github.com/C4illin/ConvertX/compare/v0.13.0...v0.14.0) (2025-06-03)
### Features
- add dvisvgm ([625e1a5](https://github.com/C4illin/ConvertX/commit/625e1a51f620fe9da79d0127eb6c95f468d9ea2b))
- add ImageMagick ([b47e575](https://github.com/C4illin/ConvertX/commit/b47e5755f677056e8acecad54c0c2e28a5e137f3)), closes [#295](https://github.com/C4illin/ConvertX/issues/295), closes [#269](https://github.com/C4illin/ConvertX/issues/269)
- enhance job details display with file information ([50725ed](https://github.com/C4illin/ConvertX/commit/50725edd021bb9a7f58c85b79c1eab355ad22ced)), closes [#251](https://github.com/C4illin/ConvertX/issues/251)
- improve job details interaction and accessibility ([29ba229](https://github.com/C4illin/ConvertX/commit/29ba229bc23d2019d2ee9829da7852f884ffa611))
- show version in footer ([9a49ded](https://github.com/C4illin/ConvertX/commit/9a49dedacac7e67a432b6da0daf1967038d97d26))
### Bug Fixes
- add av1 and h26X with containers ([af5c768](https://github.com/C4illin/ConvertX/commit/af5c768dc74b3124fd7ef4b29e27c83a5d19ad49)), closes [#287](https://github.com/C4illin/ConvertX/issues/287), closes [#293](https://github.com/C4illin/ConvertX/issues/293)
- progress bars on firefox ([ff2c005](https://github.com/C4illin/ConvertX/commit/ff2c0057e890b9ecb552df30914333349ea20eb7))
- register button style ([b9bbf77](https://github.com/C4illin/ConvertX/commit/b9bbf7792f01fcaa77e3520925de107e856926f1))
- switch from alpine to debian trixie ([4e4c029](https://github.com/C4illin/ConvertX/commit/4e4c029cb800df86affb99c3a82dda9e6708bdde)), closes [#234](https://github.com/C4illin/ConvertX/issues/234), closes [#199](https://github.com/C4illin/ConvertX/issues/199)
## [0.13.0](https://github.com/C4illin/ConvertX/compare/v0.12.1...v0.13.0) (2025-05-14)
### Features
- add HIDE_HISTORY option to control visibility of history page ([9d1c931](https://github.com/C4illin/ConvertX/commit/9d1c93155cc33ed6c83f9e5122afff8f28d0e4bf))
- add potrace converter ([bdbd4a1](https://github.com/C4illin/ConvertX/commit/bdbd4a122c09559b089b985ea12c5f3e085107da))
- Add support for .HIF files ([70705c1](https://github.com/C4illin/ConvertX/commit/70705c1850d470296df85958c02a01fb5bc3a25f))
- add support for drag/drop of images ([ff2ef74](https://github.com/C4illin/ConvertX/commit/ff2ef7413542cf10ba7a6e246763bcecd6829ec1))
### Bug Fixes
- add timezone support ([4b5c732](https://github.com/C4illin/ConvertX/commit/4b5c732380bc844dccf340ea1eb4f8bfe3bb44a5)), closes [#258](https://github.com/C4illin/ConvertX/issues/258)
## [0.12.1](https://github.com/C4illin/ConvertX/compare/v0.12.0...v0.12.1) (2025-03-20)
### Bug Fixes
- rollback to bun 1.2.2 ([cdae798](https://github.com/C4illin/ConvertX/commit/cdae798fcf5879e4adea87386a38748b9a1e1ddc))
## [0.12.0](https://github.com/C4illin/ConvertX/compare/v0.11.1...v0.12.0) (2025-03-06)
### Features
- added progress bar for file upload ([db60f35](https://github.com/C4illin/ConvertX/commit/db60f355b2973f43f8e5990e6fe4e351b959b659))
- made every upload file independent ([cc54bdc](https://github.com/C4illin/ConvertX/commit/cc54bdcbe764c41cc3273485d072fd3178ad2dca))
- replace exec with execFile ([9263d17](https://github.com/C4illin/ConvertX/commit/9263d17609dc4b2b367eb7fee67b3182e283b3a3))
### Bug Fixes
- add libheif ([6b92540](https://github.com/C4illin/ConvertX/commit/6b9254047c0598963aee1d99e20ba1650a0368bf))
- add libheif ([decfea5](https://github.com/C4illin/ConvertX/commit/decfea5dc9627b216bb276a9e1578c32cfa1deb6)), closes [#202](https://github.com/C4illin/ConvertX/issues/202)
- added onerror log ([ae4bbc8](https://github.com/C4illin/ConvertX/commit/ae4bbc8baacbaf67763c62ea44140bb21cc17230))
- refactored uploadFile to only accept a single file instead of multiple ([dc82a43](https://github.com/C4illin/ConvertX/commit/dc82a438d4104b79ff423d502a6779a43928968a))
- update libheif to 1.19.5 ([fba5e21](https://github.com/C4illin/ConvertX/commit/fba5e212e8d0eaba8971e239e35aeb521f3cd813)), closes [#202](https://github.com/C4illin/ConvertX/issues/202)
## [0.11.1](https://github.com/C4illin/ConvertX/compare/v0.11.0...v0.11.1) (2025-02-07)
### Bug Fixes
- mobile view overflow ([bec58ac](https://github.com/C4illin/ConvertX/commit/bec58ac59f9600e35385b9e21d174f3ab1b42b1d))
* mobile view overflow ([bec58ac](https://github.com/C4illin/ConvertX/commit/bec58ac59f9600e35385b9e21d174f3ab1b42b1d))
## [0.11.0](https://github.com/C4illin/ConvertX/compare/v0.10.1...v0.11.0) (2025-02-05)
### Features
- add deps for vaapi ([2bbbd03](https://github.com/C4illin/ConvertX/commit/2bbbd03554d384a4488143f29e5fc863cfdf333b)), closes [#192](https://github.com/C4illin/ConvertX/issues/192)
* add deps for vaapi ([2bbbd03](https://github.com/C4illin/ConvertX/commit/2bbbd03554d384a4488143f29e5fc863cfdf333b)), closes [#192](https://github.com/C4illin/ConvertX/issues/192)
### Bug Fixes
- don't crash if file is not found ([16f27c1](https://github.com/C4illin/ConvertX/commit/16f27c13bbc1c0e5fa2316f3db11d0918524053b))
- install numpy for inkscape ([0e61051](https://github.com/C4illin/ConvertX/commit/0e61051fc6be188164c3865b4fb579c140859fdc))
* don't crash if file is not found ([16f27c1](https://github.com/C4illin/ConvertX/commit/16f27c13bbc1c0e5fa2316f3db11d0918524053b))
* install numpy for inkscape ([0e61051](https://github.com/C4illin/ConvertX/commit/0e61051fc6be188164c3865b4fb579c140859fdc))
## [0.10.1](https://github.com/C4illin/ConvertX/compare/v0.10.0...v0.10.1) (2025-01-21)
### Bug Fixes
- ffmpeg works without ffmpeg_args ([3b7ea88](https://github.com/C4illin/ConvertX/commit/3b7ea88b7382f7c21b120bdc9bda5bb10547f55d)), closes [#212](https://github.com/C4illin/ConvertX/issues/212)
* ffmpeg works without ffmpeg_args ([3b7ea88](https://github.com/C4illin/ConvertX/commit/3b7ea88b7382f7c21b120bdc9bda5bb10547f55d)), closes [#212](https://github.com/C4illin/ConvertX/issues/212)
## [0.10.0](https://github.com/C4illin/ConvertX/compare/v0.9.0...v0.10.0) (2025-01-18)
### Features
- add calibre ([03d3edf](https://github.com/C4illin/ConvertX/commit/03d3edfff65c252dd4b8922fc98257c089c1ff74)), closes [#191](https://github.com/C4illin/ConvertX/issues/191)
* add calibre ([03d3edf](https://github.com/C4illin/ConvertX/commit/03d3edfff65c252dd4b8922fc98257c089c1ff74)), closes [#191](https://github.com/C4illin/ConvertX/issues/191)
### Bug Fixes
- add FFMPEG_ARGS env variable ([f537c81](https://github.com/C4illin/ConvertX/commit/f537c81db7815df8017f834e3162291197e1c40f)), closes [#190](https://github.com/C4illin/ConvertX/issues/190)
- add qt6-qtbase-private-dev from community repo ([95dbc9f](https://github.com/C4illin/ConvertX/commit/95dbc9f678bec7e6e2c03587e1473fb8ff708ea3))
- skip account setup when ALLOW_UNAUTHENTICATED is true ([538c5b6](https://github.com/C4illin/ConvertX/commit/538c5b60c9e27a8184740305475245da79bae143))
* add FFMPEG_ARGS env variable ([f537c81](https://github.com/C4illin/ConvertX/commit/f537c81db7815df8017f834e3162291197e1c40f)), closes [#190](https://github.com/C4illin/ConvertX/issues/190)
* add qt6-qtbase-private-dev from community repo ([95dbc9f](https://github.com/C4illin/ConvertX/commit/95dbc9f678bec7e6e2c03587e1473fb8ff708ea3))
* skip account setup when ALLOW_UNAUTHENTICATED is true ([538c5b6](https://github.com/C4illin/ConvertX/commit/538c5b60c9e27a8184740305475245da79bae143))
## [0.9.0](https://github.com/C4illin/ConvertX/compare/v0.8.1...v0.9.0) (2024-11-21)
### Features
- add inkscape for vector images ([f3740e9](https://github.com/C4illin/ConvertX/commit/f3740e9ded100b8500f3613517960248bbd3c210))
- Allow to chose webroot ([36cb6cc](https://github.com/C4illin/ConvertX/commit/36cb6cc589d80d0a87fa8dbe605db71a9a2570f9)), closes [#180](https://github.com/C4illin/ConvertX/issues/180)
- disable convert when uploading ([58e220e](https://github.com/C4illin/ConvertX/commit/58e220e82d7f9c163d6ea4dc31092c08a3e254f4)), closes [#177](https://github.com/C4illin/ConvertX/issues/177)
* add inkscape for vector images ([f3740e9](https://github.com/C4illin/ConvertX/commit/f3740e9ded100b8500f3613517960248bbd3c210))
* Allow to chose webroot ([36cb6cc](https://github.com/C4illin/ConvertX/commit/36cb6cc589d80d0a87fa8dbe605db71a9a2570f9)), closes [#180](https://github.com/C4illin/ConvertX/issues/180)
* disable convert when uploading ([58e220e](https://github.com/C4illin/ConvertX/commit/58e220e82d7f9c163d6ea4dc31092c08a3e254f4)), closes [#177](https://github.com/C4illin/ConvertX/issues/177)
### Bug Fixes
- treat unknown as m4a ([1a442d6](https://github.com/C4illin/ConvertX/commit/1a442d6e69606afef63b1e7df36aa83d111fa23d)), closes [#178](https://github.com/C4illin/ConvertX/issues/178)
- wait for both upload and selection ([4c05fd7](https://github.com/C4illin/ConvertX/commit/4c05fd72bbbf91ee02327f6fcbf749b78272376b)), closes [#177](https://github.com/C4illin/ConvertX/issues/177)
* treat unknown as m4a ([1a442d6](https://github.com/C4illin/ConvertX/commit/1a442d6e69606afef63b1e7df36aa83d111fa23d)), closes [#178](https://github.com/C4illin/ConvertX/issues/178)
* wait for both upload and selection ([4c05fd7](https://github.com/C4illin/ConvertX/commit/4c05fd72bbbf91ee02327f6fcbf749b78272376b)), closes [#177](https://github.com/C4illin/ConvertX/issues/177)
## [0.8.1](https://github.com/C4illin/ConvertX/compare/v0.8.0...v0.8.1) (2024-10-05)
### Bug Fixes
- disable convert button when input is empty ([78844d7](https://github.com/C4illin/ConvertX/commit/78844d7bd55990789ed07c81e49043e688cbe656)), closes [#151](https://github.com/C4illin/ConvertX/issues/151)
- resize to fit for ico ([b4e53db](https://github.com/C4illin/ConvertX/commit/b4e53dbb8e70b3a95b44e5b756759d16117a87e1)), closes [#157](https://github.com/C4illin/ConvertX/issues/157)
- treat jfif as jpeg ([339b79f](https://github.com/C4illin/ConvertX/commit/339b79f786131deb93f0d5683e03178fdcab1ef5)), closes [#163](https://github.com/C4illin/ConvertX/issues/163)
* disable convert button when input is empty ([78844d7](https://github.com/C4illin/ConvertX/commit/78844d7bd55990789ed07c81e49043e688cbe656)), closes [#151](https://github.com/C4illin/ConvertX/issues/151)
* resize to fit for ico ([b4e53db](https://github.com/C4illin/ConvertX/commit/b4e53dbb8e70b3a95b44e5b756759d16117a87e1)), closes [#157](https://github.com/C4illin/ConvertX/issues/157)
* treat jfif as jpeg ([339b79f](https://github.com/C4illin/ConvertX/commit/339b79f786131deb93f0d5683e03178fdcab1ef5)), closes [#163](https://github.com/C4illin/ConvertX/issues/163)
## [0.8.0](https://github.com/C4illin/ConvertX/compare/v0.7.0...v0.8.0) (2024-09-30)
### Features
- add light theme, fixes [#156](https://github.com/C4illin/ConvertX/issues/156) ([72636c5](https://github.com/C4illin/ConvertX/commit/72636c5059ebf09c8fece2e268293650b2f8ccf6))
* add light theme, fixes [#156](https://github.com/C4illin/ConvertX/issues/156) ([72636c5](https://github.com/C4illin/ConvertX/commit/72636c5059ebf09c8fece2e268293650b2f8ccf6))
### Bug Fixes
- add support for usd for assimp, [#144](https://github.com/C4illin/ConvertX/issues/144) ([2057167](https://github.com/C4illin/ConvertX/commit/20571675766209ad1251f07e687d29a6791afc8b))
- cleanup formats and add opus, fixes [#159](https://github.com/C4illin/ConvertX/issues/159) ([ae1dfaf](https://github.com/C4illin/ConvertX/commit/ae1dfafc9d9116a57b08c2f7fc326990e00824b0))
- support .awb and clean up, fixes [#153](https://github.com/C4illin/ConvertX/issues/153), [#92](https://github.com/C4illin/ConvertX/issues/92) ([1c9e67f](https://github.com/C4illin/ConvertX/commit/1c9e67fc3201e0e5dee91e8981adf34daaabf33a))
* add support for usd for assimp, [#144](https://github.com/C4illin/ConvertX/issues/144) ([2057167](https://github.com/C4illin/ConvertX/commit/20571675766209ad1251f07e687d29a6791afc8b))
* cleanup formats and add opus, fixes [#159](https://github.com/C4illin/ConvertX/issues/159) ([ae1dfaf](https://github.com/C4illin/ConvertX/commit/ae1dfafc9d9116a57b08c2f7fc326990e00824b0))
* support .awb and clean up, fixes [#153](https://github.com/C4illin/ConvertX/issues/153), [#92](https://github.com/C4illin/ConvertX/issues/92) ([1c9e67f](https://github.com/C4illin/ConvertX/commit/1c9e67fc3201e0e5dee91e8981adf34daaabf33a))
## [0.7.0](https://github.com/C4illin/ConvertX/compare/v0.6.0...v0.7.0) (2024-09-26)
### Features
- Add support for 3d assets through assimp converter ([63a4328](https://github.com/C4illin/ConvertX/commit/63a4328d4a1e01df3e0ec4a877bad8c8ffe71129))
* Add support for 3d assets through assimp converter ([63a4328](https://github.com/C4illin/ConvertX/commit/63a4328d4a1e01df3e0ec4a877bad8c8ffe71129))
### Bug Fixes
- wrong layout on search with few options ([8817389](https://github.com/C4illin/ConvertX/commit/88173891ba2d69da46eda46f3f598a9b54f26f96))
* wrong layout on search with few options ([8817389](https://github.com/C4illin/ConvertX/commit/88173891ba2d69da46eda46f3f598a9b54f26f96))
## [0.6.0](https://github.com/C4illin/ConvertX/compare/v0.5.0...v0.6.0) (2024-09-25)
### Features
- ui remake with tailwind ([22f823c](https://github.com/C4illin/ConvertX/commit/22f823c535b20382981f86a13616b830a1f3392f))
* ui remake with tailwind ([22f823c](https://github.com/C4illin/ConvertX/commit/22f823c535b20382981f86a13616b830a1f3392f))
### Bug Fixes
- rename css file to force update cache, fixes [#141](https://github.com/C4illin/ConvertX/issues/141) ([47139a5](https://github.com/C4illin/ConvertX/commit/47139a550bd3d847da288c61bf8f88953b79c673))
* rename css file to force update cache, fixes [#141](https://github.com/C4illin/ConvertX/issues/141) ([47139a5](https://github.com/C4illin/ConvertX/commit/47139a550bd3d847da288c61bf8f88953b79c673))
## [0.5.0](https://github.com/C4illin/ConvertX/compare/v0.4.1...v0.5.0) (2024-09-20)
### Features
- add option to customize how often files are automatically deleted ([317c932](https://github.com/C4illin/ConvertX/commit/317c932c2a26280bf37ed3d3bf9b879413590f5a))
* add option to customize how often files are automatically deleted ([317c932](https://github.com/C4illin/ConvertX/commit/317c932c2a26280bf37ed3d3bf9b879413590f5a))
### Bug Fixes
- improve file name replacement logic ([60ba7c9](https://github.com/C4illin/ConvertX/commit/60ba7c93fbdc961f3569882fade7cc13dee7a7a5))
* improve file name replacement logic ([60ba7c9](https://github.com/C4illin/ConvertX/commit/60ba7c93fbdc961f3569882fade7cc13dee7a7a5))
## [0.4.1](https://github.com/C4illin/ConvertX/compare/v0.4.0...v0.4.1) (2024-09-15)
### Bug Fixes
- allow non lowercase true and false values, fixes [#122](https://github.com/C4illin/ConvertX/issues/122) ([bef1710](https://github.com/C4illin/ConvertX/commit/bef1710e3376baa7e25c107ded20a40d18b8c6b0))
* allow non lowercase true and false values, fixes [#122](https://github.com/C4illin/ConvertX/issues/122) ([bef1710](https://github.com/C4illin/ConvertX/commit/bef1710e3376baa7e25c107ded20a40d18b8c6b0))
## [0.4.0](https://github.com/C4illin/ConvertX/compare/v0.3.3...v0.4.0) (2024-08-26)
### Features
- add option for unauthenticated file conversions [#114](https://github.com/C4illin/ConvertX/issues/114) ([f0d0e43](https://github.com/C4illin/ConvertX/commit/f0d0e4392983c3e4c530304ea88e023fda9bcac0))
- add resvg converter ([d5eeef9](https://github.com/C4illin/ConvertX/commit/d5eeef9f6884b2bb878508bed97ea9ceaa662995))
- add robots.txt ([6597c1d](https://github.com/C4illin/ConvertX/commit/6597c1d7caeb4dfb6bc47b442e4dfc9840ad12b7))
- Add search bar for formats ([53fff59](https://github.com/C4illin/ConvertX/commit/53fff594fc4d69306abcb2a5cad890fcd0953a58))
* add option for unauthenticated file conversions [#114](https://github.com/C4illin/ConvertX/issues/114) ([f0d0e43](https://github.com/C4illin/ConvertX/commit/f0d0e4392983c3e4c530304ea88e023fda9bcac0))
* add resvg converter ([d5eeef9](https://github.com/C4illin/ConvertX/commit/d5eeef9f6884b2bb878508bed97ea9ceaa662995))
* add robots.txt ([6597c1d](https://github.com/C4illin/ConvertX/commit/6597c1d7caeb4dfb6bc47b442e4dfc9840ad12b7))
* Add search bar for formats ([53fff59](https://github.com/C4illin/ConvertX/commit/53fff594fc4d69306abcb2a5cad890fcd0953a58))
### Bug Fixes
- keep unauthenticated user logged in if allowed [#114](https://github.com/C4illin/ConvertX/issues/114) ([bc4ad49](https://github.com/C4illin/ConvertX/commit/bc4ad492852fad8cb832a0c03485cccdd7f7b117))
- pdf support in vips ([8ca4f15](https://github.com/C4illin/ConvertX/commit/8ca4f1587df7f358893941c656d78d75f04dac93))
- Slow click on conversion popup does not work ([4d9c4d6](https://github.com/C4illin/ConvertX/commit/4d9c4d64aa0266f3928935ada68d91ac81f638aa))
* keep unauthenticated user logged in if allowed [#114](https://github.com/C4illin/ConvertX/issues/114) ([bc4ad49](https://github.com/C4illin/ConvertX/commit/bc4ad492852fad8cb832a0c03485cccdd7f7b117))
* pdf support in vips ([8ca4f15](https://github.com/C4illin/ConvertX/commit/8ca4f1587df7f358893941c656d78d75f04dac93))
* Slow click on conversion popup does not work ([4d9c4d6](https://github.com/C4illin/ConvertX/commit/4d9c4d64aa0266f3928935ada68d91ac81f638aa))
## [0.3.3](https://github.com/C4illin/ConvertX/compare/v0.3.2...v0.3.3) (2024-07-30)
### Bug Fixes
- downgrade @elysiajs/html dependency to version 1.0.2 ([c714ade](https://github.com/C4illin/ConvertX/commit/c714ade3e23865ba6cfaf76c9e7259df1cda222c))
* downgrade @elysiajs/html dependency to version 1.0.2 ([c714ade](https://github.com/C4illin/ConvertX/commit/c714ade3e23865ba6cfaf76c9e7259df1cda222c))
## [0.3.2](https://github.com/C4illin/ConvertX/compare/v0.3.1...v0.3.2) (2024-07-09)
### Bug Fixes
- increase max request body to support large uploads ([3ae2db5](https://github.com/C4illin/ConvertX/commit/3ae2db5d9b36fe3dcd4372ddcd32aa573ea59aa6)), closes [#64](https://github.com/C4illin/ConvertX/issues/64)
* increase max request body to support large uploads ([3ae2db5](https://github.com/C4illin/ConvertX/commit/3ae2db5d9b36fe3dcd4372ddcd32aa573ea59aa6)), closes [#64](https://github.com/C4illin/ConvertX/issues/64)
## [0.3.1](https://github.com/C4illin/ConvertX/compare/v0.3.0...v0.3.1) (2024-06-27)
### Bug Fixes
- release releases ([4d4c13a](https://github.com/C4illin/ConvertX/commit/4d4c13a8d85ec7c9209ad41cdbea7d4380b0edbf))
* release releases ([4d4c13a](https://github.com/C4illin/ConvertX/commit/4d4c13a8d85ec7c9209ad41cdbea7d4380b0edbf))
## [0.3.0](https://github.com/C4illin/ConvertX/compare/v0.2.0...v0.3.0) (2024-06-27)
### Features
- add version number to log ([4dcb796](https://github.com/C4illin/ConvertX/commit/4dcb796e1bd27badc078d0638076cd9f1e81c4a4)), closes [#44](https://github.com/C4illin/ConvertX/issues/44)
- change to xelatex ([fae2ba9](https://github.com/C4illin/ConvertX/commit/fae2ba9c54461dccdccd1bfb5e76398540d11d0b))
- print version of installed converters to log ([801cf28](https://github.com/C4illin/ConvertX/commit/801cf28d1e5edac9353b0b16be75a4fb48470b8a))
* add version number to log ([4dcb796](https://github.com/C4illin/ConvertX/commit/4dcb796e1bd27badc078d0638076cd9f1e81c4a4)), closes [#44](https://github.com/C4illin/ConvertX/issues/44)
* change to xelatex ([fae2ba9](https://github.com/C4illin/ConvertX/commit/fae2ba9c54461dccdccd1bfb5e76398540d11d0b))
* print version of installed converters to log ([801cf28](https://github.com/C4illin/ConvertX/commit/801cf28d1e5edac9353b0b16be75a4fb48470b8a))
## [0.2.0](https://github.com/C4illin/ConvertX/compare/v0.1.2...v0.2.0) (2024-06-20)
### Features
- add libjxl for jpegxl conversion ([ff680cb](https://github.com/C4illin/ConvertX/commit/ff680cb29534a25c3148a90fd064bb86c71fb482))
- change from debian to alpine ([1316957](https://github.com/C4illin/ConvertX/commit/13169574f0134ae236f8d41287bb73930b575e82)), closes [#34](https://github.com/C4illin/ConvertX/issues/34)
* add libjxl for jpegxl conversion ([ff680cb](https://github.com/C4illin/ConvertX/commit/ff680cb29534a25c3148a90fd064bb86c71fb482))
* change from debian to alpine ([1316957](https://github.com/C4illin/ConvertX/commit/13169574f0134ae236f8d41287bb73930b575e82)), closes [#34](https://github.com/C4illin/ConvertX/issues/34)
## [0.1.2](https://github.com/C4illin/ConvertX/compare/v0.1.1...v0.1.2) (2024-06-10)
### Bug Fixes
- fix incorrect redirect ([25df58b](https://github.com/C4illin/ConvertX/commit/25df58ba82321aaa6617811a6995cb96c2a00a40)), closes [#23](https://github.com/C4illin/ConvertX/issues/23)
* fix incorrect redirect ([25df58b](https://github.com/C4illin/ConvertX/commit/25df58ba82321aaa6617811a6995cb96c2a00a40)), closes [#23](https://github.com/C4illin/ConvertX/issues/23)
## [0.1.1](https://github.com/C4illin/ConvertX/compare/v0.1.0...v0.1.1) (2024-05-30)
### Bug Fixes
- :bug: make sure all redirects are 302 ([9970fd3](https://github.com/C4illin/ConvertX/commit/9970fd3f89190af96f8762edc3817d1e03082b3a)), closes [#12](https://github.com/C4illin/ConvertX/issues/12)
* :bug: make sure all redirects are 302 ([9970fd3](https://github.com/C4illin/ConvertX/commit/9970fd3f89190af96f8762edc3817d1e03082b3a)), closes [#12](https://github.com/C4illin/ConvertX/issues/12)
## 0.1.0 (2024-05-30)
### Features
- remove file from file list in index.html ([787ff97](https://github.com/C4illin/ConvertX/commit/787ff9741ecbbf4fb4c02b43bd22a214a173fd7b))
* remove file from file list in index.html ([787ff97](https://github.com/C4illin/ConvertX/commit/787ff9741ecbbf4fb4c02b43bd22a214a173fd7b))
### Miscellaneous Chores
- release 0.1.0 ([54d9aec](https://github.com/C4illin/ConvertX/commit/54d9aecbf949689b12aa7e5e8e9be7b9032f4431))
* release 0.1.0 ([54d9aec](https://github.com/C4illin/ConvertX/commit/54d9aecbf949689b12aa7e5e8e9be7b9032f4431))

View File

@@ -1,25 +1,7 @@
FROM debian:trixie-slim AS base
FROM oven/bun:1.2.2-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/C4illin/ConvertX"
WORKDIR /app
# install bun
RUN apt-get update && apt-get install -y \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# if architecture is arm64, use the arm64 version of bun
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
else \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
fi
RUN unzip -j bun-linux-*.zip -d /usr/local/bin && \
rm bun-linux-*.zip && \
chmod +x /usr/local/bin/bun
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
@@ -32,7 +14,24 @@ RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM base AS prerelease
FROM base AS resvg
RUN apk --no-cache add curl gcc
ENV PATH=/root/.cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
RUN cargo install resvg
FROM base AS dcraw
# build dcraw
RUN apk --no-cache add build-base wget tar jasper-dev jpeg-dev lcms2-dev libc6-compat gettext libintl gettext-dev gettext-libs gettext-lang gettext-static intltool linux-tools-dev
RUN mkdir -p /temp
WORKDIR /temp
RUN wget https://www.dechifro.org/dcraw/archive/dcraw-9.28.0.tar.gz ; \
tar -xzvf dcraw-*.tar.gz ; cd dcraw ; chmod 755 install ; ./install ;
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
# will switch to alpine again when it works
FROM oven/bun:1.2.2-slim AS prerelease
WORKDIR /app
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
@@ -42,63 +41,47 @@ RUN bun run build
# copy production dependencies and source code into final image
FROM base AS release
LABEL maintainer="Emrik Östling (C4illin)"
LABEL description="ConvertX: self-hosted online file converter supporting 700+ file formats."
LABEL repo="https://github.com/C4illin/ConvertX"
RUN apk --no-cache add qt6-qtbase-private-dev libheif-tools --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/
# install additional dependencies
RUN apt-get update && apt-get install -y \
assimp-utils \
calibre \
dasel \
dcraw \
dvisvgm \
ffmpeg \
ghostscript \
graphicsmagick \
imagemagick-7.q16 \
inkscape \
latexmk \
libheif-examples \
libjxl-tools \
libreoffice \
libva2 \
libvips-tools \
libemail-outlook-message-perl \
lmodern \
mupdf-tools \
RUN apk --no-cache add \
pandoc \
poppler-utils \
potrace \
python3-numpy \
resvg \
texlive \
texlive-fonts-recommended \
texlive-latex-extra \
texlive-latex-recommended \
texlive-xetex \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
texmf-dist-latexextra \
ffmpeg \
graphicsmagick \
ghostscript \
vips-tools \
vips-poppler \
vips-jxl \
vips-heif \
vips-magick \
libjxl-tools \
assimp \
inkscape \
poppler-utils \
gcompat \
libva-utils \
py3-numpy
# Install VTracer binary
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
else \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
fi && \
curl -L -o /tmp/vtracer.tar.gz "https://github.com/visioncortex/vtracer/releases/download/0.6.4/${VTRACER_ASSET}" && \
tar -xzf /tmp/vtracer.tar.gz -C /tmp/ && \
mv /tmp/vtracer /usr/local/bin/vtracer && \
chmod +x /usr/local/bin/vtracer && \
rm /tmp/vtracer.tar.gz
RUN apk --no-cache add calibre --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/
# this might be needed for some latex use cases, will add it if needed.
# texmf-dist-fontsextra \
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /app/public/ /app/public/
COPY --from=prerelease /app/dist /app/dist
# COPY . .
RUN mkdir data
COPY --from=resvg /root/.cargo/bin/resvg /usr/local/bin/resvg
COPY --from=dcraw /usr/local/bin/dcraw /usr/local/bin/dcraw
COPY --from=prerelease /app/public/generated.css /app/public/
# COPY --from=prerelease /app/src/index.tsx /app/src/
# COPY --from=prerelease /app/package.json .
COPY . .
EXPOSE 3000/tcp
# used for calibre
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
ENV NODE_ENV=production
ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
ENTRYPOINT [ "bun", "run", "./src/index.tsx" ]

View File

@@ -9,9 +9,6 @@
![GitHub commits since latest release](https://img.shields.io/github/commits-since/C4illin/ConvertX/latest)
![GitHub repo size](https://img.shields.io/github/repo-size/C4illin/ConvertX)
![Docker container size](https://ghcr-badge.egpl.dev/c4illin/convertx/size?color=%230375b6&tag=latest&label=image+size&trim=)
<a href="https://trendshift.io/repositories/13818" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13818" alt="C4illin%2FConvertX | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<!-- ![Dev image size](https://ghcr-badge.egpl.dev/c4illin/convertx/size?color=%230375b6&tag=main&label=dev+image&trim=) -->
A self-hosted online file converter. Supports over a thousand different formats. Written with TypeScript, Bun and Elysia.
@@ -25,26 +22,19 @@ A self-hosted online file converter. Supports over a thousand different formats.
## Converters supported
| Converter | Use case | Converts from | Converts to |
| --------------------------------------------------------------- | ---------------- | ------------- | ----------- |
| [Inkscape](https://inkscape.org/) | Vector images | 7 | 17 |
| [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 |
| [resvg](https://github.com/RazrFalcon/resvg) | SVG | 1 | 1 |
| [Vips](https://github.com/libvips/libvips) | Images | 45 | 23 |
| [libheif](https://github.com/strukturag/libheif) | HEIF | 2 | 4 |
| [XeLaTeX](https://tug.org/xetex/) | LaTeX | 1 | 1 |
| [Calibre](https://calibre-ebook.com/) | E-books | 26 | 19 |
| [LibreOffice](https://www.libreoffice.org/) | Documents | 41 | 22 |
| [Dasel](https://github.com/TomWright/dasel) | Data Files | 5 | 4 |
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
| [msgconvert](https://github.com/mvz/email-outlook-message-perl) | Outlook | 1 | 1 |
| [dvisvgm](https://dvisvgm.de/) | Vector images | 4 | 2 |
| [ImageMagick](https://imagemagick.org/) | Images | 245 | 183 |
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 167 | 130 |
| [Assimp](https://github.com/assimp/assimp) | 3D Assets | 77 | 23 |
| [FFmpeg](https://ffmpeg.org/) | Video | ~472 | ~199 |
| [Potrace](https://potrace.sourceforge.net/) | Raster to vector | 4 | 11 |
| [VTracer](https://github.com/visioncortex/vtracer) | Raster to vector | 8 | 1 |
| Converter | Use case | Converts from | Converts to |
|------------------------------------------------------------------------------|---------------|---------------|-------------|
| [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 |
| [resvg](https://github.com/RazrFalcon/resvg) | SVG | 1 | 1 |
| [libheif](https://github.com/strukturag/libheif) | HEIF | 7 | 6 |
| [Vips](https://github.com/libvips/libvips) | Images | 45 | 23 |
| [XeLaTeX](https://tug.org/xetex/) | LaTeX | 1 | 1 |
| [Calibre](https://calibre-ebook.com/) | E-books | 26 | 19 |
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 167 | 130 |
| [Inkscape](https://inkscape.org/) | Vector images | 7 | 17 |
| [Assimp](https://github.com/assimp/assimp) | 3D Assets | 77 | 23 |
| [FFmpeg](https://ffmpeg.org/) | Video | ~472 | ~199 |
<!-- many ffmpeg fileformats are duplicates -->
@@ -52,9 +42,6 @@ Any missing converter? Open an issue or pull request!
## Deployment
> [!WARNING]
> If you can't login, make sure you are accessing the service over localhost or https otherwise set HTTP_ALLOWED=true
```yml
# docker-compose.yml
services:
@@ -66,7 +53,6 @@ services:
- "3000:3000"
environment:
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() if unset
# - HTTP_ALLOWED=true # uncomment this if accessing it over a non-https connection
volumes:
- ./data:/app/data
```
@@ -85,19 +71,18 @@ If you get unable to open database file run `chown -R $USER:$USER path` on the p
All are optional, JWT_SECRET is recommended to be set.
| Name | Default | Description |
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token |
| ACCOUNT_REGISTRATION | false | Allow users to register accounts |
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally |
| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable |
| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" |
| FFMPEG_ARGS | | Arguments to pass to ffmpeg, e.g. `-preset veryfast` |
| HIDE_HISTORY | false | Hide the history page |
| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) |
| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users |
| MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. |
| Name | Default | Description |
|---------------------------|---------|-------------|
| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token |
| ACCOUNT_REGISTRATION | false | Allow users to register accounts |
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally |
| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable |
| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" |
| FFMPEG_ARGS | | Arguments to pass to ffmpeg, e.g. `-preset veryfast` |
> [!WARNING]
> If you can't login, make sure you are accessing the service over https or set HTTP_ALLOWED=true
### Docker images
@@ -105,23 +90,17 @@ There is a `:latest` tag that is updated with every release and a `:main` tag th
The image is available on [GitHub Container Registry](https://github.com/C4illin/ConvertX/pkgs/container/ConvertX) and [Docker Hub](https://hub.docker.com/r/c4illin/convertx).
| Image | What it is |
| -------------------------------------- | -------------------------------- |
| `image: ghcr.io/c4illin/convertx` | The latest release on ghcr |
| `image: ghcr.io/c4illin/convertx:main` | The latest commit on ghcr |
| `image: c4illin/convertx` | The latest release on docker hub |
| `image: c4illin/convertx:main` | The latest commit on docker hub |
![Release image size](https://ghcr-badge.egpl.dev/c4illin/convertx/size?color=%230375b6&tag=latest&label=release+image&trim=)
![Dev image size](https://ghcr-badge.egpl.dev/c4illin/convertx/size?color=%230375b6&tag=main&label=dev+image&trim=)
| Image | What it is |
|-------|------------|
| `image: ghcr.io/c4illin/convertx` | The latest release on ghcr |
| `image: ghcr.io/c4illin/convertx:main` | The latest commit on ghcr |
| `image: c4illin/convertx` | The latest release on docker hub |
| `image: c4illin/convertx:main` | The latest commit on docker hub |
<!-- Dockerhub was introduced in 0.9.0 and older releases -->
### Tutorial
> [!NOTE]
> These are written by other people, and may be outdated, incorrect or wrong.
Tutorial in french: <https://belginux.com/installer-convertx-avec-docker/>
Tutorial in chinese: <https://xzllll.com/24092901/>
@@ -137,9 +116,21 @@ Tutorial in chinese: <https://xzllll.com/24092901/>
2. `bun install`
3. `bun run dev`
Pull requests are welcome! See open issues for the list of todos. The ones tagged with "converter request" are quite easy. Help with docs and cleaning up in issues are also very welcome!
Pull requests are welcome! See below and open issues for the list of todos.
Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for commit messages.
## Todo
- [x] Add messages for errors in converters
- [x] Add searchable list of formats
- [ ] Add options for converters
- [ ] Divide index.tsx into smaller components
- [ ] Add tests
- [ ] Make the upload button nicer and more easy to drop files on. Support copy paste as well if possible.
- [ ] Make errors logs visible from the web ui
- [ ] Add more converters:
- [ ] [deark](https://github.com/jsummers/deark)
- [ ] LibreOffice
- [ ] [dvisvgm](https://github.com/mgieseki/dvisvgm)
## Contributors

View File

@@ -1,9 +0,0 @@
# Security Policy
## Supported Versions
Only the latest release is supported
## Reporting a Vulnerability
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/C4illin/ConvertX/security/advisories/new) tab.

View File

@@ -10,7 +10,10 @@
"attributePosition": "auto"
},
"files": {
"ignore": ["**/node_modules/**", "**/pico.lime.min.css"]
"ignore": [
"**/node_modules/**",
"**/pico.lime.min.css"
]
},
"organizeImports": {
"enabled": true

770
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,10 @@ services:
environment: # Defaults are listed below. All are optional.
- ACCOUNT_REGISTRATION=true # true or false, doesn't matter for the first account (e.g. keep this to false if you only want one account)
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() by default
- HTTP_ALLOWED=false # setting this to true is unsafe, only set this to true locally
- ALLOW_UNAUTHENTICATED=false # allows anyone to use the service without logging in, only set this to true locally
- HTTP_ALLOWED=true # setting this to true is unsafe, only set this to true locally
- ALLOW_UNAUTHENTICATED=true # allows anyone to use the service without logging in, only set this to true locally
- AUTO_DELETE_EVERY_N_HOURS=1 # checks every n hours for files older then n hours and deletes them, set to 0 to disable
# - FFMPEG_ARGS=-hwaccel vulkan # additional arguments to pass to ffmpeg
# - WEBROOT=/convertx # the root path of the web interface, leave empty to disable
# - HIDE_HISTORY=true # hides the history tab in the web interface, defaults to false
- TZ=Europe/Stockholm # set your timezone, defaults to UTC
# - UNAUTHENTICATED_USER_SHARING=true # for use with ALLOW_UNAUTHENTICATED=true to share history with all unauthenticated users / devices
ports:
- 3000:3000

View File

@@ -1,17 +1,21 @@
import js from "@eslint/js";
import eslintParserTypeScript from "@typescript-eslint/parser";
import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss";
import type { Linter } from "eslint";
import eslintPluginReadableTailwind from "eslint-plugin-readable-tailwind";
import simpleImportSortPlugin from "eslint-plugin-simple-import-sort";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
export default [
js.configs.recommended,
tseslint.configs.recommended,
...tseslint.configs.recommended,
// ...tailwind.configs["flat/recommended"],
{
plugins: {
"better-tailwindcss": eslintPluginBetterTailwindcss,
"simple-import-sort": simpleImportSortPlugin,
"readable-tailwind": eslintPluginReadableTailwind,
},
ignores: ["**/node_modules/**", "eslint.config.ts"],
ignores: ["**/node_modules/**"],
languageOptions: {
parser: eslintParserTypeScript,
parserOptions: {
@@ -22,49 +26,32 @@ export default tseslint.config(
},
globals: {
...globals.node,
...globals.browser,
},
},
files: ["**/*.{tsx,ts}"],
settings: {
"better-tailwindcss": {
entryPoint: "src/main.css",
},
},
files: ["**/*.{js,mjs,cjs,jsx,tsx,ts}"],
rules: {
...(eslintPluginBetterTailwindcss.configs["recommended-warn"] ?? {}).rules,
...(eslintPluginBetterTailwindcss.configs["stylistic-warn"] ?? {}).rules,
...eslintPluginReadableTailwind.configs.warning.rules,
// "tailwindcss/classnames-order": "off",
"better-tailwindcss/enforce-consistent-line-wrapping": [
"readable-tailwind/multiline": [
"warn",
{
group: "newLine",
printWidth: 100,
},
],
"better-tailwindcss/no-unregistered-classes": [
"warn",
{
ignore: [
"^group(?:\\/(\\S*))?$",
"^peer(?:\\/(\\S*))?$",
"select_container",
"convert_to_popup",
"convert_to_group",
"target",
"convert_to_target",
"job-details-toggle",
],
},
],
// "tailwindcss/no-custom-classname": [
// "warn",
// {
// whitelist: [
// "select_container",
// "convert_to_popup",
// "convert_to_group",
// "target",
// "convert_to_target",
// ],
// },
// ],
},
},
{
files: ["**/*.{js,cjs,mjs,jsx}"],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: {
globals: {
...globals.browser,
},
},
},
);
] as Linter.Config[];

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["tests/**/*.test.ts"],
"project": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
"tailwind": {
"entry": ["src/main.css"]
},
"ignoreDependencies": ["tailwind-scrollbar"]
}

View File

@@ -1,2 +0,0 @@
[tools]
bun = "1.2.2"

View File

@@ -1,28 +1,23 @@
{
"name": "convertx-frontend",
"version": "0.14.1",
"version": "0.11.1",
"scripts": {
"dev": "bun run --watch src/index.tsx",
"hot": "bun run --hot src/index.tsx",
"format": "npm-run-all 'format:*'",
"format:eslint": "eslint --fix .",
"format:prettier": "prettier --write .",
"build:js": "tsc",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:js",
"lint": "npm-run-all 'lint:*'",
"format": "eslint --fix .",
"build": "bunx @tailwindcss/cli -i ./src/main.css -o ./public/generated.css",
"lint": "run-p 'lint:*'",
"lint:tsc": "tsc --noEmit",
"lint:knip": "knip",
"lint:eslint": "eslint .",
"lint:prettier": "prettier --check ."
"lint:eslint": "eslint ."
},
"dependencies": {
"@elysiajs/html": "^1.4.0",
"@elysiajs/jwt": "^1.4.0",
"@elysiajs/static": "^1.4.0",
"@kitajs/html": "^4.2.10",
"elysia": "^1.4.9",
"sanitize-filename": "^1.6.3",
"tar": "^7.5.1"
"@elysiajs/cookie": "^0.8.0",
"@elysiajs/html": "^1.2.0",
"@elysiajs/jwt": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"@kitajs/html": "^4.2.7",
"elysia": "^1.2.12"
},
"module": "src/index.tsx",
"type": "module",
@@ -30,28 +25,31 @@
"start": "bun run src/index.tsx"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@kitajs/ts-html-plugin": "^4.1.3",
"@tailwindcss/cli": "^4.1.14",
"@tailwindcss/postcss": "^4.1.14",
"@types/bun": "latest",
"@types/node": "^24.6.2",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9.37.0",
"eslint-plugin-better-tailwindcss": "^3.7.9",
"globals": "^16.4.0",
"knip": "^5.64.1",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0"
},
"trustedDependencies": [
"@parcel/watcher",
"@tailwindcss/oxide",
"oxc-resolver"
]
"@eslint/js": "^9.19.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@kitajs/ts-html-plugin": "^4.1.1",
"@tailwindcss/cli": "^4.0.4",
"@tailwindcss/postcss": "^4.0.4",
"@total-typescript/ts-reset": "^0.6.1",
"@types/bun": "^1.2.2",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.13.1",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"eslint": "^9.19.0",
"eslint-plugin-readable-tailwind": "^2.0.0-beta.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-tailwindcss": "4.0.0-alpha.0",
"globals": "^15.14.0",
"knip": "^5.43.6",
"npm-run-all2": "^7.0.2",
"postcss": "^8.5.1",
"postcss-cli": "^11.0.0",
"prettier": "^3.4.2",
"tailwind-scrollbar": "^4.0.0",
"tailwindcss": "^4.0.4",
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0"
}
}

View File

@@ -1,12 +1,13 @@
/**
* @type {import('prettier').Config}
* @type {import('prettier').Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig}
*/
const config = {
arrowParens: "always",
printWidth: 100,
printWidth: 80,
singleQuote: false,
semi: true,
tabWidth: 2,
plugins: ["@ianvs/prettier-plugin-sort-imports"],
};
export default config;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 B

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512" style="cursor:default" viewBox="0 0 96.983 132.292"><g transform="translate(-56.568 -82.29)"><path d="M124.878 83.82h-60.91a5.86 5.86 0 0 0-5.87 5.87v117.496a5.855 5.855 0 0 0 5.87 5.866h82.182a5.855 5.855 0 0 0 5.87-5.866v-92.55z" style="display:inline;fill:#111827;stroke:#aeb9d0;stroke-width:3.06006;stroke-dasharray:none;stroke-opacity:1"/><circle cx="84.331" cy="128.904" r="6.653" style="fill:#84cc16;fill-opacity:1;stroke-width:2.13082"/><circle cx="105.059" cy="128.904" r="6.653" style="fill:#fff;fill-opacity:1;stroke-width:2.13082"/><circle cx="125.786" cy="128.904" r="6.653" style="fill:#84cc16;fill-opacity:1;stroke-width:2.13082"/><circle cx="84.331" cy="148.438" r="6.653" style="fill:#fff;fill-opacity:1;stroke-width:2.13082"/><circle cx="105.059" cy="148.438" r="6.653" style="display:inline;fill:#84cc16;fill-opacity:1;stroke-width:2.13082"/><circle cx="125.786" cy="148.438" r="6.653" style="fill:#fff;fill-opacity:1;stroke-width:2.13082"/><circle cx="84.331" cy="167.971" r="6.653" style="fill:#84cc16;fill-opacity:1;stroke-width:2.13082"/><circle cx="105.059" cy="167.971" r="6.653" style="fill:#fff;fill-opacity:1;stroke-width:2.13082"/><circle cx="125.786" cy="167.971" r="6.653" style="fill:#84cc16;fill-opacity:1;stroke-width:2.13082"/><path d="M119.124 161.326h13.287v13.287h-13.287z" style="fill:#84cc16;fill-opacity:1;stroke:none;stroke-width:3.04496;stroke-opacity:1"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,4 +1,18 @@
const webroot = document.querySelector("meta[name='webroot']").content;
window.downloadAll = function () {
// Get all download links
const downloadLinks = document.querySelectorAll("a[download]");
// Trigger download for each link
downloadLinks.forEach((link, index) => {
// We add a delay for each download to prevent them from starting at the same time
setTimeout(() => {
const event = new MouseEvent("click");
link.dispatchEvent(event);
}, index * 100);
});
};
const jobId = window.location.pathname.split("/").pop();
const main = document.querySelector("main");
let progressElem = document.querySelector("progress");
@@ -22,17 +36,3 @@ const refreshData = () => {
};
refreshData();
window.downloadAll = function () {
// Get all download links
const downloadLinks = document.querySelectorAll("tbody a[download]");
// Trigger download for each link
downloadLinks.forEach((link, index) => {
// We add a delay for each download to prevent them from starting at the same time
setTimeout(() => {
const event = new MouseEvent("click");
link.dispatchEvent(event);
}, index * 300);
});
};

View File

@@ -7,8 +7,7 @@ let fileType;
let pendingFiles = 0;
let formatSelected = false;
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.addEventListener("dragover", () => {
dropZone.classList.add("dragover");
});
@@ -16,63 +15,16 @@ dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.addEventListener("drop", () => {
dropZone.classList.remove("dragover");
const files = e.dataTransfer.files;
if (files.length === 0) {
console.warn("No files dropped — likely a URL or unsupported source.");
return;
}
for (const file of files) {
console.log("Handling dropped file:", file.name);
handleFile(file);
}
});
// Extracted handleFile function for reusability in drag-and-drop and file input
function handleFile(file) {
const fileList = document.querySelector("#file-list");
const row = document.createElement("tr");
row.innerHTML = `
<td>${file.name}</td>
<td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a onclick="deleteRow(this)">Remove</a></td>
`;
if (!fileType) {
fileType = file.name.split(".").pop();
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
fetch(`${webroot}/conversions`, {
method: "POST",
body: JSON.stringify({ fileType }),
headers: { "Content-Type": "application/json" },
})
.then((res) => res.text())
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch(console.error);
}
fileList.appendChild(row);
file.htmlRow = row;
fileNames.push(file.name);
uploadFile(file);
}
const selectContainer = document.querySelector("form .select_container");
const updateSearchBar = () => {
const convertToInput = document.querySelector("input[name='convert_to_search']");
const convertToInput = document.querySelector(
"input[name='convert_to_search']",
);
const convertToPopup = document.querySelector(".convert_to_popup");
const convertToGroupElements = document.querySelectorAll(".convert_to_group");
const convertToGroups = {};
@@ -154,10 +106,58 @@ const updateSearchBar = () => {
// Add a 'change' event listener to the file input element
fileInput.addEventListener("change", (e) => {
// Get the selected files from the event target
const files = e.target.files;
// Select the file-list table
const fileList = document.querySelector("#file-list");
// Loop through the selected files
for (const file of files) {
handleFile(file);
// Create a new table row for each file
const row = document.createElement("tr");
row.innerHTML = `
<td>${file.name}</td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a onclick="deleteRow(this)">Remove</a></td>
`;
if (!fileType) {
fileType = file.name.split(".").pop();
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
// choose the option that matches the file type
// for (const option of convertFromSelect.children) {
// console.log(option.value);
// if (option.value === fileType) {
// option.selected = true;
// }
// }
fetch(`${webroot}/conversions`, {
method: "POST",
body: JSON.stringify({ fileType: fileType }),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.text())
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch((err) => console.log(err));
}
// Append the row to the file-list table
fileList.appendChild(row);
// Append the file to the hidden input
fileNames.push(file.name);
}
uploadFiles(files);
});
const setTitle = () => {
@@ -193,52 +193,37 @@ const deleteRow = (target) => {
headers: {
"Content-Type": "application/json",
},
}).catch((err) => console.log(err));
})
.catch((err) => console.log(err));
};
const uploadFile = (file) => {
const uploadFiles = (files) => {
convertButton.disabled = true;
convertButton.textContent = "Uploading...";
pendingFiles += 1;
const formData = new FormData();
formData.append("file", file, file.name);
let xhr = new XMLHttpRequest();
for (const file of files) {
formData.append("file", file, file.name);
}
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = () => {
let data = JSON.parse(xhr.responseText);
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
fetch(`${webroot}/upload`, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((data) => {
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
}
convertButton.textContent = "Convert";
}
//Remove the progress bar when upload is done
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].parentElement.remove();
console.log(data);
};
xhr.upload.onprogress = (e) => {
let sent = e.loaded;
let total = e.total;
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].value = (100 * sent) / total;
};
xhr.onerror = (e) => {
console.log(e);
};
xhr.send(formData);
console.log(data);
})
.catch((err) => console.log(err));
};
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);

View File

@@ -1,9 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":disableDependencyDashboard"],
"extends": [
"config:recommended",
":disableDependencyDashboard"
],
"lockFileMaintenance": {
"enabled": true,
"automerge": true
},
"ignoreDeps": ["bun-types", "@types/bun"]
}
}

1
reset.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import "@total-typescript/ts-reset";

View File

@@ -1,4 +1,4 @@
import { version } from "../../package.json";
import { Html } from "@elysiajs/html";
export const BaseHtml = ({
children,
@@ -16,28 +16,25 @@ export const BaseHtml = ({
<meta name="webroot" content={webroot} />
<title safe>{title}</title>
<link rel="stylesheet" href={`${webroot}/generated.css`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${webroot}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${webroot}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${webroot}/favicon-16x16.png`} />
<link
rel="apple-touch-icon"
sizes="180x180"
href={`${webroot}/apple-touch-icon.png`}
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href={`${webroot}/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${webroot}/favicon-16x16.png`}
/>
<link rel="manifest" href={`${webroot}/site.webmanifest`} />
</head>
<body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}>
{children}
<footer class="w-full">
<div class="p-4 text-center text-sm text-neutral-500">
<span>Powered by </span>
<a
href="https://github.com/C4illin/ConvertX"
class={`
text-neutral-400
hover:text-accent-500
`}
>
ConvertX{" "}
</a>
<span safe>v{version || ""}</span>
</div>
</footer>
</body>
<body class="w-full bg-neutral-900 text-neutral-200">{children}</body>
</html>
);

View File

@@ -1,46 +1,31 @@
import { Html } from "@kitajs/html";
export const Header = ({
loggedIn,
accountRegistration,
allowUnauthenticated,
hideHistory,
webroot = "",
}: {
loggedIn?: boolean;
accountRegistration?: boolean;
allowUnauthenticated?: boolean;
hideHistory?: boolean;
webroot?: string;
}) => {
let rightNav: JSX.Element;
if (loggedIn) {
rightNav = (
<ul class="flex gap-4">
{!hideHistory && (
<li>
<a
class={`
text-accent-600 transition-all
hover:text-accent-500 hover:underline
`}
href={`${webroot}/history`}
>
History
</a>
</li>
)}
{!allowUnauthenticated ? (
<li>
<a
class={`
text-accent-600 transition-all
hover:text-accent-500 hover:underline
`}
href={`${webroot}/account`}
>
Account
</a>
</li>
) : null}
<li>
<a
class={`
text-accent-600 transition-all
hover:text-accent-500 hover:underline
`}
href={`${webroot}/history`}
>
History
</a>
</li>
{!allowUnauthenticated ? (
<li>
<a
@@ -89,7 +74,7 @@ export const Header = ({
return (
<header class="w-full p-4">
<nav class={`mx-auto flex max-w-4xl justify-between rounded-sm bg-neutral-900 p-4`}>
<nav class="mx-auto flex max-w-4xl justify-between rounded-sm bg-neutral-900 p-4">
<ul>
<li>
<strong>

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -117,11 +116,13 @@ export async function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const command = `assimp export "${filePath}" "${targetPath}"`;
return new Promise((resolve, reject) => {
execFile("assimp", ["export", filePath, targetPath], (error, stdout, stderr) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -40,7 +39,6 @@ export const properties = {
"fb2",
"html",
"htmlz",
"kepub.epub",
"lit",
"lrf",
"mobi",
@@ -63,11 +61,13 @@ export async function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const command = `ebook-convert "${filePath}" "${targetPath}"`;
return new Promise((resolve, reject) => {
execFile("ebook-convert", [filePath, targetPath], (error, stdout, stderr) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}

View File

@@ -1,48 +0,0 @@
import fs from "fs";
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
document: ["yaml", "toml", "json", "xml", "csv"],
},
to: {
document: ["yaml", "toml", "json", "csv"],
},
};
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const args: string[] = [];
args.push("--file", filePath);
args.push("--read", fileType);
args.push("--write", convertTo);
return new Promise((resolve, reject) => {
execFile("dasel", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
return;
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
fs.writeFile(targetPath, stdout, (err: NodeJS.ErrnoException | null) => {
if (err) {
reject(`Failed to write output: ${err}`);
} else {
resolve("Done");
}
});
});
});
}

View File

@@ -1,49 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
images: ["dvi", "xdv", "pdf", "eps"],
},
to: {
images: ["svg", "svgz"],
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const inputArgs: string[] = [];
if (fileType === "eps") {
inputArgs.push("--eps");
}
if (fileType === "pdf") {
inputArgs.push("--pdf");
}
if (convertTo === "svgz") {
inputArgs.push("-z");
}
return new Promise((resolve, reject) => {
execFile("dvisvgm", [...inputArgs, filePath, "-o", targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
export const properties = {
@@ -509,8 +508,7 @@ export const properties = {
"ast",
"au",
"aud",
"av1.mkv",
"av1.mp4",
"av1",
"avi",
"avif",
"avs",
@@ -548,11 +546,9 @@ export const properties = {
"gxf",
"h261",
"h263",
"h264.mkv",
"h264.mp4",
"h265.mkv",
"h265.mp4",
"h266.mkv",
"h264",
"h265",
"h266",
"hdr",
"hevc",
"ico",
@@ -692,64 +688,35 @@ export async function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
let extraArgs: string[] = [];
let extra = "";
let message = "Done";
if (convertTo === "ico") {
// make sure image is 256x256 or smaller
extraArgs = [
"-filter:v",
"scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease",
];
extra = `-filter:v "scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease"`;
message = "Done: resized to 256x256";
}
if (convertTo.split(".").length > 1) {
// support av1.mkv and av1.mp4 and h265.mp4 etc.
const split = convertTo.split(".");
const codec_short = split[0];
switch (codec_short) {
case "av1":
extraArgs.push("-c:v", "libaom-av1");
break;
case "h264":
extraArgs.push("-c:v", "libx264");
break;
case "h265":
extraArgs.push("-c:v", "libx265");
break;
case "h266":
extraArgs.push("-c:v", "libx266");
break;
}
}
// Parse FFMPEG_ARGS environment variable into array
const ffmpegArgs = process.env.FFMPEG_ARGS ? process.env.FFMPEG_ARGS.split(/\s+/) : [];
const command = `ffmpeg ${process.env.FFMPEG_ARGS || ""} -i "${filePath}" ${extra} "${targetPath}"`;
return new Promise((resolve, reject) => {
execFile(
"ffmpeg",
[...ffmpegArgs, "-i", filePath, ...extraArgs, targetPath],
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve(message);
},
);
resolve(message);
});
});
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -314,24 +313,27 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("gm", ["convert", filePath, targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
exec(
`gm convert "${filePath}" "${targetPath}"`,
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
resolve("Done");
},
);
});
}

View File

@@ -1,492 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
from: {
images: [
"3fr",
"3g2",
"3gp",
"aai",
"ai",
"apng",
"art",
"arw",
"avci",
"avi",
"avif",
"avs",
"bayer",
"bayera",
"bgr",
"bgra",
"bgro",
"bmp",
"bmp2",
"bmp3",
"cal",
"cals",
"canvas",
"caption",
"cin",
"clip",
"clipboard",
"cmyk",
"cmyka",
"cr2",
"cr3",
"crw",
"cube",
"cur",
"cut",
"data",
"dcm",
"dcr",
"dcraw",
"dcx",
"dds",
"dfont",
"dng",
"dpx",
"dxt1",
"dxt5",
"emf",
"epdf",
"epi",
"eps",
"epsf",
"epsi",
"ept",
"ept2",
"ept3",
"erf",
"exr",
"farbfeld",
"fax",
"ff",
"fff",
"file",
"fits",
"fl32",
"flif",
"flv",
"fractal",
"ftp",
"fts",
"ftxt",
"g3",
"g4",
"gif",
"gif87",
"gradient",
"gray",
"graya",
"group4",
"hald",
"hdr",
"heic",
"heif",
"hrz",
"http",
"https",
"icb",
"ico",
"icon",
"iiq",
"inline",
"ipl",
"j2c",
"j2k",
"jng",
"jnx",
"jp2",
"jpc",
"jpe",
"jpeg",
"jpg",
"jpm",
"jps",
"jpt",
"jxl",
"k25",
"kdc",
"label",
"m2v",
"m4v",
"mac",
"map",
"mask",
"mat",
"mdc",
"mef",
"miff",
"mkv",
"mng",
"mono",
"mos",
"mov",
"mp4",
"mpc",
"mpeg",
"mpg",
"mpo",
"mrw",
"msl",
"msvg",
"mtv",
"mvg",
"nef",
"nrw",
"null",
"ora",
"orf",
"otb",
"otf",
"pal",
"palm",
"pam",
"pango",
"pattern",
"pbm",
"pcd",
"pcds",
"pcl",
"pct",
"pcx",
"pdb",
"pdf",
"pdfa",
"pef",
"pes",
"pfa",
"pfb",
"pfm",
"pgm",
"pgx",
"phm",
"picon",
"pict",
"pix",
"pjpeg",
"plasma",
"png",
"png00",
"png24",
"png32",
"png48",
"png64",
"png8",
"pnm",
"pocketmod",
"ppm",
"ps",
"psb",
"psd",
"ptif",
"pwp",
"qoi",
"radial",
"raf",
"ras",
"raw",
"rgb",
"rgb565",
"rgba",
"rgbo",
"rgf",
"rla",
"rle",
"rmf",
"rsvg",
"rw2",
"rwl",
"scr",
"screenshot",
"sct",
"sfw",
"sgi",
"six",
"sixel",
"sr2",
"srf",
"srw",
"stegano",
"sti",
"strimg",
"sun",
"svg",
"svgz",
"text",
"tga",
"tiff",
"tiff64",
"tile",
"tim",
"tm2",
"ttc",
"ttf",
"txt",
"uyvy",
"vda",
"vicar",
"vid",
"viff",
"vips",
"vst",
"wbmp",
"webm",
"webp",
"wmf",
"wmv",
"wpg",
"x3f",
"xbm",
"xc",
"xcf",
"xpm",
"xps",
"xv",
"ycbcr",
"ycbcra",
"yuv",
],
},
to: {
images: [
"aai",
"ai",
"apng",
"art",
"ashlar",
"avif",
"avs",
"bayer",
"bayera",
"bgr",
"bgra",
"bgro",
"bmp",
"bmp2",
"bmp3",
"brf",
"cal",
"cals",
"cin",
"cip",
"clip",
"clipboard",
"cmyk",
"cmyka",
"cur",
"data",
"dcx",
"dds",
"dpx",
"dxt1",
"dxt5",
"epdf",
"epi",
"eps",
"eps2",
"eps3",
"epsf",
"epsi",
"ept",
"ept2",
"ept3",
"exr",
"farbfeld",
"fax",
"ff",
"fits",
"fl32",
"flif",
"flv",
"fts",
"ftxt",
"g3",
"g4",
"gif",
"gif87",
"gray",
"graya",
"group4",
"hdr",
"histogram",
"hrz",
"htm",
"html",
"icb",
"ico",
"icon",
"info",
"inline",
"ipl",
"isobrl",
"isobrl6",
"j2c",
"j2k",
"jng",
"jp2",
"jpc",
"jpe",
"jpeg",
"jpg",
"jpm",
"jps",
"jpt",
"json",
"jxl",
"m2v",
"m4v",
"map",
"mask",
"mat",
"matte",
"miff",
"mkv",
"mng",
"mono",
"mov",
"mp4",
"mpc",
"mpeg",
"mpg",
"msl",
"msvg",
"mtv",
"mvg",
"null",
"otb",
"pal",
"palm",
"pam",
"pbm",
"pcd",
"pcds",
"pcl",
"pct",
"pcx",
"pdb",
"pdf",
"pdfa",
"pfm",
"pgm",
"pgx",
"phm",
"picon",
"pict",
"pjpeg",
"png",
"png00",
"png24",
"png32",
"png48",
"png64",
"png8",
"pnm",
"pocketmod",
"ppm",
"ps",
"ps2",
"ps3",
"psb",
"psd",
"ptif",
"qoi",
"ras",
"rgb",
"rgba",
"rgbo",
"rgf",
"rsvg",
"sgi",
"shtml",
"six",
"sixel",
"sparse",
"strimg",
"sun",
"svg",
"svgz",
"tga",
"thumbnail",
"tiff",
"tiff64",
"txt",
"ubrl",
"ubrl6",
"uil",
"uyvy",
"vda",
"vicar",
"vid",
"viff",
"vips",
"vst",
"wbmp",
"webm",
"webp",
"wmv",
"wpg",
"xbm",
"xpm",
"xv",
"yaml",
"ycbcr",
"ycbcra",
"yuv",
],
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
let outputArgs: string[] = [];
let inputArgs: string[] = [];
if (convertTo === "ico") {
outputArgs = ["-define", "icon:auto-resize=256,128,64,48,32,16", "-background", "none"];
if (fileType === "svg") {
// this might be a bit too much, but it works
inputArgs = ["-background", "none", "-density", "512"];
}
}
// Handle EMF files specifically to avoid LibreOffice delegate issues
if (fileType === "emf") {
// Use direct conversion without delegates for EMF files
inputArgs.push("-define", "emf:delegate=false", "-density", "300");
outputArgs.push("-background", "white", "-alpha", "remove");
}
return new Promise((resolve, reject) => {
execFile(
"magick",
[...inputArgs, filePath, ...outputArgs, targetPath],
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
},
);
});
}

View File

@@ -1,56 +1,64 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
images: ["svg", "pdf", "eps", "ps", "wmf", "emf", "png"],
},
to: {
images: [
"dxf",
"emf",
"eps",
"fxg",
"gpl",
"hpgl",
"html",
"odg",
"pdf",
"png",
"pov",
"ps",
"sif",
"svg",
"svgz",
"tex",
"wmf",
],
},
};
from: {
images: [
"svg",
"pdf",
"eps",
"ps",
"wmf",
"emf",
"png"
]
},
to: {
images: [
"dxf",
"emf",
"eps",
"fxg",
"gpl",
"hpgl",
"html",
"odg",
"pdf",
"png",
"pov",
"ps",
"sif",
"svg",
"svgz",
"tex",
"wmf",
]
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("inkscape", [filePath, "-o", targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
): Promise<string> {
return new Promise((resolve, reject) => {
exec(`inkscape "${filePath}" -o "${targetPath}"`, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
resolve("Done");
});
});
});
}
}

View File

@@ -1,9 +1,19 @@
import { execFile as execFileOriginal } from "child_process";
import { ExecFileFn } from "./types";
import { execFile } from "child_process";
export const properties = {
from: {
images: ["avci", "avcs", "avif", "h264", "heic", "heics", "heif", "heifs", "hif", "mkv", "mp4"],
images: [
"avci",
"avcs",
"avif",
"h264",
"heic",
"heics",
"heif",
"heifs",
"mkv",
"mp4",
],
},
to: {
images: ["jpeg", "png", "y4m"],
@@ -15,24 +25,28 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("heif-convert", [filePath, targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
execFile(
"heif-convert",
[filePath, targetPath],
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
resolve("Done");
},
);
});
}

View File

@@ -1,14 +1,35 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
// declare possible conversions
export const properties = {
from: {
jxl: ["jxl"],
images: ["apng", "exr", "gif", "jpeg", "pam", "pfm", "pgm", "pgx", "png", "ppm"],
images: [
"apng",
"exr",
"gif",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
],
},
to: {
jxl: ["apng", "exr", "jpeg", "pam", "pfm", "pgm", "pgx", "png", "ppm"],
jxl: [
"apng",
"exr",
"gif",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
],
images: ["jxl"],
},
};
@@ -18,8 +39,8 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
let tool = "";
if (fileType === "jxl") {
@@ -31,7 +52,7 @@ export function convert(
}
return new Promise((resolve, reject) => {
execFile(tool, [filePath, targetPath], (error, stdout, stderr) => {
exec(`${tool} "${filePath}" "${targetPath}"`, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}

View File

@@ -1,177 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
text: [
"602",
"abw",
"csv",
"cwk",
"doc",
"docm",
"docx",
"dot",
"dotx",
"dotm",
"epub",
"fb2",
"fodt",
"htm",
"html",
"hwp",
"mcw",
"mw",
"mwd",
"lwp",
"lrf",
"odt",
"ott",
"pages",
"pdf",
"psw",
"rtf",
"sdw",
"stw",
"sxw",
"tab",
"tsv",
"txt",
"wn",
"wpd",
"wps",
"wpt",
"wri",
"xhtml",
"xml",
"zabw",
],
},
to: {
text: [
"csv",
"doc",
"docm",
"docx",
"dot",
"dotx",
"dotm",
"epub",
"fodt",
"htm",
"html",
"odt",
"ott",
"pdf",
"rtf",
"tab",
"tsv",
"txt",
"wps",
"wpt",
"xhtml",
"xml",
],
},
};
type FileCategories = "text" | "calc";
const filters: Record<FileCategories, Record<string, string>> = {
text: {
"602": "T602Document",
abw: "AbiWord",
csv: "Text",
doc: "MS Word 97",
docm: "MS Word 2007 XML VBA",
docx: "MS Word 2007 XML",
dot: "MS Word 97 Vorlage",
dotx: "MS Word 2007 XML Template",
dotm: "MS Word 2007 XML Template",
epub: "EPUB",
fb2: "Fictionbook 2",
fodt: "OpenDocument Text Flat XML",
htm: "HTML (StarWriter)",
html: "HTML (StarWriter)",
hwp: "writer_MIZI_Hwp_97",
mcw: "MacWrite",
mw: "MacWrite",
mwd: "Mariner_Write",
lwp: "LotusWordPro",
lrf: "BroadBand eBook",
odt: "writer8",
ott: "writer8_template",
pages: "Apple Pages",
// pdf: "writer_pdf_import",
psw: "PocketWord File",
rtf: "Rich Text Format",
sdw: "StarOffice_Writer",
stw: "writer_StarOffice_XML_Writer_Template",
sxw: "StarOffice XML (Writer)",
tab: "Text",
tsv: "Text",
txt: "Text",
wn: "WriteNow",
wpd: "WordPerfect",
wps: "MS Word 97",
wpt: "MS Word 97 Vorlage",
wri: "MS_Write",
xhtml: "HTML (StarWriter)",
xml: "OpenDocument Text Flat XML",
zabw: "AbiWord",
},
calc: {},
};
const getFilters = (fileType: string, converto: string) => {
if (fileType in filters.text && converto in filters.text) {
return [filters.text[fileType], filters.text[converto]];
} else if (fileType in filters.calc && converto in filters.calc) {
return [filters.calc[fileType], filters.calc[converto]];
}
return [null, null];
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
// Build arguments array
const args: string[] = [];
args.push("--headless");
const [inFilter, outFilter] = getFilters(fileType, convertTo);
if (inFilter) {
args.push(`--infilter="${inFilter}"`);
}
if (outFilter) {
args.push("--convert-to", `${convertTo}:${outFilter}`, "--outdir", outputPath, filePath);
} else {
args.push("--convert-to", convertTo, "--outdir", outputPath, filePath);
}
return new Promise((resolve, reject) => {
execFile("soffice", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}

View File

@@ -1,28 +1,16 @@
import { Cookie } from "elysia";
import db from "../db/db";
import { MAX_CONVERT_PROCESS } from "../helpers/env";
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
import { normalizeFiletype } from "../helpers/normalizeFiletype";
import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertDasel, properties as propertiesDasel } from "./dasel";
import { convert as convertDvisvgm, properties as propertiesDvisvgm } from "./dvisvgm";
import { convert as convertFFmpeg, properties as propertiesFFmpeg } from "./ffmpeg";
import {
convert as convertGraphicsmagick,
properties as propertiesGraphicsmagick,
} from "./graphicsmagick";
import { convert as convertImagemagick, properties as propertiesImagemagick } from "./imagemagick";
import { convert as convertGraphicsmagick, properties as propertiesGraphicsmagick } from "./graphicsmagick";
import { convert as convertInkscape, properties as propertiesInkscape } from "./inkscape";
import { convert as convertLibheif, properties as propertiesLibheif } from "./libheif";
import { convert as convertLibjxl, properties as propertiesLibjxl } from "./libjxl";
import { convert as convertLibreOffice, properties as propertiesLibreOffice } from "./libreoffice";
import { convert as convertMsgconvert, properties as propertiesMsgconvert } from "./msgconvert";
import { convert as convertPandoc, properties as propertiesPandoc } from "./pandoc";
import { convert as convertPotrace, properties as propertiesPotrace } from "./potrace";
import { convert as convertresvg, properties as propertiesresvg } from "./resvg";
import { convert as convertImage, properties as propertiesImage } from "./vips";
import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer";
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertLibheif, properties as propertiesLibheif } from "./libheif";
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
@@ -54,11 +42,6 @@ const properties: Record<
) => unknown;
}
> = {
// Prioritize Inkscape for EMF files as it handles them better than ImageMagick
inkscape: {
properties: propertiesInkscape,
converter: convertInkscape,
},
libjxl: {
properties: propertiesLibjxl,
converter: convertLibjxl,
@@ -67,14 +50,14 @@ const properties: Record<
properties: propertiesresvg,
converter: convertresvg,
},
vips: {
properties: propertiesImage,
converter: convertImage,
},
libheif: {
properties: propertiesLibheif,
converter: convertLibheif,
},
vips: {
properties: propertiesImage,
converter: convertImage,
},
xelatex: {
properties: propertiesxelatex,
converter: convertxelatex,
@@ -83,34 +66,18 @@ const properties: Record<
properties: propertiesCalibre,
converter: convertCalibre,
},
dasel: {
properties: propertiesDasel,
converter: convertDasel,
},
libreoffice: {
properties: propertiesLibreOffice,
converter: convertLibreOffice,
},
pandoc: {
properties: propertiesPandoc,
converter: convertPandoc,
},
msgconvert: {
properties: propertiesMsgconvert,
converter: convertMsgconvert,
},
dvisvgm: {
properties: propertiesDvisvgm,
converter: convertDvisvgm,
},
imagemagick: {
properties: propertiesImagemagick,
converter: convertImagemagick,
},
graphicsmagick: {
properties: propertiesGraphicsmagick,
converter: convertGraphicsmagick,
},
inkscape: {
properties: propertiesInkscape,
converter: convertInkscape,
},
assimp: {
properties: propertiesassimp,
converter: convertassimp,
@@ -119,67 +86,9 @@ const properties: Record<
properties: propertiesFFmpeg,
converter: convertFFmpeg,
},
potrace: {
properties: propertiesPotrace,
converter: convertPotrace,
},
vtracer: {
properties: propertiesVtracer,
converter: convertVtracer,
},
};
function chunks<T>(arr: T[], size: number): T[][] {
if (size <= 0) {
return [arr];
}
return Array.from({ length: Math.ceil(arr.length / size) }, (_: T, i: number) =>
arr.slice(i * size, i * size + size),
);
}
export async function handleConvert(
fileNames: string[],
userUploadsDir: string,
userOutputDir: string,
convertTo: string,
converterName: string,
jobId: Cookie<string | undefined>,
) {
const query = db.query(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for (const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
const newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
.then((r) => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
})
.catch((c) => reject(c));
}),
);
}
await Promise.all(toProcess);
}
}
async function mainConverter(
export async function mainConverter(
inputFilePath: string,
fileTypeOriginal: string,
convertTo: string,
@@ -189,7 +98,7 @@ async function mainConverter(
) {
const fileType = normalizeFiletype(fileTypeOriginal);
let converterFunc: (typeof properties)["libjxl"]["converter"] | undefined;
let converterFunc: typeof properties["libjxl"]["converter"] | undefined;
if (converterName) {
converterFunc = properties[converterName]?.converter;
@@ -215,12 +124,20 @@ async function mainConverter(
}
if (!converterFunc) {
console.log(`No available converter supports converting from ${fileType} to ${convertTo}.`);
console.log(
`No available converter supports converting from ${fileType} to ${convertTo}.`,
);
return "File type not supported";
}
try {
const result = await converterFunc(inputFilePath, fileType, convertTo, targetPath, options);
const result = await converterFunc(
inputFilePath,
fileType,
convertTo,
targetPath,
options,
);
console.log(
`Converted ${inputFilePath} from ${fileType} to ${convertTo} successfully using ${converterName}.`,
@@ -260,7 +177,8 @@ for (const converterName in properties) {
possibleTargets[extension] = {};
}
possibleTargets[extension][converterName] = converterProperties.to[key] || [];
possibleTargets[extension][converterName] =
converterProperties.to[key] || [];
}
}
}

View File

@@ -1,52 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
email: ["msg"],
},
to: {
email: ["eml"],
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
if (fileType === "msg" && convertTo === "eml") {
// Convert MSG to EML using msgconvert
// msgconvert will output to the same directory as the input file with .eml extension
// We need to use --outfile to specify the target path
const args = ["--outfile", targetPath, filePath];
execFile("msgconvert", args, (error, stdout, stderr) => {
if (error) {
reject(new Error(`msgconvert failed: ${error.message}`));
return;
}
if (stderr) {
// Log sanitized stderr to avoid exposing sensitive paths
const sanitizedStderr = stderr.replace(/(\/[^\s]+)/g, "[REDACTED_PATH]");
console.warn(
`msgconvert stderr: ${sanitizedStderr.length > 200 ? sanitizedStderr.slice(0, 200) + "..." : sanitizedStderr}`,
);
}
resolve(targetPath);
});
} else {
reject(
new Error(
`Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`,
),
);
}
});
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -125,39 +124,33 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
// set xelatex here
const xelatex = ["pdf", "latex"];
// Build arguments array
const args: string[] = [];
let option = "";
if (xelatex.includes(convertTo)) {
args.push("--pdf-engine=xelatex");
option = "--pdf-engine=xelatex";
}
args.push(filePath);
args.push("-f", fileType);
args.push("-t", convertTo);
args.push("-o", targetPath);
return new Promise((resolve, reject) => {
execFile("pandoc", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
exec(
`pandoc ${option} "${filePath}" -f ${fileType} -t ${convertTo} -o "${targetPath}"`,
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
resolve("Done");
},
);
});
}

View File

@@ -1,50 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
images: ["pnm", "pbm", "pgm", "bmp"],
},
to: {
images: [
"svg",
"pdf",
"pdfpage",
"eps",
"postscript",
"ps",
"dxf",
"geojson",
"pgm",
"gimppath",
"xfig",
],
},
};
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("potrace", [filePath, "-o", targetPath, "-b", convertTo], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -15,11 +14,11 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("resvg", [filePath, targetPath], (error, stdout, stderr) => {
exec(`resvg "${filePath}" "${targetPath}"`, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}

View File

@@ -1,17 +0,0 @@
import { ExecFileOptions } from "child_process";
export type ExecFileFn = (
cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
options?: ExecFileOptions,
) => void;
export type ConvertFnWithExecFile = (
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options: unknown,
execFileOverride?: ExecFileFn,
) => Promise<string>;

View File

@@ -1,5 +1,5 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
// declare possible conversions
export const properties = {
@@ -95,8 +95,8 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
// if (fileType === "svg") {
// const scale = options.scale || 1;
@@ -120,20 +120,23 @@ export function convert(
}
return new Promise((resolve, reject) => {
execFile("vips", [action, filePath, targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
exec(
`vips ${action} "${filePath}" "${targetPath}"`,
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
resolve("Done");
},
);
});
}

View File

@@ -1,80 +0,0 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
images: ["jpg", "jpeg", "png", "bmp", "gif", "tiff", "tif", "webp"],
},
to: {
images: ["svg"],
},
};
interface VTracerOptions {
colormode?: string;
hierarchical?: string;
mode?: string;
filter_speckle?: string | number;
color_precision?: string | number;
layer_difference?: string | number;
corner_threshold?: string | number;
length_threshold?: string | number;
max_iterations?: string | number;
splice_threshold?: string | number;
path_precision?: string | number;
}
export function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
// Build vtracer arguments
const args = ["--input", filePath, "--output", targetPath];
// Add optional parameter if provided
if (options && typeof options === "object") {
const opts = options as VTracerOptions;
const validOptions: Array<keyof VTracerOptions> = [
"colormode",
"hierarchical",
"mode",
"filter_speckle",
"color_precision",
"layer_difference",
"corner_threshold",
"length_threshold",
"max_iterations",
"splice_threshold",
"path_precision",
];
for (const option of validOptions) {
if (opts[option] !== undefined) {
args.push(`--${option}`, String(opts[option]));
}
}
}
execFile("vtracer", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}${stderr ? `\nstderr: ${stderr}` : ""}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.log(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}

View File

@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { exec } from "node:child_process";
export const properties = {
from: {
@@ -15,16 +14,18 @@ export function convert(
fileType: string,
convertTo: string,
targetPath: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
// const fileName: string = (targetPath.split("/").pop() as string).replace(".pdf", "")
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "");
execFile(
"latexmk",
["-xelatex", "-interaction=nonstopmode", `-output-directory=${outputPath}`, filePath],
const outputPath = targetPath
.split("/")
.slice(0, -1)
.join("/")
.replace("./", "");
exec(
`latexmk -xelatex -interaction=nonstopmode -output-directory="${outputPath}" "${filePath}"`,
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);

View File

@@ -1,41 +0,0 @@
import { Database } from "bun:sqlite";
const db = new Database("./data/mydb.sqlite", { create: true });
if (!db.query("SELECT * FROM sqlite_master WHERE type='table'").get()) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS file_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
file_name TEXT NOT NULL,
output_file_name TEXT NOT NULL,
status TEXT DEFAULT 'not started',
FOREIGN KEY (job_id) REFERENCES jobs(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date_created TEXT NOT NULL,
status TEXT DEFAULT 'not started',
num_files INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
);
PRAGMA user_version = 1;`);
}
const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version;
if (dbVersion === 0) {
db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';");
db.exec("PRAGMA user_version = 1;");
console.log("Updated database to version 1.");
}
// enable WAL mode
db.exec("PRAGMA journal_mode = WAL;");
export default db;

View File

@@ -1,23 +0,0 @@
export class Filename {
id!: number;
job_id!: number;
file_name!: string;
output_file_name!: string;
status!: string;
}
export class Jobs {
finished_files!: number;
id!: number;
user_id!: number;
date_created!: string;
status!: string;
num_files!: number;
files_detailed!: Filename[];
}
export class User {
id!: number;
email!: string;
password!: string;
}

View File

@@ -1,25 +0,0 @@
export const ACCOUNT_REGISTRATION =
process.env.ACCOUNT_REGISTRATION?.toLowerCase() === "true" || false;
export const HTTP_ALLOWED = process.env.HTTP_ALLOWED?.toLowerCase() === "true" || false;
export const ALLOW_UNAUTHENTICATED =
process.env.ALLOW_UNAUTHENTICATED?.toLowerCase() === "true" || false;
export const AUTO_DELETE_EVERY_N_HOURS = process.env.AUTO_DELETE_EVERY_N_HOURS
? Number(process.env.AUTO_DELETE_EVERY_N_HOURS)
: 24;
export const HIDE_HISTORY = process.env.HIDE_HISTORY?.toLowerCase() === "true" || false;
export const WEBROOT = process.env.WEBROOT ?? "";
export const LANGUAGE = process.env.LANGUAGE?.toLowerCase() || "en";
export const MAX_CONVERT_PROCESS =
process.env.MAX_CONVERT_PROCESS && Number(process.env.MAX_CONVERT_PROCESS) > 0
? Number(process.env.MAX_CONVERT_PROCESS)
: 0;
export const UNAUTHENTICATED_USER_SHARING =
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;

View File

@@ -44,16 +44,6 @@ if (process.env.NODE_ENV === "production") {
}
});
exec("magick --version", (error, stdout) => {
if (error) {
console.error("ImageMagick is not installed.");
}
if (stdout) {
console.log(stdout.split("\n")[0]?.replace("Version: ", ""));
}
});
exec("gm version", (error, stdout) => {
if (error) {
console.error("GraphicsMagick is not installed.");
@@ -84,16 +74,6 @@ if (process.env.NODE_ENV === "production") {
}
});
exec("dasel --version", (error, stdout) => {
if (error) {
console.error("dasel is not installed.");
}
if (stdout) {
console.log(stdout.split("\n")[0]);
}
});
exec("xelatex -version", (error, stdout) => {
if (error) {
console.error("Tex Live with XeTeX is not installed.");
@@ -144,36 +124,6 @@ if (process.env.NODE_ENV === "production") {
}
});
exec("potrace -v", (error, stdout) => {
if (error) {
console.error("potrace is not installed");
}
if (stdout) {
console.log(stdout.split("\n")[0]);
}
});
exec("soffice --version", (error, stdout) => {
if (error) {
console.error("libreoffice is not installed");
}
if (stdout) {
console.log(stdout.split("\n")[0]);
}
});
exec("msgconvert --version", (error, stdout) => {
if (error) {
console.error("msgconvert (libemail-outlook-message-perl) is not installed");
}
if (stdout) {
console.log(stdout.split("\n")[0]);
}
});
exec("bun -v", (error, stdout) => {
if (error) {
console.error("Bun is not installed. wait what");

View File

@@ -1,18 +0,0 @@
export function DownloadIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
class={`size-6`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
}

View File

@@ -1,19 +0,0 @@
export function EyeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
class={`size-6`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,39 @@
@import "./theme/theme.css";
@import "tailwindcss";
@plugin "tailwind-scrollbar";
@plugin 'tailwind-scrollbar';
@theme {
--color-contrast: var(--contrast);
--color-neutral-900: var(--neutral-900);
--color-neutral-800: var(--neutral-800);
--color-neutral-700: var(--neutral-700);
--color-neutral-600: var(--neutral-600);
--color-neutral-500: var(--neutral-500);
--color-neutral-400: var(--neutral-400);
--color-neutral-300: var(--neutral-300);
--color-neutral-200: var(--neutral-200);
--color-neutral-100: var(--neutral-100);
--color-accent-600: var(--accent-600);
--color-accent-500: var(--accent-500);
--color-accent-400: var(--accent-400);
--color-contrast: rgba(var(--contrast));
--color-neutral-900: rgba(var(--neutral-900));
--color-neutral-800: rgba(var(--neutral-800));
--color-neutral-700: rgba(var(--neutral-700));
--color-neutral-600: rgba(var(--neutral-600));
--color-neutral-500: rgba(var(--neutral-500));
--color-neutral-400: rgba(var(--neutral-400));
--color-neutral-300: rgba(var(--neutral-300));
--color-neutral-200: rgba(var(--neutral-200));
--color-neutral-100: rgba(var(--neutral-100));
--color-accent-600: rgba(var(--accent-600));
--color-accent-500: rgba(var(--accent-500));
--color-accent-400: rgba(var(--accent-400));
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@utility article {
@@ -27,6 +44,35 @@
@apply bg-accent-500 text-contrast rounded-sm p-2 sm:p-4 hover:bg-accent-400 cursor-pointer transition-colors;
}
@utility btn-secondary {
@apply bg-neutral-400 text-contrast rounded-sm p-2 sm:p-4 hover:bg-neutral-300 cursor-pointer transition-colors;
:root {
--contrast: 255, 255, 255;
--neutral-900: 243, 244, 246;
--neutral-800: 229, 231, 235;
--neutral-700: 209, 213, 219;
--neutral-600: 156, 163, 175;
--neutral-500: 180, 180, 180;
--neutral-400: 75, 85, 99;
--neutral-300: 55, 65, 81;
--neutral-200: 31, 41, 55;
--neutral-100: 17, 24, 39;
--accent-400: 132, 204, 22;
--accent-500: 101, 163, 13;
--accent-600: 77, 124, 15;
}
@media (prefers-color-scheme: dark) {
:root {
--contrast: 0, 0, 0;
--neutral-900: 17, 24, 39;
--neutral-800: 31, 41, 55;
--neutral-700: 55, 65, 81;
--neutral-600: 75, 85, 99;
--neutral-500: 107, 114, 128;
--neutral-300: 209, 213, 219;
--neutral-400: 156, 163, 175;
--neutral-200: 229, 231, 235;
--accent-600: 101, 163, 13;
--accent-500: 132, 204, 22;
--accent-400: 163, 230, 53;
}
}

View File

@@ -1,66 +0,0 @@
import Elysia, { t } from "elysia";
import { getPossibleTargets } from "../converters/main";
import { userService } from "./user";
export const chooseConverter = new Elysia().use(userService).post(
"/conversions",
({ body }) => {
return (
<>
<article
class={`
convert_to_popup absolute z-2 m-0 hidden h-[50vh] max-h-[50vh] w-full flex-col
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
sm:h-[30vh]
`}
>
{Object.entries(getPossibleTargets(body.fileType)).map(([converter, targets]) => (
<article
class={`convert_to_group flex w-full flex-col border-b border-neutral-700 p-4`}
data-converter={converter}
>
<header class="mb-2 w-full text-xl font-bold" safe>
{converter}
</header>
<ul class="convert_to_target flex flex-row flex-wrap gap-1">
{targets.map((target) => (
<button
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
tabindex={0}
class={`
target rounded bg-neutral-700 p-1 text-base
hover:bg-neutral-600
`}
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
type="button"
safe
>
{target}
</button>
))}
</ul>
</article>
))}
</article>
<select name="convert_to" aria-label="Convert to" required hidden>
<option selected disabled value="">
Convert to
</option>
{Object.entries(getPossibleTargets(body.fileType)).map(([converter, targets]) => (
<optgroup label={converter}>
{targets.map((target) => (
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
))}
</select>
</>
);
},
{ body: t.Object({ fileType: t.String() }) },
);

View File

@@ -1,94 +0,0 @@
import { mkdir } from "node:fs/promises";
import { Elysia, t } from "elysia";
import sanitize from "sanitize-filename";
import { outputDir, uploadsDir } from "..";
import { handleConvert } from "../converters/main";
import db from "../db/db";
import { Jobs } from "../db/types";
import { WEBROOT } from "../helpers/env";
import { normalizeFiletype } from "../helpers/normalizeFiletype";
import { userService } from "./user";
export const convert = new Elysia().use(userService).post(
"/convert",
async ({ body, redirect, jwt, cookie: { auth, jobId } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.as(Jobs)
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
const userOutputDir = `${outputDir}${user.id}/${jobId.value}/`;
// create the output directory
try {
await mkdir(userOutputDir, { recursive: true });
} catch (error) {
console.error(`Failed to create the output directory: ${userOutputDir}.`, error);
}
const convertTo = normalizeFiletype(body.convert_to.split(",")[0] ?? "");
const converterName = body.convert_to.split(",")[1];
if (!converterName) {
return redirect(`${WEBROOT}/`, 302);
}
const fileNames = JSON.parse(body.file_names) as string[];
for (let i = 0; i < fileNames.length; i++) {
fileNames[i] = sanitize(fileNames[i] || "");
}
if (!Array.isArray(fileNames) || fileNames.length === 0) {
return redirect(`${WEBROOT}/`, 302);
}
db.query("UPDATE jobs SET num_files = ?1, status = 'pending' WHERE id = ?2").run(
fileNames.length,
jobId.value,
);
// Start the conversion process in the background
handleConvert(fileNames, userUploadsDir, userOutputDir, convertTo, converterName, jobId)
.then(() => {
// All conversions are done, update the job status to 'completed'
if (jobId.value) {
db.query("UPDATE jobs SET status = 'completed' WHERE id = ?1").run(jobId.value);
}
// Delete all uploaded files in userUploadsDir
// rmSync(userUploadsDir, { recursive: true, force: true });
})
.catch((error) => {
console.error("Error in conversion process:", error);
});
// Redirect the client immediately
return redirect(`${WEBROOT}/results/${jobId.value}`, 302);
},
{
body: t.Object({
convert_to: t.String(),
file_names: t.String(),
}),
auth: true,
},
);

View File

@@ -1,32 +0,0 @@
import { unlink } from "node:fs/promises";
import { Elysia, t } from "elysia";
import { uploadsDir } from "..";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const deleteFile = new Elysia().use(userService).post(
"/delete",
async ({ body, redirect, cookie: { jobId }, user }) => {
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = await db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
await unlink(`${userUploadsDir}${body.filename}`);
return {
message: "File deleted successfully.",
};
},
{ body: t.Object({ filename: t.String() }), auth: true },
);

View File

@@ -1,65 +0,0 @@
import path from "node:path";
import { Elysia } from "elysia";
import sanitize from "sanitize-filename";
import * as tar from "tar";
import { outputDir } from "..";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const download = new Elysia()
.use(userService)
.get(
"/download/:userId/:jobId/:fileName",
async ({ params, redirect, user }) => {
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.get(user.id, params.jobId);
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
// parse from URL encoded string
const userId = decodeURIComponent(params.userId);
const jobId = decodeURIComponent(params.jobId);
const fileName = sanitize(decodeURIComponent(params.fileName));
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
return Bun.file(filePath);
},
{
auth: true,
},
)
.get(
"/archive/:userId/:jobId",
async ({ params, redirect, user }) => {
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.get(user.id, params.jobId);
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
const userId = decodeURIComponent(params.userId);
const jobId = decodeURIComponent(params.jobId);
const outputPath = `${outputDir}${userId}/${jobId}`;
const outputTar = path.join(outputPath, `converted_files_${jobId}.tar`);
await tar.create(
{
file: outputTar,
cwd: outputPath,
filter: (path) => {
return !path.match(".*\\.tar");
},
},
["."],
);
return Bun.file(outputTar);
},
{
auth: true,
},
);

View File

@@ -1,214 +0,0 @@
import { Elysia } from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import db from "../db/db";
import { Filename, Jobs } from "../db/types";
import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, LANGUAGE, WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const history = new Elysia().use(userService).get(
"/history",
async ({ redirect, user }) => {
if (HIDE_HISTORY) {
return redirect(`${WEBROOT}/`, 302);
}
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
let userJobs = db.query("SELECT * FROM jobs WHERE user_id = ?").as(Jobs).all(user.id).reverse();
for (const job of userJobs) {
const files = db.query("SELECT * FROM file_names WHERE job_id = ?").as(Filename).all(job.id);
job.finished_files = files.length;
job.files_detailed = files;
}
// Filter out jobs with no files
userJobs = userJobs.filter((job) => job.num_files > 0);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Results">
<>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Results</h1>
<table
class={`
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
<span class="sr-only">Expand details</span>
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Time
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Files
</th>
<th
class={`
px-2 py-2
max-sm:hidden
sm:px-4
`}
>
Files Done
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
View
</th>
</tr>
</thead>
<tbody>
{userJobs.map((job) => (
<>
<tr id={`job-row-${job.id}`}>
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
<svg
id={`arrow-${job.id}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="inline-block h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</td>
<td safe>{new Date(job.date_created).toLocaleTimeString(LANGUAGE)}</td>
<td>{job.num_files}</td>
<td class="max-sm:hidden">{job.finished_files}</td>
<td safe>{job.status}</td>
<td>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/results/${job.id}`}
>
View
</a>
</td>
</tr>
<tr id={`details-${job.id}`} class="hidden">
<td colspan="6">
<div class="p-2 text-sm text-neutral-500">
<div class="mb-1 font-semibold">Detailed File Information:</div>
{job.files_detailed.map((file: Filename) => (
<div class="flex items-center">
<span class="w-5/12 truncate" title={file.file_name} safe>
{file.file_name}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class={`mx-2 inline-block h-4 w-4 text-neutral-500`}
>
<path
fill-rule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="w-5/12 truncate" title={file.output_file_name} safe>
{file.output_file_name}
</span>
</div>
))}
</div>
</td>
</tr>
</>
))}
</tbody>
</table>
</article>
</main>
<script>
{`
document.addEventListener('DOMContentLoaded', () => {
const toggles = document.querySelectorAll('.job-details-toggle');
toggles.forEach(toggle => {
toggle.addEventListener('click', function() {
const jobId = this.dataset.jobId;
const detailsRow = document.getElementById(\`details-\${jobId}\`);
// The arrow SVG itself has the ID arrow-\${jobId}
const arrow = document.getElementById(\`arrow-\${jobId}\`);
if (detailsRow && arrow) {
detailsRow.classList.toggle("hidden");
if (detailsRow.classList.contains("hidden")) {
// Right-facing arrow (collapsed)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />';
} else {
// Down-facing arrow (expanded)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />';
}
}
});
});
});
`}
</script>
</>
</BaseHtml>
);
},
{
auth: true,
},
);

View File

@@ -1,74 +0,0 @@
import Elysia from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import { getAllInputs, getAllTargets } from "../converters/main";
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const listConverters = new Elysia().use(userService).get(
"/converters",
async () => {
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Converters">
<>
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn />
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Converters</h1>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
[&_ul]:list-inside [&_ul]:list-disc
`}
>
<thead>
<tr>
<th class="mx-4 my-2">Converter</th>
<th class="mx-4 my-2">From (Count)</th>
<th class="mx-4 my-2">To (Count)</th>
</tr>
</thead>
<tbody>
{Object.entries(getAllTargets()).map(([converter, targets]) => {
const inputs = getAllInputs(converter);
return (
<tr>
<td safe>{converter}</td>
<td>
Count: {inputs.length}
<ul>
{inputs.map((input) => (
<li safe>{input}</li>
))}
</ul>
</td>
<td>
Count: {targets.length}
<ul>
{targets.map((target) => (
<li safe>{target}</li>
))}
</ul>
</td>
</tr>
);
})}
</tbody>
</table>
</article>
</main>
</>
</BaseHtml>
);
},
{
auth: true,
},
);

View File

@@ -1,205 +0,0 @@
import { JWTPayloadSpec } from "@elysiajs/jwt";
import { Elysia } from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import db from "../db/db";
import { Filename, Jobs } from "../db/types";
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { DownloadIcon } from "../icons/download";
import { EyeIcon } from "../icons/eye";
import { userService } from "./user";
function ResultsArticle({
user,
job,
files,
outputPath,
}: {
user: {
id: string;
} & JWTPayloadSpec;
job: Jobs;
files: Filename[];
outputPath: string;
}) {
return (
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<div class="flex flex-row gap-4">
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
href={`${WEBROOT}/archive/${user.id}/${job.id}`}
download={`converted_files_${job.id}.tar`}
class="flex btn-primary flex-row gap-2 text-contrast"
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
>
<DownloadIcon /> <p>Tar</p>
</a>
<button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()">
<DownloadIcon /> <p>All</p>
</button>
</div>
</div>
<progress
max={job.num_files}
{...(files.length === job.num_files ? { value: files.length } : "")}
class={`
mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full border-0
bg-neutral-700 bg-none text-accent-500 accent-accent-500
[&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full
[&::-webkit-progress-value]:[background:none]
[&[value]::-webkit-progress-value]:bg-accent-500
[&[value]::-webkit-progress-value]:transition-[inline-size]
`}
/>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Converted File Name
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Actions
</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
download={file.output_file_name}
>
<DownloadIcon />
</a>
</td>
</tr>
))}
</tbody>
</table>
</article>
);
}
export const results = new Elysia()
.use(userService)
.get(
"/results/:jobId",
async ({ params, set, cookie: { job_id }, user }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
}
const outputPath = `${user.id}/${params.jobId}/`;
const files = db
.query("SELECT * FROM file_names WHERE job_id = ?")
.as(Filename)
.all(params.jobId);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Result">
<>
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn />
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<ResultsArticle user={user} job={job} files={files} outputPath={outputPath} />
</main>
<script src={`${WEBROOT}/results.js`} defer />
</>
</BaseHtml>
);
},
{ auth: true },
)
.post(
"/progress/:jobId",
async ({ set, params, cookie: { job_id }, user }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
}
const outputPath = `${user.id}/${params.jobId}/`;
const files = db
.query("SELECT * FROM file_names WHERE job_id = ?")
.as(Filename)
.all(params.jobId);
return <ResultsArticle user={user} job={job} files={files} outputPath={outputPath} />;
},
{ auth: true },
);

View File

@@ -1,249 +0,0 @@
import { randomInt } from "node:crypto";
import { JWTPayloadSpec } from "@elysiajs/jwt";
import { Elysia, t } from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import { getAllTargets } from "../converters/main";
import db from "../db/db";
import { User } from "../db/types";
import {
ACCOUNT_REGISTRATION,
ALLOW_UNAUTHENTICATED,
HIDE_HISTORY,
HTTP_ALLOWED,
UNAUTHENTICATED_USER_SHARING,
WEBROOT,
} from "../helpers/env";
import { FIRST_RUN, userService } from "./user";
export const root = new Elysia().use(userService).get(
"/",
async ({ jwt, redirect, cookie: { auth, jobId } }) => {
if (!ALLOW_UNAUTHENTICATED) {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
}
// validate jwt
let user: ({ id: string } & JWTPayloadSpec) | false = false;
if (ALLOW_UNAUTHENTICATED) {
const newUserId = String(
UNAUTHENTICATED_USER_SHARING
? 0
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
);
const accessToken = await jwt.sign({
id: newUserId,
});
user = { id: newUserId };
if (!auth) {
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
};
}
// set cookie
auth.set({
value: accessToken,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});
} else if (auth?.value) {
user = await jwt.verify(auth.value);
if (
user !== false &&
user.id &&
(Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED)
) {
// Make sure user exists in db
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
if (!existingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
}
}
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
// create a new job
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
user.id,
new Date().toISOString(),
);
const { id } = db
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
.get(user.id) as { id: number };
if (!jobId) {
return { message: "Cookies should be enabled to use this app." };
}
jobId.set({
value: id,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});
console.log("jobId set to:", id);
return (
<BaseHtml webroot={WEBROOT}>
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Convert</h1>
<div class="mb-4 scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
id="file-list"
class={`
w-full table-auto rounded bg-neutral-900
[&_td]:p-4 [&_td]:first:max-w-[30vw] [&_td]:first:truncate
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
/>
</div>
<div
id="dropzone"
class={`
relative flex h-48 w-full items-center justify-center rounded border border-dashed
border-neutral-700 transition-all
hover:border-neutral-600
[&.dragover]:border-4 [&.dragover]:border-neutral-500
`}
>
<span>
<b>Choose a file</b> or drag it here
</span>
<input
type="file"
name="file"
multiple
class="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</div>
</article>
<form
method="post"
action={`${WEBROOT}/convert`}
class="relative mx-auto mb-[35vh] w-full max-w-4xl"
>
<input type="hidden" name="file_names" id="file_names" />
<article class="article w-full">
<input
type="search"
name="convert_to_search"
placeholder="Search for conversions"
autocomplete="off"
class="w-full rounded-sm bg-neutral-800 p-4"
/>
<div class="select_container relative">
<article
class={`
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full flex-col
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
sm:h-[30vh]
`}
>
{Object.entries(getAllTargets()).map(([converter, targets]) => (
<article
class={`
convert_to_group flex w-full flex-col border-b border-neutral-700 p-4
`}
data-converter={converter}
>
<header class="mb-2 w-full text-xl font-bold" safe>
{converter}
</header>
<ul class={`convert_to_target flex flex-row flex-wrap gap-1`}>
{targets.map((target) => (
<button
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
tabindex={0}
class={`
target rounded bg-neutral-700 p-1 text-base
hover:bg-neutral-600
`}
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
type="button"
safe
>
{target}
</button>
))}
</ul>
</article>
))}
</article>
{/* Hidden element which determines the format to convert the file too and the converter to use */}
<select name="convert_to" aria-label="Convert to" required hidden>
<option selected disabled value="">
Convert to
</option>
{Object.entries(getAllTargets()).map(([converter, targets]) => (
<optgroup label={converter}>
{targets.map((target) => (
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
))}
</select>
</div>
</article>
<input
class={`
w-full btn-primary opacity-100
disabled:cursor-not-allowed disabled:opacity-50
`}
type="submit"
value="Convert"
disabled
/>
</form>
</main>
<script src="script.js" defer />
</>
</BaseHtml>
);
},
{
cookie: t.Cookie({
auth: t.Optional(t.String()),
jobId: t.Optional(t.String()),
}),
},
);

View File

@@ -1,39 +0,0 @@
import { Elysia, t } from "elysia";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { uploadsDir } from "../index";
import { userService } from "./user";
export const upload = new Elysia().use(userService).post(
"/upload",
async ({ body, redirect, user, cookie: { jobId } }) => {
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = await db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
if (body?.file) {
if (Array.isArray(body.file)) {
for (const file of body.file) {
await Bun.write(`${userUploadsDir}${file.name}`, file);
}
} else {
await Bun.write(`${userUploadsDir}${body.file["name"]}`, body.file);
}
}
return {
message: "Files uploaded successfully.",
};
},
{ body: t.Object({ file: t.Files() }), auth: true },
);

View File

@@ -1,522 +0,0 @@
import { randomUUID } from "node:crypto";
import { jwt } from "@elysiajs/jwt";
import { Elysia, t } from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import db from "../db/db";
import { User } from "../db/types";
import {
ACCOUNT_REGISTRATION,
ALLOW_UNAUTHENTICATED,
HIDE_HISTORY,
HTTP_ALLOWED,
WEBROOT,
} from "../helpers/env";
export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
export const userService = new Elysia({ name: "user/service" })
.use(
jwt({
name: "jwt",
schema: t.Object({
id: t.String(),
}),
secret: process.env.JWT_SECRET ?? randomUUID(),
exp: "7d",
}),
)
.model({
signIn: t.Object({
email: t.String(),
password: t.String(),
}),
session: t.Cookie({
auth: t.String(),
jobId: t.Optional(t.String()),
}),
optionalSession: t.Cookie({
auth: t.Optional(t.String()),
jobId: t.Optional(t.String()),
}),
})
.macro("auth", {
cookie: "session",
async resolve({ status, jwt, cookie: { auth } }) {
if (!auth.value) {
return status(401, {
success: false,
message: "Unauthorized",
});
}
const user = await jwt.verify(auth.value);
if (!user) {
return status(401, {
success: false,
message: "Unauthorized",
});
}
return {
success: true,
user,
};
},
});
export const user = new Elysia()
.use(userService)
.get("/setup", ({ redirect }) => {
if (!FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml title="ConvertX | Setup" webroot={WEBROOT}>
<main
class={`
mx-auto w-full max-w-4xl flex-1 px-2
sm:px-4
`}
>
<h1 class="my-8 text-3xl">Welcome to ConvertX!</h1>
<article class="article p-0">
<header class="w-full bg-neutral-800 p-4">Create your account</header>
<form method="post" action={`${WEBROOT}/register`} class="p-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input type="submit" value="Create account" class="btn-primary" />
</form>
<footer class="p-4">
Report any issues on{" "}
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href="https://github.com/C4illin/ConvertX"
>
GitHub
</a>
.
</footer>
</article>
</main>
</BaseHtml>
);
})
.get("/register", ({ redirect }) => {
if (!ACCOUNT_REGISTRATION) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Register">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input type="submit" value="Register" class="w-full btn-primary" />
</form>
</article>
</main>
</>
</BaseHtml>
);
})
.post(
"/register",
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
if (!ACCOUNT_REGISTRATION && !FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302);
}
if (FIRST_RUN) {
FIRST_RUN = false;
}
const existingUser = await db.query("SELECT * FROM users WHERE email = ?").get(email);
if (existingUser) {
set.status = 400;
return {
message: "Email already in use.",
};
}
const savedPassword = await Bun.password.hash(password);
db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, savedPassword);
const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
if (!user) {
set.status = 500;
return {
message: "Failed to create user.",
};
}
const accessToken = await jwt.sign({
id: String(user.id),
});
if (!auth) {
set.status = 500;
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
};
}
// set cookie
auth.set({
value: accessToken,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 60 * 60 * 24 * 7,
sameSite: "strict",
});
return redirect(`${WEBROOT}/`, 302);
},
{ body: "signIn" },
)
.get(
"/login",
async ({ jwt, redirect, cookie: { auth } }) => {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}
// if already logged in, redirect to home
if (auth?.value) {
const user = await jwt.verify(auth.value);
if (user) {
return redirect(`${WEBROOT}/`, 302);
}
auth.remove();
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
{ACCOUNT_REGISTRATION ? (
<a
href={`${WEBROOT}/register`}
role="button"
class="w-full btn-secondary text-center"
>
Register
</a>
) : null}
<input type="submit" value="Login" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
},
{ body: "signIn", cookie: "optionalSession" },
)
.post(
"/login",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email);
if (!existingUser) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const validPassword = await Bun.password.verify(body.password, existingUser.password);
if (!validPassword) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const accessToken = await jwt.sign({
id: String(existingUser.id),
});
if (!auth) {
set.status = 500;
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
};
}
// set cookie
auth.set({
value: accessToken,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 60 * 60 * 24 * 7,
sameSite: "strict",
});
return redirect(`${WEBROOT}/`, 302);
},
{ body: "signIn" },
)
.get("/logoff", ({ redirect, cookie: { auth } }) => {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
})
.post("/logoff", ({ redirect, cookie: { auth } }) => {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
})
.get(
"/account",
async ({ user, redirect }) => {
if (!user) {
return redirect(`${WEBROOT}/`, 302);
}
const userData = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
if (!userData) {
return redirect(`${WEBROOT}/`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
value={userData.email}
required
/>
</label>
<label class="flex flex-col gap-1">
Password (leave blank for unchanged)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
/>
</label>
<label class="flex flex-col gap-1">
Current Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Update" class="w-full btn-primary" />
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
},
{
auth: true,
},
)
.post(
"/account",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
if (!existingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
const validPassword = await Bun.password.verify(body.password, existingUser.password);
if (!validPassword) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const fields = [];
const values = [];
if (body.email) {
const existingUser = await db
.query("SELECT id FROM users WHERE email = ?")
.as(User)
.get(body.email);
if (existingUser && existingUser.id.toString() !== user.id) {
set.status = 409;
return { message: "Email already in use." };
}
fields.push("email");
values.push(body.email);
}
if (body.newPassword) {
fields.push("password");
values.push(await Bun.password.hash(body.newPassword));
}
if (fields.length > 0) {
db.query(
`UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`,
).run(...values, user.id);
}
return redirect(`${WEBROOT}/`, 302);
},
{
body: t.Object({
email: t.MaybeEmpty(t.String()),
newPassword: t.MaybeEmpty(t.String()),
password: t.String(),
}),
cookie: "session",
},
);

View File

@@ -1,47 +0,0 @@
:root {
/* Light mode */
--contrast: oklch(100% 0 0);
/* Neutral colors - Gray */
--neutral-950: oklch(98.5% 0.002 247.839);
--neutral-900: oklch(96.7% 0.003 264.542);
--neutral-800: oklch(92.8% 0.006 264.531);
--neutral-700: oklch(87.2% 0.01 258.338);
--neutral-600: oklch(70.7% 0.022 261.325);
--neutral-500: oklch(55.1% 0.027 264.364);
--neutral-400: oklch(44.6% 0.03 256.802);
--neutral-300: oklch(37.3% 0.034 259.733);
--neutral-200: oklch(26.9% 0 0);
--neutral-100: oklch(21% 0.034 264.665);
--neutral-50: oklch(13% 0.028 261.692);
/* lime-700 */
--accent-600: oklch(53.2% 0.157 131.589);
/* lime-600 */
--accent-500: oklch(64.8% 0.2 131.684);
/* lime-500 */
--accent-400: oklch(76.8% 0.233 130.85);
}
@media (prefers-color-scheme: dark) {
/* Dark mode */
:root {
--contrast: oklch(0% 0 0);
/* Neutral colors - Gray */
--neutral-950: oklch(13% 0.028 261.692);
--neutral-900: oklch(21% 0.034 264.665);
--neutral-800: oklch(27.8% 0.033 256.848);
--neutral-700: oklch(37.3% 0.034 259.733);
--neutral-600: oklch(44.6% 0.03 256.802);
--neutral-500: oklch(55.1% 0.027 264.364);
--neutral-400: oklch(70.7% 0.022 261.325);
--neutral-300: oklch(87.2% 0.01 258.338);
--neutral-200: oklch(92.8% 0.006 264.531);
--neutral-100: oklch(96.7% 0.003 264.542);
--neutral-50: oklch(98.5% 0.002 247.839);
/* lime-600 */
--accent-600: oklch(64.8% 0.2 131.684);
/* lime-500 */
--accent-500: oklch(76.8% 0.233 130.85);
/* lime-400 */
--accent-400: oklch(84.1% 0.238 128.85);
}
}

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/assimp";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/calibre";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,91 +0,0 @@
import type { ExecFileException } from "node:child_process";
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/dvisvgm";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
runCommonTests(convert);
test("convert respects eps filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.eps", "eps", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["--eps", "input.eps", "output.stl"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects pdf filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.pdf", "pdf", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["--pdf", "input.pdf", "output.stl"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects svgz conversion target type", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.obj", "eps", "svgz", "output.svgz", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["-z", "input.obj", "output.svgz"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});

View File

@@ -1,181 +0,0 @@
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
let calls: string[][] = [];
function mockExecFile(
_cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
calls.push(args);
if (args.includes("fail.mov")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
callback(null, "Fake stdout", "");
}
}
beforeEach(() => {
calls = [];
delete process.env.FFMPEG_ARGS;
});
test("converts a normal file", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["-i", "in.mp4", "out.avi"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("adds resize for ico output", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.png", "png", "ico", "out.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done: resized to 256x256");
expect(calls[0]).toEqual(
expect.arrayContaining(["-filter:v", expect.stringContaining("scale=")]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libaom-av1 for av1.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "av1.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libaom-av1"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libx264 for h264.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libx265 for h265.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx265"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("uses libx266 for h266.mp4", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("in.mkv", "mkv", "h266.mp4", "out.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx266"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("respects FFMPEG_ARGS", async () => {
process.env.FFMPEG_ARGS = "-hide_banner -y";
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(calls[0]?.slice(0, 2)).toEqual(["-hide_banner", "-y"]);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("fails on exec error", async () => {
const originalConsoleError = console.error;
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
expect(convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile)).rejects.toThrow(
"mock failure",
);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Fake stderr: fail");
});
test("logs stderr when execFile returns only stderr and no error", async () => {
const originalConsoleError = console.error;
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
// Mock execFile to call back with no error, no stdout, but with stderr
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
};
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFileStderrOnly);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Only stderr output");
});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/graphicsmagick";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,26 +0,0 @@
import { test } from "bun:test";
import { ConvertFnWithExecFile } from "../../../src/converters/types";
import {
runConvertFailTest,
runConvertLogsStderror,
runConvertLogsStderrorAndStdout,
runConvertSuccessTest,
} from "./converters";
export function runCommonTests(convert: ConvertFnWithExecFile) {
test("convert resolves when execFile succeeds", async () => {
await runConvertSuccessTest(convert);
});
test("convert rejects when execFile fails", async () => {
await runConvertFailTest(convert);
});
test("convert logs stderr when present", async () => {
await runConvertLogsStderror(convert);
});
test("convert logs both stderr and stdout when present", async () => {
await runConvertLogsStderrorAndStdout(convert);
});
}

View File

@@ -1,121 +0,0 @@
import { expect } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types";
export async function runConvertSuccessTest(convertFn: ConvertFnWithExecFile) {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "");
};
const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(loggedMessage).toBe("stdout: Fake stdout");
}
export async function runConvertFailTest(convertFn: ConvertFnWithExecFile) {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("Test error"), "", "");
};
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile),
).rejects.toMatch(/error: Error: Test error/);
// Test with error object lacking 'message' property
const mockExecFileNoMessage: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
// Simulate a non-standard error object
callback({ notMessage: true } as unknown as ExecFileException, "", "");
};
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileNoMessage),
).rejects.toMatch(/error:/i);
// Test with a non-object error (e.g., a string)
const mockExecFileStringError: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback("string error" as unknown as ExecFileException, "", "");
};
expect(
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileStringError),
).rejects.toMatch(/error:/i);
}
export async function runConvertLogsStderror(convertFn: ConvertFnWithExecFile) {
const originalConsoleError = console.error;
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Fake stderr");
};
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Fake stderr");
}
export async function runConvertLogsStderrorAndStdout(convertFn: ConvertFnWithExecFile) {
const originalConsoleError = console.error;
const originalConsoleLog = console.log;
let loggedError = "";
let loggedMessage = "";
console.error = (msg) => {
loggedError = msg;
};
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "Fake stderr");
};
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
console.error = originalConsoleError;
console.log = originalConsoleLog;
expect(loggedError).toBe("stderr: Fake stderr");
expect(loggedMessage).toBe("stdout: Fake stdout");
}

View File

@@ -1,165 +0,0 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { convert } from "../../src/converters/imagemagick";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
runCommonTests(convert);
test("convert respects ico conversion target type", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.obj", "eps", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"input.obj",
"output.ico",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects ico conversion target type with svg as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.svg", "svg", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"-density",
"512",
"input.svg",
"output.ico",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects ico conversion target type with emf as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.emf", "emf", "ico", "output.ico", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"icon:auto-resize=256,128,64,48,32,16",
"-background",
"none",
"emf:delegate=false",
"-density",
"300",
"white",
"-alpha",
"remove",
"input.emf",
"output.ico",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert respects emf as input filetype", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.emf", "emf", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(
expect.arrayContaining([
"-define",
"emf:delegate=false",
"-density",
"300",
"-background",
"white",
"-alpha",
"remove",
"input.emf",
"output.obj",
]),
);
expect(loggedMessage).toBe("stdout: Fake stdout");
});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/inkscape";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/libheif";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,91 +0,0 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { convert } from "../../src/converters/libjxl";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let command: string = "";
beforeEach(() => {
command = "";
});
runCommonTests(convert);
test("convert uses djxl with input filetype being jxl", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(command).toEqual("djxl");
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert uses cjxl with output filetype being jxl", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(command).toEqual("cjxl");
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert uses empty string as command with neither input nor output filetype being jxl", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = _cmd;
callback(null, "Fake stdout", "");
};
const result = await convert("input.png", "png", "jpg", "output.jpg", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(command).toEqual("");
expect(loggedMessage).toBe("stdout: Fake stdout");
});

View File

@@ -1,61 +0,0 @@
import { expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { convert } from "../../src/converters/msgconvert";
import { ExecFileFn } from "../../src/converters/types";
test("convert rejects conversion if input filetype is not msg and output type is not eml", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(null, "Fake stdout", "");
};
const expectedError = new Error(
"Unsupported conversion from obj to stl. Only MSG to EML conversion is currently supported.",
);
expect(convert("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile)).rejects.toEqual(
expectedError,
);
});
test("convert rejects conversion on error", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(new Error("Test error"), "", "");
};
const expectedError = new Error("msgconvert failed: Test error");
expect(convert("input.msg", "msg", "eml", "output.eml", undefined, mockExecFile)).rejects.toEqual(
expectedError,
);
});
test("convert logs stderr as warning", async () => {
const originalConsoleWarn = console.warn;
let loggedMessage = "";
console.warn = (msg) => {
loggedMessage = msg;
};
const mockExecFile = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Fake stderr");
};
await convert("file.msg", "msg", "eml", "out.eml", undefined, mockExecFile);
console.error = originalConsoleWarn;
expect(loggedMessage).toBe("msgconvert stderr: Fake stderr");
});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/potrace";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/resvg";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -1,65 +0,0 @@
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { ExecFileFn } from "../../src/converters/types";
import { convert } from "../../src/converters/vips";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
runCommonTests(convert);
test("convert uses action pdfload with filetype being pdf", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.pdf", "pdf", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["pdfload"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("convert uses action copy with filetype being anything but pdf", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
calls.push(_args);
callback(null, "Fake stdout", "");
};
const result = await convert("input.jpg", "jpg", "obj", "output.obj", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(expect.arrayContaining(["copy"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
});

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test";
import { convert } from "../../src/converters/xelatex";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});

View File

@@ -5,15 +5,15 @@
"target": "ES2021",
"moduleResolution": "bundler",
"moduleDetection": "force",
// "allowImportingTsExtensions": true,
"outDir": "dist",
"noEmit": false,
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"jsx": "react",
"jsxFactory": "Html.createElement",
"jsxFragmentFactory": "Html.Fragment",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
@@ -24,10 +24,7 @@
// "noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"resolveJsonModule": true,
"esModuleInterop": true
"noImplicitOverride": true
// "noImplicitReturns": true
},
"include": ["src", "tests", "package.json"]
}
}