├── .air.toml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── deploy_image.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── pkg ├── files │ ├── document_factory.go │ ├── documents.go │ ├── documents │ │ ├── csv.go │ │ ├── documents.go │ │ ├── documents_test.go │ │ ├── docx.go │ │ ├── pdf.go │ │ ├── testdata │ │ │ ├── bitcoin.pdf │ │ │ ├── file_sample.docx │ │ │ ├── movies.xlsx │ │ │ └── student.csv │ │ └── xlsx.go │ ├── ebooks.go │ ├── ebooks │ │ ├── ebooks.go │ │ ├── ebooks_test.go │ │ ├── epub.go │ │ ├── mobi.go │ │ └── testdata │ │ │ ├── basilleja.mobi │ │ │ └── no-man-s-land.epub │ ├── file_factory.go │ ├── file_factory_test.go │ ├── files.go │ ├── image_factory.go │ ├── images.go │ ├── images │ │ ├── avif.go │ │ ├── bmp.go │ │ ├── gif.go │ │ ├── images.go │ │ ├── images_test.go │ │ ├── jpeg.go │ │ ├── png.go │ │ ├── testdata │ │ │ ├── Golang_Gopher.jpg │ │ │ ├── dancing-gopher.gif │ │ │ ├── fox.avif │ │ │ ├── gopher.webp │ │ │ ├── gopher_pirate.png │ │ │ └── sunset.bmp │ │ ├── tiff.go │ │ └── webp.go │ ├── mimetypes.go │ └── mimetypes_test.go └── util │ └── util.go ├── screenshots ├── download_file_morphos.png ├── file_converted_morphos.png ├── file_uploaded_morphos.png ├── modal_morphos.png ├── morphos.jpg ├── morphos.png ├── select_options_morphos.png └── upload_file_morphos.png ├── static ├── bootstrap.min.css ├── bootstrap.min.js ├── htmx.min.js ├── response-targets.js └── zip-icon.png └── templates ├── base.tmpl └── partials ├── active_modal.tmpl ├── card_file.tmpl ├── error.tmpl ├── form.tmpl ├── htmx.tmpl ├── js.tmpl ├── modal.tmpl ├── nav.tmpl └── style.tmpl /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [screen] 45 | clear_on_rebuild = false 46 | keep_scroll = true 47 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | LICENSE 3 | README.md 4 | tmp/ 5 | .dockerignore 6 | .gitignore 7 | docker-compose.yml 8 | Dockerfile 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug to help us fix it 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request Template 2 | 3 | ## Description 4 | 5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 6 | 7 | Fixes # (issue) 8 | 9 | ## Type of change 10 | 11 | Please delete options that are not relevant. 12 | 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | - [ ] This change requires a documentation update 17 | 18 | ## How Has This Been Tested? 19 | 20 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 21 | 22 | ## Checklist: 23 | 24 | - [ ] My code follows the style guidelines of this project 25 | - [ ] I have performed a self-review of my own code 26 | - [ ] I have commented my code, particularly in hard-to-understand areas 27 | - [ ] I have made corresponding changes to the documentation 28 | - [ ] I have added tests that prove my fix is effective or that my feature works 29 | - [ ] New and existing unit tests pass locally with my changes 30 | - [ ] I have checked my code and corrected any misspellings 31 | -------------------------------------------------------------------------------- /.github/workflows/deploy_image.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Morphos Server Container Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' # Only build on tag with semantic versioning format 7 | 8 | jobs: 9 | push_morphos_image: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | packages: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | # Script for setting the version without the leading 'v' 20 | - name: Set Versions 21 | uses: actions/github-script@v7 22 | id: set_version 23 | with: 24 | script: | 25 | // Get the tag 26 | const tag = context.ref.substring(10) 27 | // Replace the tag with one without v 28 | const no_v = tag.replace('v', '') 29 | // Looks for a dash 30 | const dash_index = no_v.lastIndexOf('-') 31 | // If any, removes it, otherwise return the value unchanged 32 | const no_dash = (dash_index > -1) ? no_v.substring(0, dash_index) : no_v 33 | // Set the tag, no-v and no-dash as output variables 34 | core.setOutput('tag', tag) 35 | core.setOutput('no-v', no_v) 36 | core.setOutput('no-dash', no_dash) 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v3 39 | - name: Login to GitHub Container Registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Build and push 46 | uses: docker/build-push-action@v5 47 | with: 48 | context: . 49 | platforms: linux/amd64,linux/arm64 50 | push: true 51 | tags: ghcr.io/${{ github.actor }}/morphos-server:latest, ghcr.io/${{ github.actor }}/morphos-server:${{steps.set_version.outputs.no-dash}} 52 | - name: Release 53 | uses: softprops/action-gh-release@v1 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Go 11 | uses: actions/setup-go@v4 12 | with: 13 | go-version: "1.23" 14 | - name: Install linux dependencies 15 | run: | 16 | sudo apt-get update && sudo apt-get upgrade 17 | sudo apt-get -y install libreoffice calibre 18 | - name: Setup ffmpeg 19 | uses: FedericoCarboni/setup-ffmpeg@v3 20 | id: setup-ffmpeg 21 | with: 22 | ffmpeg-version: 6.1.0 23 | github-token: ${{ github.server_url == 'https://github.com' && github.token || '' }} 24 | - name: Install Go dependencies 25 | run: go get . 26 | - name: Test 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Working directory 18 | # . or absolute path, please note that the directories following must be under root 19 | tmp/ 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the application from source 2 | FROM golang:1.23 AS builder 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | 7 | WORKDIR /app 8 | 9 | RUN apt-get update \ 10 | && apt-get install -y --no-install-recommends fonts-recommended \ 11 | && apt-get autoremove -y \ 12 | && apt-get purge -y --auto-remove \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | COPY go.* ./ 16 | RUN go mod download 17 | 18 | COPY . . 19 | 20 | RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o morphos . 21 | 22 | # Deploy the application binary into a lean image 23 | FROM debian:trixie-slim AS release 24 | 25 | WORKDIR / 26 | 27 | RUN apt-get update \ 28 | && apt-get install -y --no-install-recommends default-jre libreoffice libreoffice-java-common ffmpeg calibre \ 29 | && apt-get autoremove -y \ 30 | && apt-get purge -y --auto-remove \ 31 | && rm -rf /var/lib/apt/lists/* 32 | 33 | COPY --from=builder /app/morphos /bin/morphos 34 | COPY --from=builder /usr/share/fonts /usr/share/fonts 35 | 36 | ENV FONTCONFIG_PATH /usr/share/fonts 37 | 38 | # Use morphos as user 39 | RUN useradd -m morphos 40 | USER morphos 41 | 42 | EXPOSE 8080 43 | 44 | ENTRYPOINT ["/bin/morphos"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Daniel Omar Vergara Pérez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | HTMX_VERSION=1.9.6 2 | RESPONSE_TARGETS_VERSION=1.9.11 3 | BOOTSTRAP_VERSION=5.3.2 4 | 5 | .PHONY: run 6 | ## run: Runs the air command. 7 | run: 8 | MORPHOS_PORT=8080 air -c .air.toml 9 | 10 | .PHONY: download-htmx 11 | ## download-htmx: Downloads HTMX minified js file 12 | download-htmx: 13 | curl -o static/htmx.min.js https://unpkg.com/htmx.org@${HTMX_VERSION}/dist/htmx.min.js 14 | 15 | .PHONY: download-htmx-resp-targ 16 | ## download-htmx-resp-targ: Downloads the HTMX response target extension 17 | download-htmx-resp-targ: 18 | curl -o static/response-targets.js https://unpkg.com/htmx.org@${RESPONSE_TARGETS_VERSION}/dist/ext/response-targets.js 19 | 20 | .PHONY: download-bootstrap 21 | ## download-bootstrap: Downloads Bootstrap minified css/js file 22 | download-bootstrap: 23 | curl -o static/bootstrap.min.css https://cdn.jsdelivr.net/npm/bootstrap@${BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css 24 | curl -o static/bootstrap.min.js https://cdn.jsdelivr.net/npm/bootstrap@${BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js 25 | 26 | .PHONY: docker-build 27 | ## docker-build: Builds the container image 28 | docker-build: 29 | docker build -t morphos . 30 | 31 | .PHONY: docker-run 32 | ## docker-run: Runs the container 33 | docker-run: docker-build 34 | docker run --rm -p 8080:8080 -v /tmp:/tmp morphos 35 | 36 | .PHONY: help 37 | ## help: Prints this help message 38 | help: 39 | @echo "Usage:" 40 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Morphos Server 2 | =============== 3 | 4 | ![tests](https://github.com/danvergara/morphos/actions/workflows/test.yml/badge.svg) 5 | [![Release](https://img.shields.io/github/release/danvergara/morphos.svg?label=Release)](https://github.com/danvergara/morphos/releases) 6 | 7 |

8 | morphos logo 9 |

10 | 11 | __Self-Hosted file converter server.__ 12 | 13 | ## Table of contents 14 | 15 | - [Overview](#overview) 16 | - [Installation](#installation) 17 | - [Docker](#docker) 18 | - [Features](#features) 19 | - [Usage](#usage) 20 | - [Supported Files](#supported-files-and-convert-matrix) 21 | - [Images To Images](#images-x-images) 22 | - [Images To Documents](#images-x-documents) 23 | - [Documents To Images](#documents-x-images) 24 | - [License](#license) 25 | 26 | ## Overview 27 | 28 | Today we are forced to rely on third party services to convert files to other formats. This is a serious threat to our privacy, if we use such services to convert files with highly sensitive personal data. It can be used against us, sooner or later. 29 | Morphos server aims to solve the problem mentioned above, by providing a self-hosted server to convert files privately. The project provides an user-friendly web UI. 30 | For now, Morphos only supports images. Documents will be added soon. 31 | 32 | ## Installation 33 | 34 | ### Docker 35 | 36 | ``` 37 | docker run --rm -p 8080:8080 -v /tmp:/tmp ghcr.io/danvergara/morphos-server:latest 38 | ``` 39 | 40 | ## Features 41 | 42 | - Serves a nice web UI 43 | - Simple installation (distributed as a Docker image) 44 | 45 | ## Usage 46 | 47 | ### HTML form 48 | 49 | Run the server as mentioned above and open up your favorite browser. You'll see something like this: 50 | 51 | 52 | 53 | Hit the file input section on the form to upload an image. 54 | 55 | 56 | 57 | You'll see the filed uploaded in the form. 58 | 59 | 60 | 61 | Then, you can select from a variety of other formats you can convert the current image to. 62 | 63 | 64 | 65 | After hitting `Upload` button you will see a view like the one below, asking you to download the converted file. 66 | 67 | 68 | 69 | A modal will pop up with a preview of the converted image. 70 | 71 | 72 | 73 | ### API 74 | 75 | You can consume morphos through an API, so other systems can integrate with it. 76 | 77 | ##### Endpoints 78 | 79 | `GET /api/v1/formats` 80 | 81 | This returns a JSON that shows the supported formats at the moment. 82 | 83 | e.g. 84 | 85 | ``` 86 | {"documents": ["docx", "xls"], "image": ["png", "jpeg"]} 87 | ``` 88 | 89 | `POST /api/v1/upload` 90 | 91 | This is the endpoint that converts files to a desired format. It is basically a multipart form data in a POST request. The API simply writes the converted files to the response body. 92 | 93 | e.g. 94 | 95 | ``` 96 | curl -F 'targetFormat=epub' -F 'uploadFile=@/path/to/file/foo.pdf' localhost:8080/api/v1/upload --output foo.epub 97 | ``` 98 | The form fields are: 99 | 100 | * targetFormat: the target format the file will be converted to 101 | * uploadFile: The path to the file that is going to be converted 102 | 103 | ### Configuration 104 | 105 | The configuration is only done by the environment varibles shown below. 106 | 107 | * `MORPHOS_PORT` changes the port the server will listen to (default is `8080`) 108 | * `MORPHOS_UPLOAD_PATH` defines the temporary path the files will be stored on disk (default is `/tmp`) 109 | 110 | ## Supported Files And Convert Matrix 111 | 112 | ### Images X Images 113 | 114 | | | PNG | JPEG | GIF | WEBP | TIFF | BMP | AVIF | 115 | |-------|-------|--------|-------|--------|--------|-------|--------| 116 | | PNG | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 117 | | JPEG | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | 118 | | GIF | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | 119 | | WEBP | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | 120 | | TIFF | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | 121 | | BMP | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | 122 | | AVIF | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 123 | 124 | ### Images X Documents 125 | 126 | | | PDF | 127 | |-------|-------| 128 | | PNG | ✅ | 129 | | JPEG | ✅ | 130 | | GIF | ✅ | 131 | | WEBP | ✅ | 132 | | TIFF | ✅ | 133 | | BMP | ✅ | 134 | | AVIF | | 135 | 136 | ## Documents X Images 137 | 138 | | | PNG | JPEG | GIF | WEBP | TIFF | BMP | AVIF | 139 | | --- | --- | ---- | --- | ---- | ---- | --- | ---- | 140 | | PDF | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 141 | 142 | ## Documents X Documents 143 | 144 | | | DOCX | PDF | XLSX | CSV | 145 | | ---- | ---- | --- | ---- | --- | 146 | | PDF | ✅ | | | | 147 | | DOCX | | ✅ | | | 148 | | CSV | | | ✅ | | 149 | | XLSX | | | | ✅ | 150 | 151 | ## Ebooks X Ebooks 152 | 153 | | | MOBI | EPUB | 154 | | ---- | ---- | --- | 155 | | EPUB | ✅ | | 156 | | MOBI | | ✅ | 157 | 158 | 159 | ## Documents X Ebooks 160 | 161 | | | EPUB | MOBI | 162 | | ---- | ---- | --- | 163 | | PDF | ✅ | ✅ | 164 | | DOCX | | | 165 | | CSV | | | 166 | | XLSX | | | 167 | 168 | ## Ebooks X Documents 169 | 170 | | | PDF | 171 | | ---- | ---- | 172 | | EPUB | ✅ | 173 | | MOBI | ✅ | 174 | 175 | ## License 176 | The MIT License (MIT). See [LICENSE](LICENSE) file for more details. 177 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: morphos 2 | services: 3 | morphos-server: 4 | image: ghcr.io/danvergara/morphos-server:latest 5 | # uncomment this if you want to build the container yourself. 6 | # build: 7 | # context: . 8 | # target: release 9 | ports: 10 | - 8080:8080 11 | volumes: 12 | - /tmp:/tmp 13 | healthcheck: 14 | test: timeout 10s bash -c ':> /dev/tcp/127.0.0.1/8080' || exit 1 15 | interval: 60s 16 | retries: 3 17 | start_period: 20s 18 | timeout: 30s 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/danvergara/morphos 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/chai2010/webp v1.1.1 7 | github.com/gabriel-vasile/mimetype v1.4.3 8 | github.com/gen2brain/go-fitz v1.23.7 9 | github.com/go-chi/chi/v5 v5.0.10 10 | github.com/signintech/gopdf v0.20.0 11 | github.com/stretchr/testify v1.8.4 12 | github.com/tealeg/xlsx/v3 v3.3.6 13 | github.com/u2takey/ffmpeg-go v0.5.0 14 | golang.org/x/image v0.14.0 15 | golang.org/x/text v0.14.0 16 | ) 17 | 18 | require ( 19 | github.com/aws/aws-sdk-go v1.38.20 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/frankban/quicktest v1.14.6 // indirect 22 | github.com/google/btree v1.0.0 // indirect 23 | github.com/google/go-cmp v0.5.9 // indirect 24 | github.com/jmespath/go-jmespath v0.4.0 // indirect 25 | github.com/kr/pretty v0.3.1 // indirect 26 | github.com/kr/text v0.2.0 // indirect 27 | github.com/peterbourgon/diskv/v3 v3.0.1 // indirect 28 | github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect 29 | github.com/pkg/errors v0.9.1 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rogpeppe/fastuuid v1.2.0 // indirect 32 | github.com/rogpeppe/go-internal v1.9.0 // indirect 33 | github.com/shabbyrobe/xmlwriter v0.0.0-20200208144257-9fca06d00ffa // indirect 34 | github.com/u2takey/go-utils v0.3.1 // indirect 35 | golang.org/x/net v0.18.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= 2 | github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 3 | github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= 4 | github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 10 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 11 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 12 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 14 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 15 | github.com/gen2brain/go-fitz v1.23.7 h1:HPhzEVzmOINvCKqQgB/DwMzYh4ArIgy3tMwq1eJTcbg= 16 | github.com/gen2brain/go-fitz v1.23.7/go.mod h1:HU04vc+RisUh/kvEd2pB0LAxmK1oyXdN4ftyshUr9rQ= 17 | github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= 18 | github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 19 | github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= 20 | github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 21 | github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= 22 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 23 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 24 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 25 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 26 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 27 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 28 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 29 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 30 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 31 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 32 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 33 | github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 34 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 35 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 36 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 37 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 38 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 39 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 41 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 42 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 43 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 44 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 45 | github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= 46 | github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= 47 | github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= 48 | github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no= 49 | github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= 50 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 51 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 52 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 53 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pkg/profile v1.5.0 h1:042Buzk+NhDI+DeSAA62RwJL8VAuZUMQZUjCsRz1Mug= 55 | github.com/pkg/profile v1.5.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= 59 | github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= 60 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 61 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 62 | github.com/shabbyrobe/xmlwriter v0.0.0-20200208144257-9fca06d00ffa h1:2cO3RojjYl3hVTbEvJVqrMaFmORhL6O06qdW42toftk= 63 | github.com/shabbyrobe/xmlwriter v0.0.0-20200208144257-9fca06d00ffa/go.mod h1:Yjr3bdWaVWyME1kha7X0jsz3k2DgXNa1Pj3XGyUAbx8= 64 | github.com/signintech/gopdf v0.20.0 h1:a1rArIMmQCAFzjjCqXPgxynTPkytMccPuGZlUU8Jorw= 65 | github.com/signintech/gopdf v0.20.0/go.mod h1:wrLtZoWaRNrS4hphED0oflFoa6IWkOu6M3nJjm4VbO4= 66 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 69 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 70 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 71 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 72 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 73 | github.com/tealeg/xlsx/v3 v3.3.6 h1:b0SPORnNa8BDbFEujljp2IpTDVse3D+Ad5IaMz7KUL8= 74 | github.com/tealeg/xlsx/v3 v3.3.6/go.mod h1:KV4FTFtvGy0TBlOivJLZu/YNZk6e0Qtk7eOSglWksuA= 75 | github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= 76 | github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= 77 | github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= 78 | github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= 79 | gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= 80 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 81 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 82 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 83 | golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= 84 | golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 85 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 86 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 87 | golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= 88 | golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= 89 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 90 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 91 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 92 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 94 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 95 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 96 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 97 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 98 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 99 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 100 | golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 101 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 102 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= 103 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 105 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 106 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 107 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 108 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 109 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= 111 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "embed" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "html/template" 11 | "io" 12 | "log" 13 | "net/http" 14 | "os" 15 | "os/signal" 16 | "path/filepath" 17 | "strings" 18 | "sync" 19 | "syscall" 20 | "time" 21 | 22 | "github.com/gabriel-vasile/mimetype" 23 | "github.com/go-chi/chi/v5" 24 | "github.com/go-chi/chi/v5/middleware" 25 | "golang.org/x/text/cases" 26 | "golang.org/x/text/language" 27 | 28 | "github.com/danvergara/morphos/pkg/files" 29 | ) 30 | 31 | const ( 32 | uploadFileFormField = "uploadFile" 33 | ) 34 | 35 | var ( 36 | //go:embed all:templates 37 | templatesHTML embed.FS 38 | 39 | //go:embed all:static 40 | staticFiles embed.FS 41 | // Upload path. 42 | // It is a variable now, which means that can be 43 | // cofigurable through a environment variable. 44 | uploadPath string 45 | ) 46 | 47 | func init() { 48 | uploadPath = os.Getenv("MORPHOS_UPLOAD_PATH") 49 | if uploadPath == "" { 50 | uploadPath = "/tmp" 51 | } 52 | } 53 | 54 | // statusError struct is the error representation 55 | // at the HTTP layer. 56 | type statusError struct { 57 | error 58 | status int 59 | } 60 | 61 | // Unwrap method returns the inner error. 62 | func (e statusError) Unwrap() error { return e.error } 63 | 64 | // HTTPStatus returns a HTTP status code. 65 | func HTTPStatus(err error) int { 66 | if err == nil { 67 | return 0 68 | } 69 | 70 | var statusErr interface { 71 | error 72 | HTTPStatus() int 73 | } 74 | 75 | // Checks if err implements the statusErr interface. 76 | if errors.As(err, &statusErr) { 77 | return statusErr.HTTPStatus() 78 | } 79 | 80 | // Returns a default status code if none is provided. 81 | return http.StatusInternalServerError 82 | } 83 | 84 | // WithHTTPStatus returns an error with the original error and the status code. 85 | func WithHTTPStatus(err error, status int) error { 86 | return statusError{ 87 | error: err, 88 | status: status, 89 | } 90 | } 91 | 92 | // toHandler is a wrapper for functions that have the following signature: 93 | // func(http.ResponseWriter, *http.Request) error 94 | // So, regular handlers can return an error that can be unwrapped. 95 | // If an errors is received at the time to execute the original handler, 96 | // renderError function is called. 97 | func toHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { 98 | return func(w http.ResponseWriter, r *http.Request) { 99 | err := f(w, r) 100 | if err != nil { 101 | renderError(w, err.Error(), HTTPStatus(err)) 102 | } 103 | } 104 | } 105 | 106 | type ConvertedFile struct { 107 | Filename string 108 | FileType string 109 | } 110 | 111 | func index(w http.ResponseWriter, _ *http.Request) error { 112 | tmpls := []string{ 113 | "templates/base.tmpl", 114 | "templates/partials/htmx.tmpl", 115 | "templates/partials/style.tmpl", 116 | "templates/partials/nav.tmpl", 117 | "templates/partials/form.tmpl", 118 | "templates/partials/modal.tmpl", 119 | "templates/partials/js.tmpl", 120 | } 121 | 122 | tmpl, err := template.ParseFS(templatesHTML, tmpls...) 123 | if err != nil { 124 | log.Printf("error ocurred parsing templates: %v", err) 125 | return WithHTTPStatus(err, http.StatusInternalServerError) 126 | } 127 | 128 | err = tmpl.ExecuteTemplate(w, "base", nil) 129 | if err != nil { 130 | log.Printf("error ocurred executing template: %v", err) 131 | return WithHTTPStatus(err, http.StatusInternalServerError) 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func handleUploadFile(w http.ResponseWriter, r *http.Request) error { 138 | convertedFileName, convertedFileType, _, err := convertFile(r) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | tmpls := []string{ 144 | "templates/partials/card_file.tmpl", 145 | "templates/partials/modal.tmpl", 146 | } 147 | 148 | tmpl, err := template.ParseFS(templatesHTML, tmpls...) 149 | if err != nil { 150 | log.Printf("error occurred parsing template files: %v", err) 151 | return WithHTTPStatus(err, http.StatusInternalServerError) 152 | } 153 | 154 | err = tmpl.ExecuteTemplate( 155 | w, 156 | "content", 157 | ConvertedFile{Filename: convertedFileName, FileType: convertedFileType}, 158 | ) 159 | if err != nil { 160 | log.Printf("error occurred executing template files: %v", err) 161 | return WithHTTPStatus(err, http.StatusInternalServerError) 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func handleFileFormat(w http.ResponseWriter, r *http.Request) error { 168 | file, _, err := r.FormFile(uploadFileFormField) 169 | if err != nil { 170 | log.Printf("error ocurred while getting file from form: %v", err) 171 | return WithHTTPStatus(err, http.StatusBadRequest) 172 | } 173 | 174 | fileBytes, err := io.ReadAll(file) 175 | if err != nil { 176 | log.Printf("error occurred while executing template files: %v", err) 177 | return WithHTTPStatus(err, http.StatusBadRequest) 178 | } 179 | 180 | detectedFileType := mimetype.Detect(fileBytes) 181 | 182 | templates := []string{ 183 | "templates/partials/form.tmpl", 184 | } 185 | 186 | fileType, subType, err := files.TypeAndSupType(detectedFileType.String()) 187 | if err != nil { 188 | log.Printf("error occurred getting type and subtype from mimetype: %v", err) 189 | return WithHTTPStatus(err, http.StatusBadRequest) 190 | } 191 | 192 | fileFactory, err := files.BuildFactory(fileType, "") 193 | if err != nil { 194 | log.Printf("error occurred while getting a file factory: %v", err) 195 | return WithHTTPStatus(err, http.StatusBadRequest) 196 | } 197 | 198 | f, err := fileFactory.NewFile(subType) 199 | if err != nil { 200 | log.Printf("error occurred getting the file object: %v", err) 201 | return WithHTTPStatus(err, http.StatusInternalServerError) 202 | } 203 | 204 | tmpl, err := template.ParseFS(templatesHTML, templates...) 205 | if err != nil { 206 | log.Printf("error occurred parsing template files: %v", err) 207 | return WithHTTPStatus(err, http.StatusInternalServerError) 208 | } 209 | 210 | if err = tmpl.ExecuteTemplate(w, "format-elements", f.SupportedFormats()); err != nil { 211 | log.Printf("error occurred executing template files: %v", err) 212 | return WithHTTPStatus(err, http.StatusInternalServerError) 213 | } 214 | 215 | return nil 216 | } 217 | 218 | func handleModal(w http.ResponseWriter, r *http.Request) error { 219 | filename := r.URL.Query().Get("filename") 220 | filetype := r.URL.Query().Get("filetype") 221 | 222 | tmpls := []string{ 223 | "templates/partials/active_modal.tmpl", 224 | } 225 | 226 | tmpl, err := template.ParseFS(templatesHTML, tmpls...) 227 | if err != nil { 228 | log.Printf("error occurred parsing template files: %v", err) 229 | return WithHTTPStatus(err, http.StatusInternalServerError) 230 | } 231 | 232 | if err = tmpl.ExecuteTemplate(w, "content", ConvertedFile{Filename: filename, FileType: filetype}); err != nil { 233 | log.Printf("error occurred executing template files: %v", err) 234 | return WithHTTPStatus(err, http.StatusInternalServerError) 235 | } 236 | 237 | return nil 238 | } 239 | 240 | func getFormats(w http.ResponseWriter, r *http.Request) error { 241 | resp, err := json.Marshal(supportedFormatsJSONResponse()) 242 | if err != nil { 243 | log.Printf("error ocurred marshalling the response: %v", err) 244 | return WithHTTPStatus(err, http.StatusInternalServerError) 245 | } 246 | 247 | w.Header().Set("Content-Type", "application/json") 248 | if _, err := w.Write(resp); err != nil { 249 | log.Printf("error ocurred writting to the ResponseWriter : %v", err) 250 | return WithHTTPStatus(err, http.StatusInternalServerError) 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func uploadFile(w http.ResponseWriter, r *http.Request) error { 257 | _, _, convertedFileBytes, err := convertFile(r) 258 | if err != nil { 259 | return err 260 | } 261 | 262 | w.WriteHeader(http.StatusOK) 263 | w.Header().Set("Content-Type", "application/octet-stream") 264 | if _, err := w.Write(convertedFileBytes); err != nil { 265 | log.Printf("error occurred writing converted file to response writer: %v", err) 266 | return WithHTTPStatus(err, http.StatusInternalServerError) 267 | } 268 | 269 | return nil 270 | } 271 | 272 | func newRouter() http.Handler { 273 | r := chi.NewRouter() 274 | r.Use(middleware.Logger) 275 | 276 | fsUpload := http.FileServer(http.Dir(uploadPath)) 277 | 278 | var staticFS = http.FS(staticFiles) 279 | fs := http.FileServer(staticFS) 280 | 281 | addRoutes(r, fs, fsUpload) 282 | 283 | return r 284 | } 285 | 286 | func apiRouter() http.Handler { 287 | r := chi.NewRouter() 288 | r.Get("/formats", toHandler(getFormats)) 289 | r.Post("/upload", toHandler(uploadFile)) 290 | return r 291 | } 292 | 293 | func addRoutes(r *chi.Mux, fs, fsUpload http.Handler) { 294 | r.HandleFunc("/healthz", healthz) 295 | r.Handle("/static/*", fs) 296 | r.Handle("/files/*", http.StripPrefix("/files", fsUpload)) 297 | r.Get("/", toHandler(index)) 298 | r.Post("/upload", toHandler(handleUploadFile)) 299 | r.Post("/format", toHandler(handleFileFormat)) 300 | r.Get("/modal", toHandler(handleModal)) 301 | 302 | // Mount the api router. 303 | r.Mount("/api/v1", apiRouter()) 304 | } 305 | 306 | func run(ctx context.Context) error { 307 | port := os.Getenv("MORPHOS_PORT") 308 | 309 | // default port. 310 | if port == "" { 311 | port = "8080" 312 | } 313 | 314 | ctx, stop := signal.NotifyContext(ctx, 315 | os.Interrupt, 316 | syscall.SIGTERM, 317 | syscall.SIGQUIT) 318 | defer stop() 319 | 320 | r := newRouter() 321 | 322 | srv := &http.Server{ 323 | Addr: fmt.Sprintf(":%s", port), 324 | Handler: r, 325 | } 326 | 327 | go func() { 328 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 329 | fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err) 330 | } 331 | }() 332 | 333 | var wg sync.WaitGroup 334 | wg.Add(1) 335 | 336 | go func() { 337 | defer wg.Done() 338 | <-ctx.Done() 339 | 340 | log.Println("shutdown signal received") 341 | 342 | ctxTimeout, cancel := context.WithTimeout(context.Background(), 10*time.Second) 343 | defer cancel() 344 | 345 | srv.SetKeepAlivesEnabled(false) 346 | 347 | if err := srv.Shutdown(ctxTimeout); err != nil { 348 | fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err) 349 | } 350 | 351 | log.Println("shutdown completed") 352 | }() 353 | 354 | wg.Wait() 355 | 356 | return nil 357 | } 358 | 359 | func main() { 360 | ctx := context.Background() 361 | 362 | if err := run(ctx); err != nil { 363 | fmt.Fprintf(os.Stderr, "%s\n", err) 364 | os.Exit(1) 365 | } 366 | 367 | log.Println("exiting...") 368 | } 369 | 370 | // renderError functions executes the error template. 371 | func renderError(w http.ResponseWriter, message string, statusCode int) { 372 | w.WriteHeader(statusCode) 373 | tmpl, _ := template.ParseFS(templatesHTML, "templates/partials/error.tmpl") 374 | _ = tmpl.ExecuteTemplate(w, "error", struct{ ErrorMessage string }{ErrorMessage: message}) 375 | } 376 | 377 | func fileNameWithoutExtension(fileName string) string { 378 | return strings.TrimSuffix(filepath.Base(fileName), filepath.Ext(fileName)) 379 | } 380 | 381 | func filename(filename, extension string) string { 382 | return fmt.Sprintf("%s.%s", fileNameWithoutExtension(filename), extension) 383 | } 384 | 385 | func healthz(w http.ResponseWriter, r *http.Request) { 386 | w.WriteHeader(http.StatusOK) 387 | } 388 | 389 | // convertFile handles everything required to convert a file. 390 | // It returns the name of the file, the target file type, the file as a slice of bytes and a possible error. 391 | // It is both used by the HTML form and the API. 392 | func convertFile(r *http.Request) (string, string, []byte, error) { 393 | var ( 394 | convertedFile io.Reader 395 | convertedFilePath string 396 | convertedFileName string 397 | err error 398 | ) 399 | 400 | // Parse and validate file and post parameters. 401 | file, fileHeader, err := r.FormFile(uploadFileFormField) 402 | if err != nil { 403 | log.Printf("error ocurred getting file from form: %v", err) 404 | return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) 405 | } 406 | defer file.Close() 407 | 408 | // Get the content of the file in form of a slice of bytes. 409 | fileBytes, err := io.ReadAll(file) 410 | if err != nil { 411 | log.Printf("error ocurred reading file: %v", err) 412 | return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) 413 | } 414 | 415 | // Get the sub-type of the input file from the form. 416 | targetFileSubType := r.FormValue("targetFormat") 417 | 418 | // Call Detect fuction to get the mimetype of the input file. 419 | detectedFileType := mimetype.Detect(fileBytes) 420 | 421 | // Parse the mimetype to get the type and the sub-type of the input file. 422 | fileType, subType, err := files.TypeAndSupType(detectedFileType.String()) 423 | if err != nil { 424 | log.Printf("error occurred getting type and subtype from mimetype: %v", err) 425 | return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) 426 | } 427 | 428 | // Get the right factory based off the input file type. 429 | fileFactory, err := files.BuildFactory(fileType, fileHeader.Filename) 430 | if err != nil { 431 | log.Printf("error occurred while getting a file factory: %v", err) 432 | return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) 433 | } 434 | 435 | // Returns an object that implements the File interface based on the sub-type of the input file. 436 | f, err := fileFactory.NewFile(subType) 437 | if err != nil { 438 | log.Printf("error occurred getting the file object: %v", err) 439 | return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) 440 | } 441 | 442 | // Return the kind of the output file. 443 | targetFileType := files.SupportedFileTypes()[targetFileSubType] 444 | 445 | // Convert the file to the target format. 446 | // convertedFile is an io.Reader. 447 | convertedFile, err = f.ConvertTo( 448 | cases.Title(language.English).String(targetFileType), 449 | targetFileSubType, 450 | bytes.NewReader(fileBytes), 451 | ) 452 | if err != nil { 453 | log.Printf("error ocurred while processing the input file: %v", err) 454 | return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) 455 | } 456 | 457 | switch fileType { 458 | case "application", "text": 459 | targetFileSubType = "zip" 460 | } 461 | 462 | convertedFileName = filename(fileHeader.Filename, targetFileSubType) 463 | convertedFilePath = filepath.Join(uploadPath, convertedFileName) 464 | 465 | newFile, err := os.Create(convertedFilePath) 466 | if err != nil { 467 | log.Printf("error occurred while creating the output file: %v", err) 468 | return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) 469 | } 470 | defer newFile.Close() 471 | 472 | buf := new(bytes.Buffer) 473 | if _, err := buf.ReadFrom(convertedFile); err != nil { 474 | log.Printf("error occurred while readinf from the converted file: %v", err) 475 | return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) 476 | } 477 | 478 | convertedFileBytes := buf.Bytes() 479 | if _, err := newFile.Write(convertedFileBytes); err != nil { 480 | log.Printf("error occurred writing converted output to a file in disk: %v", err) 481 | return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) 482 | } 483 | 484 | convertedFileMimeType := mimetype.Detect(convertedFileBytes) 485 | 486 | convertedFileType, _, err := files.TypeAndSupType(convertedFileMimeType.String()) 487 | if err != nil { 488 | log.Printf("error occurred getting the file type of the result file: %v", err) 489 | return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) 490 | } 491 | 492 | return convertedFileName, convertedFileType, convertedFileBytes, nil 493 | } 494 | 495 | // supportedFormatsJSONResponse returns the supported formas as a map formatted to be shown as JSON. 496 | // The intention of this is showing the supported formats to the client. 497 | // Example: 498 | // {"documents": ["docx", "xls"], "image": ["png", "jpeg"]} 499 | func supportedFormatsJSONResponse() map[string][]string { 500 | result := make(map[string][]string) 501 | 502 | for k, v := range files.SupportedFileTypes() { 503 | result[v] = append(result[v], k) 504 | } 505 | 506 | return result 507 | } 508 | -------------------------------------------------------------------------------- /pkg/files/document_factory.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/danvergara/morphos/pkg/files/documents" 7 | "github.com/danvergara/morphos/pkg/files/ebooks" 8 | ) 9 | 10 | // DocumentFactory implements the FileFactory interface. 11 | type DocumentFactory struct { 12 | filename string 13 | } 14 | 15 | func NewDocumentFactory(filename string) *DocumentFactory { 16 | return &DocumentFactory{filename: filename} 17 | } 18 | 19 | // NewFile method returns an object that implements the File interface, 20 | // given a document format as input. 21 | // If not supported, it will error out. 22 | func (d *DocumentFactory) NewFile(f string) (File, error) { 23 | switch f { 24 | case documents.PDF: 25 | return documents.NewPdf(d.filename), nil 26 | case documents.DOCX, documents.DOCXMIMEType: 27 | return documents.NewDocx(d.filename), nil 28 | case documents.XLSX, documents.XLSXMIMEType: 29 | return documents.NewXlsx(d.filename), nil 30 | case documents.CSV: 31 | return documents.NewCsv(d.filename), nil 32 | case ebooks.EpubMimeType, ebooks.EPUB: 33 | return ebooks.NewEpub(d.filename), nil 34 | case ebooks.MobiMimeType, ebooks.MOBI: 35 | return ebooks.NewMobi(d.filename), nil 36 | default: 37 | return nil, fmt.Errorf("type file %s not recognized", f) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/files/documents.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | // Document interface is the one that defines what a document is 4 | // in this context. It's responsible to return kind of the underlying document. 5 | type Document interface { 6 | DocumentType() string 7 | } 8 | -------------------------------------------------------------------------------- /pkg/files/documents/csv.go: -------------------------------------------------------------------------------- 1 | package documents 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/csv" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | 15 | "github.com/tealeg/xlsx/v3" 16 | ) 17 | 18 | // Csv struct implements the File and Document interface from the file package. 19 | type Csv struct { 20 | filename string 21 | compatibleFormats map[string][]string 22 | compatibleMIMETypes map[string][]string 23 | } 24 | 25 | // NewCsv returns a pointer to Csv. 26 | func NewCsv(filename string) *Csv { 27 | c := Csv{ 28 | filename: filename, 29 | compatibleFormats: map[string][]string{ 30 | "Document": { 31 | XLSX, 32 | }, 33 | }, 34 | compatibleMIMETypes: map[string][]string{ 35 | "Document": { 36 | XLSX, 37 | }, 38 | }, 39 | } 40 | 41 | return &c 42 | } 43 | 44 | // SupportedFormats returns a map witht the compatible formats that CSv is 45 | // compatible to be converted to. 46 | func (c *Csv) SupportedFormats() map[string][]string { 47 | return c.compatibleFormats 48 | } 49 | 50 | // SupportedMIMETypes returns a map witht the compatible MIME types that Docx is 51 | // compatible to be converted to. 52 | func (c *Csv) SupportedMIMETypes() map[string][]string { 53 | return c.compatibleMIMETypes 54 | } 55 | 56 | func (c *Csv) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 57 | compatibleFormats, ok := c.SupportedFormats()[fileType] 58 | if !ok { 59 | return nil, fmt.Errorf("file type not supported: %s", fileType) 60 | } 61 | 62 | if !slices.Contains(compatibleFormats, subType) { 63 | return nil, fmt.Errorf("sub-type not supported: %s", subType) 64 | } 65 | 66 | switch strings.ToLower(fileType) { 67 | case documentType: 68 | switch subType { 69 | case XLSX: 70 | xlsxFilename := fmt.Sprintf( 71 | "%s.xlsx", 72 | strings.TrimSuffix(c.filename, filepath.Ext(c.filename)), 73 | ) 74 | 75 | xlsxPath := filepath.Join("/tmp", xlsxFilename) 76 | 77 | // Parses the file name of the Zip file. 78 | zipFileName := filepath.Join("/tmp", fmt.Sprintf( 79 | "%s.zip", 80 | strings.TrimSuffix(c.filename, filepath.Ext(c.filename)), 81 | )) 82 | 83 | reader := csv.NewReader(file) 84 | xlsxFile := xlsx.NewFile() 85 | sheet, err := xlsxFile.AddSheet( 86 | strings.TrimSuffix(c.filename, filepath.Ext(c.filename)), 87 | ) 88 | if err != nil { 89 | return nil, fmt.Errorf("error creating a xlsx sheet %w", err) 90 | } 91 | 92 | for { 93 | fields, err := reader.Read() 94 | if err == io.EOF { 95 | break 96 | } 97 | 98 | row := sheet.AddRow() 99 | for _, field := range fields { 100 | cell := row.AddCell() 101 | cell.Value = field 102 | } 103 | } 104 | 105 | if err := xlsxFile.Save(xlsxPath); err != nil { 106 | return nil, fmt.Errorf( 107 | "error at saving the temporary csv file: %w", 108 | err, 109 | ) 110 | } 111 | 112 | tmpCsvFile, err := os.Open(xlsxPath) 113 | if err != nil { 114 | return nil, fmt.Errorf( 115 | "error at opening the pdf file: %w", 116 | err, 117 | ) 118 | } 119 | defer os.Remove(tmpCsvFile.Name()) 120 | 121 | // Creates the zip file that will be returned. 122 | archive, err := os.Create(zipFileName) 123 | if err != nil { 124 | return nil, fmt.Errorf( 125 | "error at creating the zip file to store the pdf file: %w", 126 | err, 127 | ) 128 | } 129 | defer os.Remove(archive.Name()) 130 | 131 | // Creates a Zip Writer to add files later on. 132 | zipWriter := zip.NewWriter(archive) 133 | 134 | w1, err := zipWriter.Create(xlsxFilename) 135 | if err != nil { 136 | return nil, fmt.Errorf( 137 | "eror at creating a zip file: %w", 138 | err, 139 | ) 140 | } 141 | 142 | if _, err := io.Copy(w1, tmpCsvFile); err != nil { 143 | return nil, fmt.Errorf( 144 | "error at writing the pdf file content to the zip writer: %w", 145 | err, 146 | ) 147 | } 148 | 149 | // Closes both zip writer and the zip file after its done with the writing. 150 | zipWriter.Close() 151 | archive.Close() 152 | 153 | // Reads the zip file as an slice of bytes. 154 | zipFile, err := os.ReadFile(zipFileName) 155 | if err != nil { 156 | return nil, fmt.Errorf("error reading zip file: %v", err) 157 | } 158 | 159 | return bytes.NewReader(zipFile), nil 160 | } 161 | } 162 | 163 | return nil, errors.New("not implemented") 164 | } 165 | 166 | func (c *Csv) DocumentType() string { 167 | return CSV 168 | } 169 | -------------------------------------------------------------------------------- /pkg/files/documents/documents.go: -------------------------------------------------------------------------------- 1 | package documents 2 | 3 | const ( 4 | DOCX = "docx" 5 | DOCXMIMEType = "vnd.openxmlformats-officedocument.wordprocessingml.document" 6 | PDF = "pdf" 7 | CSV = "csv" 8 | XLSX = "xlsx" 9 | XLSXMIMEType = "vnd.openxmlformats-officedocument.spreadsheetml.sheet" 10 | 11 | EpubMimeType = "epub+zip" 12 | EPUB = "epub" 13 | 14 | MOBI = "mobi" 15 | MobiMimeType = "x-mobipocket-ebook" 16 | 17 | imageMimeType = "image/" 18 | imageType = "image" 19 | 20 | documentMimeType = "application/" 21 | tesxtMimeType = "text/" 22 | documentType = "document" 23 | 24 | ebookType = "ebook" 25 | ) 26 | -------------------------------------------------------------------------------- /pkg/files/documents/documents_test.go: -------------------------------------------------------------------------------- 1 | package documents_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/gabriel-vasile/mimetype" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/danvergara/morphos/pkg/files/documents" 13 | ) 14 | 15 | type filer interface { 16 | SupportedFormats() map[string][]string 17 | ConvertTo(string, string, io.Reader) (io.Reader, error) 18 | } 19 | 20 | type documenter interface { 21 | filer 22 | DocumentType() string 23 | } 24 | 25 | func TestPDFTConvertTo(t *testing.T) { 26 | type input struct { 27 | filename string 28 | mimetype string 29 | targetFileType string 30 | targetFormat string 31 | documenter documenter 32 | } 33 | type expected struct { 34 | mimetype string 35 | } 36 | var tests = []struct { 37 | name string 38 | input input 39 | expected expected 40 | }{ 41 | { 42 | name: "pdf to mobi", 43 | input: input{ 44 | filename: "testdata/bitcoin.pdf", 45 | mimetype: "application/pdf", 46 | targetFileType: "Ebook", 47 | targetFormat: "mobi", 48 | documenter: documents.NewPdf("bitcoin.pdf"), 49 | }, 50 | expected: expected{ 51 | mimetype: "application/zip", 52 | }, 53 | }, 54 | { 55 | name: "pdf to epub", 56 | input: input{ 57 | filename: "testdata/bitcoin.pdf", 58 | mimetype: "application/pdf", 59 | targetFileType: "Ebook", 60 | targetFormat: "epub", 61 | documenter: documents.NewPdf("bitcoin.pdf"), 62 | }, 63 | expected: expected{ 64 | mimetype: "application/zip", 65 | }, 66 | }, 67 | { 68 | name: "pdf to jpeg", 69 | input: input{ 70 | filename: "testdata/bitcoin.pdf", 71 | mimetype: "application/pdf", 72 | targetFileType: "Image", 73 | targetFormat: "jpeg", 74 | documenter: documents.NewPdf("bitcoin.pdf"), 75 | }, 76 | expected: expected{ 77 | mimetype: "application/zip", 78 | }, 79 | }, 80 | { 81 | name: "pdf to png", 82 | input: input{ 83 | filename: "testdata/bitcoin.pdf", 84 | mimetype: "application/pdf", 85 | targetFileType: "Image", 86 | targetFormat: "png", 87 | documenter: documents.NewPdf("bitcoin.pdf"), 88 | }, 89 | expected: expected{ 90 | mimetype: "application/zip", 91 | }, 92 | }, 93 | { 94 | name: "pdf to gif", 95 | input: input{ 96 | filename: "testdata/bitcoin.pdf", 97 | mimetype: "application/pdf", 98 | targetFileType: "Image", 99 | targetFormat: "gif", 100 | documenter: documents.NewPdf("bitcoin.pdf"), 101 | }, 102 | expected: expected{ 103 | mimetype: "application/zip", 104 | }, 105 | }, 106 | { 107 | name: "pdf to webp", 108 | input: input{ 109 | filename: "testdata/bitcoin.pdf", 110 | mimetype: "application/pdf", 111 | targetFileType: "Image", 112 | targetFormat: "webp", 113 | documenter: documents.NewPdf("bitcoin.pdf"), 114 | }, 115 | expected: expected{ 116 | mimetype: "application/zip", 117 | }, 118 | }, 119 | { 120 | name: "pdf to docx", 121 | input: input{ 122 | filename: "testdata/bitcoin.pdf", 123 | mimetype: "application/pdf", 124 | targetFileType: "Document", 125 | targetFormat: "docx", 126 | documenter: documents.NewPdf("bitcoin.pdf"), 127 | }, 128 | expected: expected{ 129 | mimetype: "application/zip", 130 | }, 131 | }, 132 | } 133 | 134 | for _, tc := range tests { 135 | tc := tc 136 | t.Run(tc.name, func(t *testing.T) { 137 | t.Parallel() 138 | 139 | inputDoc, err := os.ReadFile(tc.input.filename) 140 | require.NoError(t, err) 141 | 142 | detectedFileType := mimetype.Detect(inputDoc) 143 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 144 | 145 | outoutFile, err := tc.input.documenter.ConvertTo( 146 | tc.input.targetFileType, 147 | tc.input.targetFormat, 148 | bytes.NewReader(inputDoc), 149 | ) 150 | 151 | require.NoError(t, err) 152 | 153 | buf := new(bytes.Buffer) 154 | _, err = buf.ReadFrom(outoutFile) 155 | require.NoError(t, err) 156 | 157 | outoutFileBytes := buf.Bytes() 158 | detectedFileType = mimetype.Detect(outoutFileBytes) 159 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 160 | }) 161 | } 162 | } 163 | 164 | func TestDOCXTConvertTo(t *testing.T) { 165 | type input struct { 166 | filename string 167 | mimetype string 168 | targetFileType string 169 | targetFormat string 170 | documenter documenter 171 | } 172 | type expected struct { 173 | mimetype string 174 | } 175 | var tests = []struct { 176 | name string 177 | input input 178 | expected expected 179 | }{ 180 | { 181 | name: "docx to pdf", 182 | input: input{ 183 | filename: "testdata/file_sample.docx", 184 | mimetype: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 185 | targetFileType: "Document", 186 | targetFormat: "pdf", 187 | documenter: documents.NewDocx("file_sample.docx"), 188 | }, 189 | expected: expected{ 190 | mimetype: "application/zip", 191 | }, 192 | }, 193 | } 194 | 195 | for _, tc := range tests { 196 | tc := tc 197 | t.Run(tc.name, func(t *testing.T) { 198 | t.Parallel() 199 | 200 | inputDoc, err := os.ReadFile(tc.input.filename) 201 | require.NoError(t, err) 202 | 203 | detectedFileType := mimetype.Detect(inputDoc) 204 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 205 | 206 | resultFile, err := tc.input.documenter.ConvertTo( 207 | tc.input.targetFileType, 208 | tc.input.targetFormat, 209 | bytes.NewReader(inputDoc), 210 | ) 211 | 212 | require.NoError(t, err) 213 | 214 | buf := new(bytes.Buffer) 215 | _, err = buf.ReadFrom(resultFile) 216 | require.NoError(t, err) 217 | 218 | resultFileBytes := buf.Bytes() 219 | detectedFileType = mimetype.Detect(resultFileBytes) 220 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 221 | }) 222 | } 223 | } 224 | 225 | func TestCSVTConvertTo(t *testing.T) { 226 | type input struct { 227 | filename string 228 | mimetype string 229 | targetFileType string 230 | targetFormat string 231 | documenter documenter 232 | } 233 | type expected struct { 234 | mimetype string 235 | } 236 | var tests = []struct { 237 | name string 238 | input input 239 | expected expected 240 | }{ 241 | { 242 | name: "csv to xlsx", 243 | input: input{ 244 | filename: "testdata/student.csv", 245 | mimetype: "text/csv", 246 | targetFileType: "Document", 247 | targetFormat: "xlsx", 248 | documenter: documents.NewCsv("student.csv"), 249 | }, 250 | expected: expected{ 251 | mimetype: "application/zip", 252 | }, 253 | }, 254 | } 255 | for _, tc := range tests { 256 | tc := tc 257 | t.Run(tc.name, func(t *testing.T) { 258 | inputDoc, err := os.ReadFile(tc.input.filename) 259 | require.NoError(t, err) 260 | 261 | detectedFileType := mimetype.Detect(inputDoc) 262 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 263 | 264 | resultFile, err := tc.input.documenter.ConvertTo( 265 | tc.input.targetFileType, 266 | tc.input.targetFormat, 267 | bytes.NewReader(inputDoc), 268 | ) 269 | 270 | require.NoError(t, err) 271 | 272 | buf := new(bytes.Buffer) 273 | _, err = buf.ReadFrom(resultFile) 274 | require.NoError(t, err) 275 | 276 | resultFileBytes := buf.Bytes() 277 | detectedFileType = mimetype.Detect(resultFileBytes) 278 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 279 | }) 280 | } 281 | } 282 | 283 | func TestXLSXTConvertTo(t *testing.T) { 284 | type input struct { 285 | filename string 286 | mimetype string 287 | targetFileType string 288 | targetFormat string 289 | documenter documenter 290 | } 291 | type expected struct { 292 | mimetype string 293 | } 294 | var tests = []struct { 295 | name string 296 | input input 297 | expected expected 298 | }{ 299 | { 300 | name: "xlsx to csv", 301 | input: input{ 302 | filename: "testdata/movies.xlsx", 303 | mimetype: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 304 | targetFileType: "Document", 305 | targetFormat: "csv", 306 | documenter: documents.NewXlsx("movies.xlsx"), 307 | }, 308 | expected: expected{ 309 | mimetype: "application/zip", 310 | }, 311 | }, 312 | } 313 | for _, tc := range tests { 314 | tc := tc 315 | t.Run(tc.name, func(t *testing.T) { 316 | inputDoc, err := os.ReadFile(tc.input.filename) 317 | require.NoError(t, err) 318 | 319 | detectedFileType := mimetype.Detect(inputDoc) 320 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 321 | 322 | resultFile, err := tc.input.documenter.ConvertTo( 323 | tc.input.targetFileType, 324 | tc.input.targetFormat, 325 | bytes.NewReader(inputDoc), 326 | ) 327 | 328 | require.NoError(t, err) 329 | 330 | buf := new(bytes.Buffer) 331 | _, err = buf.ReadFrom(resultFile) 332 | require.NoError(t, err) 333 | 334 | resultFileBytes := buf.Bytes() 335 | detectedFileType = mimetype.Detect(resultFileBytes) 336 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 337 | }) 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /pkg/files/documents/docx.go: -------------------------------------------------------------------------------- 1 | package documents 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "slices" 14 | "strings" 15 | ) 16 | 17 | // Docx struct implements the File and Document interface from the file package. 18 | type Docx struct { 19 | filename string 20 | compatibleFormats map[string][]string 21 | compatibleMIMETypes map[string][]string 22 | OutDir string 23 | } 24 | 25 | // NewDocx returns a pointer to Docx. 26 | func NewDocx(filename string) *Docx { 27 | d := Docx{ 28 | filename: filename, 29 | compatibleFormats: map[string][]string{ 30 | "Document": { 31 | PDF, 32 | }, 33 | }, 34 | compatibleMIMETypes: map[string][]string{ 35 | "Document": { 36 | PDF, 37 | }, 38 | }, 39 | } 40 | 41 | return &d 42 | } 43 | 44 | // SupportedFormats returns a map witht the compatible formats that Docx is 45 | // compatible to be converted to. 46 | func (d *Docx) SupportedFormats() map[string][]string { 47 | return d.compatibleFormats 48 | } 49 | 50 | // SupportedMIMETypes returns a map witht the compatible MIME types that Docx is 51 | // compatible to be converted to. 52 | func (d *Docx) SupportedMIMETypes() map[string][]string { 53 | return d.compatibleMIMETypes 54 | } 55 | 56 | func (d *Docx) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 57 | compatibleFormats, ok := d.SupportedFormats()[fileType] 58 | if !ok { 59 | return nil, fmt.Errorf("file type not supported: %s", fileType) 60 | } 61 | 62 | if !slices.Contains(compatibleFormats, subType) { 63 | return nil, fmt.Errorf("sub-type not supported: %s", subType) 64 | } 65 | 66 | buf := new(bytes.Buffer) 67 | if _, err := buf.ReadFrom(file); err != nil { 68 | return nil, fmt.Errorf( 69 | "error getting the content of the docx file in form of slice of bytes: %w", 70 | err, 71 | ) 72 | } 73 | fileBytes := buf.Bytes() 74 | 75 | switch strings.ToLower(fileType) { 76 | case documentType: 77 | switch subType { 78 | case PDF: 79 | var ( 80 | stdout bytes.Buffer 81 | stderr bytes.Buffer 82 | ) 83 | 84 | docxFilename := filepath.Join("/tmp", d.filename) 85 | pdfFileName := fmt.Sprintf( 86 | "%s.pdf", 87 | strings.TrimSuffix(d.filename, filepath.Ext(d.filename)), 88 | ) 89 | tmpPdfFileName := filepath.Join("/tmp", fmt.Sprintf( 90 | "%s.pdf", 91 | strings.TrimSuffix(d.filename, filepath.Ext(d.filename)), 92 | )) 93 | 94 | // Parses the file name of the Zip file. 95 | zipFileName := filepath.Join("/tmp", fmt.Sprintf( 96 | "%s.zip", 97 | strings.TrimSuffix(d.filename, filepath.Ext(d.filename)), 98 | )) 99 | 100 | docxFile, err := os.Create(docxFilename) 101 | if err != nil { 102 | return nil, fmt.Errorf( 103 | "error creating file to store the incoming docx locally %s: %w", 104 | d.filename, 105 | err, 106 | ) 107 | } 108 | defer docxFile.Close() 109 | 110 | if _, err := docxFile.Write(fileBytes); err != nil { 111 | return nil, fmt.Errorf( 112 | "error storing the incoming pdf file %s: %w", 113 | d.filename, 114 | err, 115 | ) 116 | } 117 | 118 | tmpPdfFile, err := os.Create(tmpPdfFileName) 119 | if err != nil { 120 | return nil, fmt.Errorf( 121 | "error at creating the pdf file to store the pdf content: %w", 122 | err, 123 | ) 124 | } 125 | 126 | cmdStr := "libreoffice --headless --convert-to pdf:writer_pdf_Export --outdir %s %q" 127 | cmd := exec.Command( 128 | "bash", 129 | "-c", 130 | fmt.Sprintf(cmdStr, "/tmp", docxFilename), 131 | ) 132 | 133 | cmd.Stdout = &stdout 134 | cmd.Stderr = &stderr 135 | 136 | if err := cmd.Run(); err != nil { 137 | return nil, fmt.Errorf( 138 | "error converting docx to pdf using libreoffice: %s", 139 | err, 140 | ) 141 | } 142 | 143 | if stderr.String() != "" { 144 | return nil, fmt.Errorf( 145 | "error converting docx to pdf calling libreoffice: %s", 146 | stderr.String(), 147 | ) 148 | } 149 | 150 | log.Println(stdout.String()) 151 | 152 | tmpPdfFile.Close() 153 | 154 | tmpPdfFile, err = os.Open(tmpPdfFileName) 155 | if err != nil { 156 | return nil, fmt.Errorf( 157 | "error at opening the pdf file: %w", 158 | err, 159 | ) 160 | } 161 | defer tmpPdfFile.Close() 162 | 163 | // Creates the zip file that will be returned. 164 | archive, err := os.Create(zipFileName) 165 | if err != nil { 166 | return nil, fmt.Errorf( 167 | "error at creating the zip file to store the pdf file: %w", 168 | err, 169 | ) 170 | } 171 | 172 | // Creates a Zip Writer to add files later on. 173 | zipWriter := zip.NewWriter(archive) 174 | 175 | w1, err := zipWriter.Create(pdfFileName) 176 | if err != nil { 177 | return nil, fmt.Errorf( 178 | "eror at creating a zip file: %w", 179 | err, 180 | ) 181 | } 182 | 183 | if _, err := io.Copy(w1, tmpPdfFile); err != nil { 184 | return nil, fmt.Errorf( 185 | "error at writing the pdf file content to the zip writer: %w", 186 | err, 187 | ) 188 | } 189 | 190 | // Closes both zip writer and the zip file after its done with the writing. 191 | zipWriter.Close() 192 | archive.Close() 193 | 194 | // Reads the zip file as an slice of bytes. 195 | zipFile, err := os.ReadFile(zipFileName) 196 | if err != nil { 197 | return nil, fmt.Errorf("error reading zip file: %v", err) 198 | } 199 | 200 | return bytes.NewReader(zipFile), nil 201 | } 202 | } 203 | 204 | return nil, errors.New("not implemented") 205 | } 206 | 207 | func (d *Docx) DocumentType() string { 208 | return DOCX 209 | } 210 | -------------------------------------------------------------------------------- /pkg/files/documents/pdf.go: -------------------------------------------------------------------------------- 1 | package documents 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "image/gif" 9 | "image/jpeg" 10 | "image/png" 11 | "io" 12 | "log" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "slices" 17 | "strings" 18 | 19 | "github.com/chai2010/webp" 20 | "github.com/gen2brain/go-fitz" 21 | "golang.org/x/image/bmp" 22 | "golang.org/x/image/tiff" 23 | 24 | "github.com/danvergara/morphos/pkg/files/images" 25 | "github.com/danvergara/morphos/pkg/util" 26 | ) 27 | 28 | // Pdf struct implements the File and Document interface from the file package. 29 | type Pdf struct { 30 | filename string 31 | compatibleFormats map[string][]string 32 | compatibleMIMETypes map[string][]string 33 | OutDir string 34 | } 35 | 36 | // NewPdf returns a pointer to Pdf. 37 | func NewPdf(filename string) *Pdf { 38 | p := Pdf{ 39 | filename: filename, 40 | compatibleFormats: map[string][]string{ 41 | "Image": { 42 | images.JPG, 43 | images.JPEG, 44 | images.PNG, 45 | images.GIF, 46 | images.WEBP, 47 | images.TIFF, 48 | images.BMP, 49 | }, 50 | "Document": { 51 | DOCX, 52 | }, 53 | "Ebook": { 54 | EPUB, 55 | MOBI, 56 | }, 57 | }, 58 | compatibleMIMETypes: map[string][]string{ 59 | "Image": { 60 | images.JPG, 61 | images.JPEG, 62 | images.PNG, 63 | images.GIF, 64 | images.WEBP, 65 | images.TIFF, 66 | images.BMP, 67 | }, 68 | "Document": { 69 | DOCXMIMEType, 70 | }, 71 | "Ebook": { 72 | EpubMimeType, 73 | MobiMimeType, 74 | }, 75 | }, 76 | } 77 | 78 | return &p 79 | } 80 | 81 | // SupportedFormats returns a map witht the compatible formats that Pdf is 82 | // compatible to be converted to. 83 | func (p *Pdf) SupportedFormats() map[string][]string { 84 | return p.compatibleFormats 85 | } 86 | 87 | // SupportedMIMETypes returns a map witht the compatible MIME types that Pdf is 88 | // compatible to be converted to. 89 | func (p *Pdf) SupportedMIMETypes() map[string][]string { 90 | return p.compatibleMIMETypes 91 | } 92 | 93 | // ConvertTo converts the current PDF file to another given format. 94 | // This method receives the file type, the sub-type and the file as an slice of bytes. 95 | // Returns the converted file as an slice of bytes, if something wrong happens, an error is returned. 96 | func (p *Pdf) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 97 | // These are guard clauses that check if the target file type is valid. 98 | compatibleFormats, ok := p.SupportedFormats()[fileType] 99 | if !ok { 100 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 101 | } 102 | 103 | if !slices.Contains(compatibleFormats, subType) { 104 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 105 | } 106 | 107 | buf := new(bytes.Buffer) 108 | if _, err := buf.ReadFrom(file); err != nil { 109 | return nil, fmt.Errorf( 110 | "error getting the content of the pdf file in form of slice of bytes: %w", 111 | err, 112 | ) 113 | } 114 | 115 | fileBytes := buf.Bytes() 116 | 117 | // If the file type is valid, figures out how to go ahead. 118 | switch strings.ToLower(fileType) { 119 | case imageType: 120 | // Creates a PDF Reader based on the pdf file. 121 | doc, err := fitz.NewFromMemory(fileBytes) 122 | if err != nil { 123 | return nil, fmt.Errorf("ConvertTo: error at opening the input pdf: %w", err) 124 | } 125 | 126 | // Parses the file name of the Zip file. 127 | zipFileName := fmt.Sprintf( 128 | "%s.zip", 129 | strings.TrimSuffix(p.filename, filepath.Ext(p.filename)), 130 | ) 131 | 132 | // Creates the zip file that will be returned. 133 | archive, err := os.CreateTemp("", zipFileName) 134 | if err != nil { 135 | return nil, fmt.Errorf( 136 | "ConvertTo: error at creating the zip file to store the images: %w", 137 | err, 138 | ) 139 | } 140 | defer os.Remove(archive.Name()) 141 | 142 | // Creates a Zip Writer to add files later on. 143 | zipWriter := zip.NewWriter(archive) 144 | 145 | for n := 0; n < doc.NumPage(); n++ { 146 | // Parses the file name image. 147 | imgFileName := fmt.Sprintf( 148 | "%s_%d.%s", 149 | strings.TrimSuffix(p.filename, filepath.Ext(p.filename)), 150 | n, 151 | subType, 152 | ) 153 | 154 | // Converts the current pdf page to an image.Image. 155 | img, err := doc.Image(n) 156 | if err != nil { 157 | return nil, fmt.Errorf( 158 | "ConvertTo: error at converting the pdf page number %d to image: %w", 159 | n, 160 | err, 161 | ) 162 | } 163 | 164 | // Saves the image on disk. 165 | imgFile, err := os.Create(fmt.Sprintf("/tmp/%s", imgFileName)) 166 | if err != nil { 167 | return nil, fmt.Errorf( 168 | "ConvertTo: error at storing the pdf image from the page #%d: %w", 169 | n, 170 | err, 171 | ) 172 | } 173 | 174 | // Encodes the image based on the sub-type of the file. 175 | // e.g. png. 176 | switch subType { 177 | case images.PNG: 178 | err = png.Encode(imgFile, img) 179 | if err != nil { 180 | return nil, fmt.Errorf( 181 | "ConvertTo: error at encoding the pdf page %d as png: %w", 182 | n, 183 | err, 184 | ) 185 | } 186 | case images.JPG, images.JPEG: 187 | err = jpeg.Encode(imgFile, img, nil) 188 | if err != nil { 189 | return nil, fmt.Errorf( 190 | "ConvertTo: error at encoding the pdf page %d as jpeg: %w", 191 | n, 192 | err, 193 | ) 194 | } 195 | case images.GIF: 196 | err = gif.Encode(imgFile, img, nil) 197 | if err != nil { 198 | return nil, fmt.Errorf( 199 | "ConvertTo: error at encoding the pdf page %d as gif: %w", 200 | n, 201 | err, 202 | ) 203 | } 204 | case images.WEBP: 205 | err = webp.Encode(imgFile, img, nil) 206 | if err != nil { 207 | return nil, fmt.Errorf( 208 | "ConvertTo: error at encoding the pdf page %d as webp: %w", 209 | n, 210 | err, 211 | ) 212 | } 213 | case images.TIFF: 214 | err = tiff.Encode(imgFile, img, nil) 215 | if err != nil { 216 | return nil, fmt.Errorf( 217 | "ConvertTo: error at encoding the pdf page %d as tiff: %w", 218 | n, 219 | err, 220 | ) 221 | } 222 | case images.BMP: 223 | err = bmp.Encode(imgFile, img) 224 | if err != nil { 225 | return nil, fmt.Errorf( 226 | "ConvertTo: error at encoding the pdf page %d as bmp: %w", 227 | n, 228 | err, 229 | ) 230 | } 231 | } 232 | 233 | imgFile.Close() 234 | 235 | // Opens the image to add it to the zip file. 236 | imgFile, err = os.Open(imgFile.Name()) 237 | if err != nil { 238 | return nil, fmt.Errorf( 239 | "ConvertTo: error at storing the pdf image from the page #%d: %w", 240 | n, 241 | err, 242 | ) 243 | } 244 | 245 | // Adds the image to the zip file. 246 | w1, err := zipWriter.Create(filepath.Base(imgFile.Name())) 247 | if err != nil { 248 | return nil, fmt.Errorf( 249 | "ConvertTo: error at creating a zip writer to store the page #%d: %w", 250 | n, 251 | err, 252 | ) 253 | } 254 | 255 | if _, err := io.Copy(w1, imgFile); err != nil { 256 | return nil, fmt.Errorf( 257 | "ConvertTo: error at copying the content of the page #%d to the zipwriter: %w", 258 | n, 259 | err, 260 | ) 261 | } 262 | 263 | imgFile.Close() 264 | os.Remove(imgFile.Name()) 265 | } 266 | 267 | // Closes both zip writer and the zip file after its done with the writing. 268 | zipWriter.Close() 269 | archive.Close() 270 | 271 | // Reads the zip file as an slice of bytes. 272 | zipFile, err := os.ReadFile(archive.Name()) 273 | if err != nil { 274 | return nil, fmt.Errorf("error reading zip file: %v", err) 275 | } 276 | 277 | return bytes.NewReader(zipFile), nil 278 | case documentType: 279 | switch subType { 280 | case DOCX: 281 | var ( 282 | stdout bytes.Buffer 283 | stderr bytes.Buffer 284 | ) 285 | 286 | docxFileName := fmt.Sprintf( 287 | "%s.docx", 288 | strings.TrimSuffix(p.filename, filepath.Ext(p.filename)), 289 | ) 290 | 291 | // Parses the file name of the Zip file. 292 | zipFileName := fmt.Sprintf( 293 | "%s.zip", 294 | strings.TrimSuffix(p.filename, filepath.Ext(p.filename)), 295 | ) 296 | 297 | pdfFile, err := os.CreateTemp("", p.filename) 298 | if err != nil { 299 | return nil, fmt.Errorf( 300 | "error creating file to store the incoming pdf locally %s: %w", 301 | p.filename, 302 | err, 303 | ) 304 | } 305 | defer os.Remove(pdfFile.Name()) 306 | 307 | if _, err := pdfFile.Write(fileBytes); err != nil { 308 | return nil, fmt.Errorf( 309 | "error storing the incoming pdf file %s: %w", 310 | p.filename, 311 | err, 312 | ) 313 | } 314 | 315 | tmpDocxFile, err := os.CreateTemp("", docxFileName) 316 | if err != nil { 317 | return nil, fmt.Errorf( 318 | "error at creating the temporary docx file to store the docx content: %w", 319 | err, 320 | ) 321 | } 322 | defer os.Remove(tmpDocxFile.Name()) 323 | 324 | cmdStr := "libreoffice --headless --infilter='writer_pdf_import' --convert-to %s --outdir %s %q" 325 | cmd := exec.Command( 326 | "bash", 327 | "-c", 328 | fmt.Sprintf(cmdStr, `docx:"MS Word 2007 XML"`, "/tmp", pdfFile.Name()), 329 | ) 330 | 331 | cmd.Stdout = &stdout 332 | cmd.Stderr = &stderr 333 | 334 | if err := cmd.Run(); err != nil { 335 | return nil, fmt.Errorf( 336 | "error converting pdf to docx using libreoffice: %w", 337 | err, 338 | ) 339 | } 340 | 341 | if stderr.String() != "" { 342 | return nil, fmt.Errorf( 343 | "error converting pdf to docx calling libreoffice: %s", 344 | stderr.String(), 345 | ) 346 | } 347 | 348 | log.Println(stdout.String()) 349 | 350 | tmpDocxFile.Close() 351 | 352 | tmpDocxFile, err = os.Open(tmpDocxFile.Name()) 353 | if err != nil { 354 | return nil, fmt.Errorf( 355 | "error at opening the docx file: %w", 356 | err, 357 | ) 358 | } 359 | defer tmpDocxFile.Close() 360 | 361 | // Creates the zip file that will be returned. 362 | archive, err := os.CreateTemp("", zipFileName) 363 | if err != nil { 364 | return nil, fmt.Errorf( 365 | "error at creating the zip file to store the docx file: %w", 366 | err, 367 | ) 368 | } 369 | defer os.Remove(archive.Name()) 370 | 371 | // Creates a Zip Writer to add files later on. 372 | zipWriter := zip.NewWriter(archive) 373 | 374 | w1, err := zipWriter.Create(docxFileName) 375 | if err != nil { 376 | return nil, fmt.Errorf( 377 | "eror at creating a zip file: %w", 378 | err, 379 | ) 380 | } 381 | 382 | if _, err := io.Copy(w1, tmpDocxFile); err != nil { 383 | return nil, fmt.Errorf( 384 | "error at writing the docx file content to the zip writer: %w", 385 | err, 386 | ) 387 | } 388 | 389 | // Closes both zip writer and the zip file after its done with the writing. 390 | zipWriter.Close() 391 | archive.Close() 392 | 393 | // Reads the zip file as an slice of bytes. 394 | zipFile, err := os.ReadFile(archive.Name()) 395 | if err != nil { 396 | return nil, fmt.Errorf("error reading zip file: %v", err) 397 | } 398 | 399 | return bytes.NewReader(zipFile), nil 400 | } 401 | case ebookType: 402 | switch subType { 403 | case EPUB: 404 | return util.EbookConvert(p.filename, PDF, EPUB, fileBytes) 405 | case MOBI: 406 | return util.EbookConvert(p.filename, PDF, MOBI, fileBytes) 407 | } 408 | } 409 | 410 | return nil, errors.New("not implemented") 411 | } 412 | 413 | // DocumentType returns the type of ducument of Pdf. 414 | func (p *Pdf) DocumentType() string { 415 | return PDF 416 | } 417 | -------------------------------------------------------------------------------- /pkg/files/documents/testdata/bitcoin.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/documents/testdata/bitcoin.pdf -------------------------------------------------------------------------------- /pkg/files/documents/testdata/file_sample.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/documents/testdata/file_sample.docx -------------------------------------------------------------------------------- /pkg/files/documents/testdata/movies.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/documents/testdata/movies.xlsx -------------------------------------------------------------------------------- /pkg/files/documents/testdata/student.csv: -------------------------------------------------------------------------------- 1 | id,name,class,mark,gender 2 | 1,John Deo,Four,75,female 3 | 2,Max Ruin,Three,85,male 4 | 3,Arnold,Three,55,male 5 | 4,Krish Star,Four,60,female 6 | 5,John Mike,Four,60,female 7 | 6,Alex John,Four,55,male 8 | 7,My John Rob,Fifth,78,male 9 | 8,Asruid,Five,85,male 10 | 9,Tes Qry,Six,78,male 11 | 10,Big John,Four,55,female 12 | 11,Ronald,Six,89,female 13 | 12,Recky,Six,94,female 14 | 13,Kty,Seven,88,female 15 | 14,Bigy,Seven,88,female 16 | 15,Tade Row,Four,88,male 17 | 16,Gimmy,Four,88,male 18 | 17,Tumyu,Six,54,male 19 | 18,Honny,Five,75,male 20 | 19,Tinny,Nine,18,male 21 | 20,Jackly,Nine,65,female 22 | 21,Babby John,Four,69,female 23 | 22,Reggid,Seven,55,female 24 | 23,Herod,Eight,79,male 25 | 24,Tiddy Now,Seven,78,male 26 | 25,Giff Tow,Seven,88,male 27 | 26,Crelea,Seven,79,male 28 | 27,Big Nose,Three,81,female 29 | 28,Rojj Base,Seven,86,female 30 | 29,Tess Played,Seven,55,male 31 | 30,Reppy Red,Six,79,female 32 | 31,Marry Toeey,Four,88,male 33 | 32,Binn Rott,Seven,90,female 34 | 33,Kenn Rein,Six,96,female 35 | 34,Gain Toe,Seven,69,male 36 | 35,Rows Noump,Six,88,female 37 | -------------------------------------------------------------------------------- /pkg/files/documents/xlsx.go: -------------------------------------------------------------------------------- 1 | package documents 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/csv" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | 15 | "github.com/tealeg/xlsx/v3" 16 | ) 17 | 18 | // Xlsx struct implements the File and Document interface from the file package. 19 | type Xlsx struct { 20 | filename string 21 | compatibleFormats map[string][]string 22 | compatibleMIMETypes map[string][]string 23 | } 24 | 25 | // NewXlsx returns a pointer to Xlsx. 26 | func NewXlsx(filename string) *Xlsx { 27 | x := Xlsx{ 28 | filename: filename, 29 | compatibleFormats: map[string][]string{ 30 | "Document": { 31 | CSV, 32 | }, 33 | }, 34 | compatibleMIMETypes: map[string][]string{ 35 | "Document": { 36 | CSV, 37 | }, 38 | }, 39 | } 40 | 41 | return &x 42 | } 43 | 44 | // SupportedFormats returns a map witht the compatible formats that Xlsx is 45 | // compatible to be converted to. 46 | func (x *Xlsx) SupportedFormats() map[string][]string { 47 | return x.compatibleFormats 48 | } 49 | 50 | // SupportedMIMETypes returns a map witht the compatible MIME types that Docx is 51 | // compatible to be converted to. 52 | func (x *Xlsx) SupportedMIMETypes() map[string][]string { 53 | return x.compatibleMIMETypes 54 | } 55 | 56 | func (x *Xlsx) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 57 | compatibleFormats, ok := x.SupportedFormats()[fileType] 58 | if !ok { 59 | return nil, fmt.Errorf("file type not supported: %s", fileType) 60 | } 61 | 62 | if !slices.Contains(compatibleFormats, subType) { 63 | return nil, fmt.Errorf("sub-type not supported: %s", subType) 64 | } 65 | 66 | buf := new(bytes.Buffer) 67 | if _, err := buf.ReadFrom(file); err != nil { 68 | return nil, fmt.Errorf( 69 | "error getting the content of the xlsx file in form of slice of bytes: %w", 70 | err, 71 | ) 72 | } 73 | 74 | fileBytes := buf.Bytes() 75 | 76 | switch strings.ToLower(fileType) { 77 | case documentType: 78 | switch subType { 79 | case CSV: 80 | xlFile, err := xlsx.OpenBinary(fileBytes) 81 | if err != nil { 82 | return nil, fmt.Errorf("error trying to open the xlsx file based on bytes of file %w", err) 83 | } 84 | 85 | // Parses the file name of the Zip file. 86 | zipFileName := filepath.Join("/tmp", fmt.Sprintf( 87 | "%s.zip", 88 | strings.TrimSuffix(x.filename, filepath.Ext(x.filename)), 89 | )) 90 | 91 | // Creates the zip file that will be returned. 92 | archive, err := os.Create(zipFileName) 93 | if err != nil { 94 | return nil, fmt.Errorf( 95 | "ConvertTo: error at creating the zip file to store the images: %w", 96 | err, 97 | ) 98 | } 99 | 100 | // Creates a Zip Writer to add files later on. 101 | zipWriter := zip.NewWriter(archive) 102 | 103 | for i, sheet := range xlFile.Sheets { 104 | csvFilename := fmt.Sprintf( 105 | "%s_%d.%s", 106 | strings.TrimSuffix(x.filename, filepath.Ext(x.filename)), 107 | i+1, 108 | subType, 109 | ) 110 | 111 | tmpCsvFilename := filepath.Join("/tmp", csvFilename) 112 | 113 | // Saves the image on disk. 114 | csvFile, err := os.Create(tmpCsvFilename) 115 | if err != nil { 116 | return nil, fmt.Errorf( 117 | "error at storing the tmp csv file from the xlsx sheet #%d: %w", 118 | i+1, 119 | err, 120 | ) 121 | } 122 | 123 | cw := csv.NewWriter(csvFile) 124 | 125 | var vals []string 126 | err = sheet.ForEachRow(func(row *xlsx.Row) error { 127 | if row != nil { 128 | vals = vals[:0] 129 | err := row.ForEachCell(func(cell *xlsx.Cell) error { 130 | str, err := cell.FormattedValue() 131 | if err != nil { 132 | return err 133 | } 134 | vals = append(vals, str) 135 | return nil 136 | }) 137 | if err != nil { 138 | return err 139 | } 140 | } 141 | cw.Write(vals) 142 | return nil 143 | }) 144 | if err != nil { 145 | return nil, fmt.Errorf("error at creating a csv based of a xlsx sheet %d %w", i+1, err) 146 | } 147 | 148 | cw.Flush() 149 | if cw.Error() != nil { 150 | return nil, fmt.Errorf("error at writing buffered data to a underlying csv %w", err) 151 | } 152 | 153 | csvFile.Close() 154 | 155 | // Saves the image on disk. 156 | csvFile, err = os.Open(tmpCsvFilename) 157 | if err != nil { 158 | return nil, fmt.Errorf( 159 | "error at opening the csv file based off a xlsx sheet #%d: %w", 160 | i+1, 161 | err, 162 | ) 163 | } 164 | 165 | defer csvFile.Close() 166 | 167 | // Adds the image to the zip file. 168 | w1, err := zipWriter.Create(csvFilename) 169 | if err != nil { 170 | return nil, fmt.Errorf( 171 | "error at creating a zip writer to store the xlsx sheet #%d: %w", 172 | i+1, 173 | err, 174 | ) 175 | } 176 | 177 | if _, err := io.Copy(w1, csvFile); err != nil { 178 | return nil, fmt.Errorf( 179 | "error at copying the content of the xlsx sheet #%d to the zipwriter: %w", 180 | i+1, 181 | err, 182 | ) 183 | } 184 | } 185 | 186 | // Closes both zip writer and the zip file after its done with the writing. 187 | zipWriter.Close() 188 | archive.Close() 189 | 190 | // Reads the zip file as an slice of bytes. 191 | zipFile, err := os.ReadFile(zipFileName) 192 | if err != nil { 193 | return nil, fmt.Errorf("error reading zip file: %v", err) 194 | } 195 | 196 | return bytes.NewReader(zipFile), nil 197 | } 198 | } 199 | 200 | return nil, errors.New("not implemented") 201 | } 202 | 203 | func (x *Xlsx) DocumentType() string { 204 | return XLSX 205 | } 206 | -------------------------------------------------------------------------------- /pkg/files/ebooks.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | // Ebooker interface is the one that defines what a ebook is 4 | // in this context. It's responsible to return kind of the underlying ebook. 5 | type Ebooker interface { 6 | EbookType() string 7 | } 8 | -------------------------------------------------------------------------------- /pkg/files/ebooks/ebooks.go: -------------------------------------------------------------------------------- 1 | package ebooks 2 | 3 | const ( 4 | documentType = "document" 5 | ebookType = "ebook" 6 | 7 | PDF = "pdf" 8 | 9 | EPUB = "epub" 10 | EpubMimeType = "epub+zip" 11 | 12 | MOBI = "mobi" 13 | MobiMimeType = "x-mobipocket-ebook" 14 | ) 15 | -------------------------------------------------------------------------------- /pkg/files/ebooks/ebooks_test.go: -------------------------------------------------------------------------------- 1 | package ebooks 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/gabriel-vasile/mimetype" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type file interface { 14 | SupportedFormats() map[string][]string 15 | ConvertTo(string, string, io.Reader) (io.Reader, error) 16 | } 17 | 18 | type ebook interface { 19 | file 20 | EbookType() string 21 | } 22 | 23 | func TestEbookTConvertTo(t *testing.T) { 24 | type input struct { 25 | filename string 26 | mimetype string 27 | targetFileType string 28 | targetFormat string 29 | ebook ebook 30 | } 31 | type expected struct { 32 | mimetype string 33 | } 34 | 35 | var tests = []struct { 36 | name string 37 | input input 38 | expected expected 39 | }{ 40 | { 41 | name: "epub to pdf", 42 | input: input{ 43 | filename: "testdata/no-man-s-land.epub", 44 | mimetype: "application/epub+zip", 45 | targetFileType: "Document", 46 | targetFormat: "pdf", 47 | ebook: NewEpub("no-man-s-land.epub"), 48 | }, 49 | expected: expected{ 50 | mimetype: "application/zip", 51 | }, 52 | }, 53 | { 54 | name: "epub to mobi", 55 | input: input{ 56 | filename: "testdata/no-man-s-land.epub", 57 | mimetype: "application/epub+zip", 58 | targetFileType: "Ebook", 59 | targetFormat: "mobi", 60 | ebook: NewEpub("no-man-s-land.epub"), 61 | }, 62 | expected: expected{ 63 | mimetype: "application/zip", 64 | }, 65 | }, 66 | { 67 | name: "mobi to epub", 68 | input: input{ 69 | filename: "testdata/basilleja.mobi", 70 | mimetype: "application/x-mobipocket-ebook", 71 | targetFileType: "Ebook", 72 | targetFormat: "epub", 73 | ebook: NewMobi("basilleja.mobi"), 74 | }, 75 | expected: expected{ 76 | mimetype: "application/zip", 77 | }, 78 | }, 79 | } 80 | for _, tc := range tests { 81 | tc := tc 82 | t.Run(tc.name, func(t *testing.T) { 83 | 84 | inputDoc, err := os.ReadFile(tc.input.filename) 85 | require.NoError(t, err) 86 | 87 | detectedFileType := mimetype.Detect(inputDoc) 88 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 89 | 90 | outoutFile, err := tc.input.ebook.ConvertTo( 91 | tc.input.targetFileType, 92 | tc.input.targetFormat, 93 | bytes.NewReader(inputDoc), 94 | ) 95 | require.NoError(t, err) 96 | 97 | buf := new(bytes.Buffer) 98 | _, err = buf.ReadFrom(outoutFile) 99 | require.NoError(t, err) 100 | 101 | outoutFileBytes := buf.Bytes() 102 | detectedFileType = mimetype.Detect(outoutFileBytes) 103 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/files/ebooks/epub.go: -------------------------------------------------------------------------------- 1 | package ebooks 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/danvergara/morphos/pkg/files/documents" 12 | "github.com/danvergara/morphos/pkg/util" 13 | ) 14 | 15 | // Epub struct implements the File and Document interface from the file package. 16 | type Epub struct { 17 | filename string 18 | compatibleFormats map[string][]string 19 | compatibleMIMETypes map[string][]string 20 | } 21 | 22 | func NewEpub(filename string) *Epub { 23 | e := Epub{ 24 | filename: filename, 25 | compatibleFormats: map[string][]string{ 26 | "Document": { 27 | documents.PDF, 28 | }, 29 | "Ebook": { 30 | MOBI, 31 | }, 32 | }, 33 | compatibleMIMETypes: map[string][]string{ 34 | "Document": { 35 | documents.PDF, 36 | }, 37 | "Ebook": { 38 | MobiMimeType, 39 | }, 40 | }, 41 | } 42 | 43 | return &e 44 | } 45 | 46 | // SupportedFormats returns a map witht the compatible formats that Pdf is 47 | // compatible to be converted to. 48 | func (e *Epub) SupportedFormats() map[string][]string { 49 | return e.compatibleFormats 50 | } 51 | 52 | // SupportedMIMETypes returns a map witht the compatible MIME types that Pdf is 53 | // compatible to be converted to. 54 | func (e *Epub) SupportedMIMETypes() map[string][]string { 55 | return e.compatibleMIMETypes 56 | } 57 | 58 | func (e *Epub) ConvertTo(fileType, subtype string, file io.Reader) (io.Reader, error) { 59 | // These are guard clauses that check if the target file type is valid. 60 | compatibleFormats, ok := e.SupportedFormats()[fileType] 61 | if !ok { 62 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 63 | } 64 | 65 | if !slices.Contains(compatibleFormats, subtype) { 66 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subtype) 67 | } 68 | 69 | buf := new(bytes.Buffer) 70 | if _, err := buf.ReadFrom(file); err != nil { 71 | return nil, fmt.Errorf( 72 | "error getting the content of the pdf file in form of slice of bytes: %w", 73 | err, 74 | ) 75 | } 76 | 77 | fileBytes := buf.Bytes() 78 | 79 | switch strings.ToLower(fileType) { 80 | case documentType: 81 | switch subtype { 82 | case PDF: 83 | return util.EbookConvert(e.filename, EPUB, PDF, fileBytes) 84 | } 85 | case ebookType: 86 | switch subtype { 87 | case MOBI: 88 | return util.EbookConvert(e.filename, EPUB, MOBI, fileBytes) 89 | } 90 | } 91 | 92 | return nil, errors.New("not implemented") 93 | } 94 | 95 | // EbookType returns the Ebook type which is Epub in this case. 96 | func (e *Epub) EbookType() string { 97 | return EPUB 98 | } 99 | -------------------------------------------------------------------------------- /pkg/files/ebooks/mobi.go: -------------------------------------------------------------------------------- 1 | package ebooks 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/danvergara/morphos/pkg/files/documents" 12 | "github.com/danvergara/morphos/pkg/util" 13 | ) 14 | 15 | type Mobi struct { 16 | filename string 17 | compatibleFormats map[string][]string 18 | compatibleMIMETypes map[string][]string 19 | } 20 | 21 | func NewMobi(filename string) *Mobi { 22 | m := Mobi{ 23 | filename: filename, 24 | compatibleFormats: map[string][]string{ 25 | "Document": { 26 | documents.PDF, 27 | }, 28 | "Ebook": { 29 | EPUB, 30 | }, 31 | }, 32 | compatibleMIMETypes: map[string][]string{ 33 | "Document": { 34 | documents.PDF, 35 | }, 36 | "Ebook": { 37 | EpubMimeType, 38 | }, 39 | }, 40 | } 41 | 42 | return &m 43 | } 44 | 45 | // SupportedFormats returns a map witht the compatible formats that MOBI is 46 | // compatible to be converted to. 47 | func (m *Mobi) SupportedFormats() map[string][]string { 48 | return m.compatibleFormats 49 | } 50 | 51 | // SupportedMIMETypes returns a map witht the compatible MIME types that MOBI is 52 | // compatible to be converted to. 53 | func (m *Mobi) SupportedMIMETypes() map[string][]string { 54 | return m.compatibleMIMETypes 55 | } 56 | 57 | func (m *Mobi) ConvertTo(fileType, subtype string, file io.Reader) (io.Reader, error) { 58 | // These are guard clauses that check if the target file type is valid. 59 | compatibleFormats, ok := m.SupportedFormats()[fileType] 60 | if !ok { 61 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 62 | } 63 | 64 | if !slices.Contains(compatibleFormats, subtype) { 65 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subtype) 66 | } 67 | 68 | buf := new(bytes.Buffer) 69 | if _, err := buf.ReadFrom(file); err != nil { 70 | return nil, fmt.Errorf( 71 | "error getting the content of the pdf file in form of slice of bytes: %w", 72 | err, 73 | ) 74 | } 75 | 76 | fileBytes := buf.Bytes() 77 | 78 | switch strings.ToLower(fileType) { 79 | case documentType: 80 | switch subtype { 81 | case PDF: 82 | return util.EbookConvert(m.filename, MOBI, PDF, fileBytes) 83 | } 84 | case ebookType: 85 | switch subtype { 86 | case EPUB: 87 | return util.EbookConvert(m.filename, MOBI, EPUB, fileBytes) 88 | } 89 | } 90 | 91 | return nil, errors.New("file format not implemented") 92 | } 93 | 94 | // EbookType returns the Ebook type which is MOBI in this case. 95 | func (m *Mobi) EbookType() string { 96 | return EPUB 97 | } 98 | -------------------------------------------------------------------------------- /pkg/files/ebooks/testdata/basilleja.mobi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/ebooks/testdata/basilleja.mobi -------------------------------------------------------------------------------- /pkg/files/ebooks/testdata/no-man-s-land.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/ebooks/testdata/no-man-s-land.epub -------------------------------------------------------------------------------- /pkg/files/file_factory.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "fmt" 4 | 5 | // FileFactory interface is responsible for defining how a FileFactory behaves. 6 | // It defines a NewFile method that returns an entity 7 | // that implements the File interface. 8 | type FileFactory interface { 9 | NewFile(string) (File, error) 10 | } 11 | 12 | const ( 13 | Img = "image" 14 | // Application is provide because the type from the document's mimetype 15 | // is defined as application, not document. Both are supported. 16 | Application = "application" 17 | Doc = "document" 18 | Text = "text" 19 | Ebook = "ebook" 20 | ) 21 | 22 | // BuildFactory is a function responsible to return a FileFactory, 23 | // given a supported and valid file type, otherwise, it will error out. 24 | func BuildFactory(f string, filename string) (FileFactory, error) { 25 | switch f { 26 | case Img: 27 | return new(ImageFactory), nil 28 | case Doc, Application, Text: 29 | return NewDocumentFactory(filename), nil 30 | default: 31 | return nil, fmt.Errorf("factory with type file %s not recognized", f) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/files/file_factory_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/danvergara/morphos/pkg/files/documents" 9 | "github.com/danvergara/morphos/pkg/files/images" 10 | ) 11 | 12 | func TestImageFactory(t *testing.T) { 13 | imgF, err := BuildFactory(Img, "foo.png") 14 | require.NoError(t, err) 15 | 16 | imageFile, err := imgF.NewFile(images.PNG) 17 | require.NoError(t, err) 18 | 19 | png, ok := imageFile.(Image) 20 | if !ok { 21 | t.Fatal("struct assertion has failed") 22 | } 23 | 24 | t.Logf("Png image has type %s", png.ImageType()) 25 | } 26 | 27 | func TestDocumentFactory(t *testing.T) { 28 | docF, err := BuildFactory(Doc, "foo.pdf") 29 | require.NoError(t, err) 30 | 31 | docFile, err := docF.NewFile(documents.PDF) 32 | require.NoError(t, err) 33 | 34 | pdf, ok := docFile.(Document) 35 | if !ok { 36 | t.Fatal("struct assertion has failed") 37 | } 38 | 39 | t.Logf("PDF document has type %s", pdf.DocumentType()) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/files/files.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "io" 4 | 5 | // File interface is the main interface of the package, 6 | // that defines what a file is in this context. 7 | // It's moslty responsible to say other entitites what formats it can be converted to 8 | // and provides a method to convert the current file given a target format, if supported. 9 | // SupportedMIMETypes was added to tell between how we see files, categorized by 10 | // extension, and how they are registered as MIME types. 11 | // e.g. 12 | // Kind of document: Microsoft Word (OpenXML) 13 | // Extension: docx 14 | // MIME Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document 15 | type File interface { 16 | SupportedFormats() map[string][]string 17 | SupportedMIMETypes() map[string][]string 18 | ConvertTo(string, string, io.Reader) (io.Reader, error) 19 | } 20 | 21 | // SupportedFileTypes returns a map with the underlying file type, 22 | // given a sub-type. 23 | func SupportedFileTypes() map[string]string { 24 | return map[string]string{ 25 | "avif": "image", 26 | "png": "image", 27 | "jpg": "image", 28 | "jpeg": "image", 29 | "gif": "image", 30 | "webp": "image", 31 | "tiff": "image", 32 | "bmp": "image", 33 | "docx": "document", 34 | "pdf": "document", 35 | "xlsx": "document", 36 | "csv": "document", 37 | "epub": "ebook", 38 | "mobi": "ebook", 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/files/image_factory.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/danvergara/morphos/pkg/files/images" 7 | ) 8 | 9 | // ImageFactory implements the FileFactory interface. 10 | type ImageFactory struct{} 11 | 12 | // NewFile method returns an object that implements the File interface, 13 | // given an image format as input. 14 | // If not supported, it will error out. 15 | func (i *ImageFactory) NewFile(f string) (File, error) { 16 | switch f { 17 | case images.PNG: 18 | return images.NewPng(), nil 19 | case images.JPEG: 20 | return images.NewJpeg(), nil 21 | case images.GIF: 22 | return images.NewGif(), nil 23 | case images.WEBP: 24 | return images.NewWebp(), nil 25 | case images.TIFF: 26 | return images.NewTiff(), nil 27 | case images.BMP: 28 | return images.NewBmp(), nil 29 | case images.AVIF: 30 | return images.NewAvif(), nil 31 | default: 32 | return nil, fmt.Errorf("type file %s not recognized", f) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/files/images.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | // Image interface is the one that defines what an images is 4 | // in this context. It's responsible to return kind of the underlying image. 5 | type Image interface { 6 | ImageType() string 7 | } 8 | -------------------------------------------------------------------------------- /pkg/files/images/avif.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | // Avif struct implements the File and Image interface from the files pkg. 11 | type Avif struct { 12 | compatibleFormats map[string][]string 13 | compatibleMIMETypes map[string][]string 14 | } 15 | 16 | // NewAvif returns a pointer to a Avif instance. 17 | // The Avif object is set with a map with list of supported file formats. 18 | func NewAvif() *Avif { 19 | a := Avif{ 20 | compatibleFormats: map[string][]string{ 21 | "Image": { 22 | JPG, 23 | JPEG, 24 | PNG, 25 | GIF, 26 | WEBP, 27 | TIFF, 28 | BMP, 29 | }, 30 | }, 31 | 32 | compatibleMIMETypes: map[string][]string{ 33 | "Image": { 34 | JPG, 35 | JPEG, 36 | PNG, 37 | GIF, 38 | WEBP, 39 | TIFF, 40 | BMP, 41 | }, 42 | }, 43 | } 44 | 45 | return &a 46 | } 47 | 48 | // SupportedFormats returns a map with a slice of supported files. 49 | // Every key of the map represents the kind of a file. 50 | func (a *Avif) SupportedFormats() map[string][]string { 51 | return a.compatibleFormats 52 | } 53 | 54 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 55 | func (a *Avif) SupportedMIMETypes() map[string][]string { 56 | return a.compatibleMIMETypes 57 | } 58 | 59 | // ConvertTo method converts a given file to a target format. 60 | // This method returns a file in form of a slice of bytes. 61 | func (a *Avif) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 62 | compatibleFormats, ok := a.SupportedFormats()[fileType] 63 | if !ok { 64 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 65 | } 66 | 67 | if !slices.Contains(compatibleFormats, subType) { 68 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 69 | } 70 | 71 | switch strings.ToLower(fileType) { 72 | case imageType: 73 | convertedImage, err := convertToImage(subType, file) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return convertedImage, nil 79 | default: 80 | return nil, fmt.Errorf("not supported file type %s", fileType) 81 | } 82 | } 83 | 84 | // ImageType returns the file format of the current image. 85 | // This method implements the Image interface. 86 | func (a *Avif) ImageType() string { 87 | return AVIF 88 | } 89 | -------------------------------------------------------------------------------- /pkg/files/images/bmp.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "slices" 8 | "strings" 9 | 10 | "golang.org/x/image/bmp" 11 | ) 12 | 13 | // Bmp struct implements the File and Image interface from the files pkg. 14 | type Bmp struct { 15 | compatibleFormats map[string][]string 16 | compatibleMIMETypes map[string][]string 17 | } 18 | 19 | // NewBmp returns a pointer to a Bmp instance. 20 | // The Bmp object is set with a map with list of supported file formats. 21 | func NewBmp() *Bmp { 22 | b := Bmp{ 23 | compatibleFormats: map[string][]string{ 24 | "Image": { 25 | AVIF, 26 | JPG, 27 | JPEG, 28 | PNG, 29 | GIF, 30 | TIFF, 31 | WEBP, 32 | }, 33 | "Document": { 34 | PDF, 35 | }, 36 | }, 37 | compatibleMIMETypes: map[string][]string{ 38 | "Image": { 39 | AVIF, 40 | JPG, 41 | JPEG, 42 | PNG, 43 | GIF, 44 | TIFF, 45 | WEBP, 46 | }, 47 | "Document": { 48 | PDF, 49 | }, 50 | }, 51 | } 52 | 53 | return &b 54 | } 55 | 56 | // SupportedFormats returns a map with a slice of supported files. 57 | // Every key of the map represents a kind of a file. 58 | func (b *Bmp) SupportedFormats() map[string][]string { 59 | return b.compatibleFormats 60 | } 61 | 62 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 63 | func (b *Bmp) SupportedMIMETypes() map[string][]string { 64 | return b.compatibleMIMETypes 65 | } 66 | 67 | // ConvertTo method converts a given file to a target format. 68 | // This method returns a file in form of a slice of bytes. 69 | // The methd receives a file type and the sub-type of the target format and the file as array of bytes. 70 | func (b *Bmp) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 71 | var result []byte 72 | 73 | compatibleFormats, ok := b.SupportedFormats()[fileType] 74 | if !ok { 75 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 76 | } 77 | 78 | if !slices.Contains(compatibleFormats, subType) { 79 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 80 | } 81 | 82 | switch strings.ToLower(fileType) { 83 | case imageType: 84 | convertedImage, err := convertToImage(subType, file) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return convertedImage, nil 90 | case documentType: 91 | img, err := bmp.Decode(file) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | result, err = convertToDocument(subType, img) 97 | if err != nil { 98 | return nil, fmt.Errorf( 99 | "ConvertTo: error at converting image to another format: %w", 100 | err, 101 | ) 102 | } 103 | } 104 | 105 | return bytes.NewReader(result), nil 106 | } 107 | 108 | // ImageType returns the file format of the current image. 109 | // This method implements the Image interface. 110 | func (b *Bmp) ImageType() string { 111 | return BMP 112 | } 113 | -------------------------------------------------------------------------------- /pkg/files/images/gif.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/gif" 7 | "io" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | // Gif struct implements the File and Image interface from the files pkg. 13 | type Gif struct { 14 | compatibleFormats map[string][]string 15 | compatibleMIMETypes map[string][]string 16 | } 17 | 18 | // NewGif returns a pointer to a Gif instance. 19 | // The Gif object is set with a map with list of supported file formats. 20 | func NewGif() *Gif { 21 | g := Gif{ 22 | compatibleFormats: map[string][]string{ 23 | "Image": { 24 | AVIF, 25 | JPG, 26 | JPEG, 27 | PNG, 28 | WEBP, 29 | TIFF, 30 | BMP, 31 | }, 32 | "Document": { 33 | PDF, 34 | }, 35 | }, 36 | compatibleMIMETypes: map[string][]string{ 37 | "Image": { 38 | AVIF, 39 | JPG, 40 | JPEG, 41 | PNG, 42 | WEBP, 43 | TIFF, 44 | BMP, 45 | }, 46 | "Document": { 47 | PDF, 48 | }, 49 | }, 50 | } 51 | 52 | return &g 53 | } 54 | 55 | // SupportedFormats returns a map with a slice of supported files. 56 | // Every key of the map represents the kind of a file. 57 | func (g *Gif) SupportedFormats() map[string][]string { 58 | return g.compatibleFormats 59 | } 60 | 61 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 62 | func (g *Gif) SupportedMIMETypes() map[string][]string { 63 | return g.compatibleMIMETypes 64 | } 65 | 66 | // ConvertTo method converts a given file to a target format. 67 | // This method returns a file in form of a slice of bytes. 68 | // The methd receives a file type and the sub-type of the target format and the file as array of bytes. 69 | func (g *Gif) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 70 | var result []byte 71 | 72 | compatibleFormats, ok := g.SupportedFormats()[fileType] 73 | if !ok { 74 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 75 | } 76 | 77 | if !slices.Contains(compatibleFormats, subType) { 78 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 79 | } 80 | 81 | switch strings.ToLower(fileType) { 82 | case imageType: 83 | convertedImage, err := convertToImage(subType, file) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return convertedImage, nil 89 | case documentType: 90 | img, err := gif.Decode(file) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | result, err = convertToDocument(subType, img) 96 | if err != nil { 97 | return nil, fmt.Errorf( 98 | "ConvertTo: error at converting image to another format: %w", 99 | err, 100 | ) 101 | } 102 | } 103 | 104 | return bytes.NewReader(result), nil 105 | } 106 | 107 | // ImageType returns the file format of the current image. 108 | // This method implements the Image interface. 109 | func (g *Gif) ImageType() string { 110 | return GIF 111 | } 112 | -------------------------------------------------------------------------------- /pkg/files/images/images.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "io" 8 | "math/rand" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/signintech/gopdf" 14 | ffmpeg "github.com/u2takey/ffmpeg-go" 15 | ) 16 | 17 | const ( 18 | // Images. 19 | PNG = "png" 20 | JPEG = "jpeg" 21 | JPG = "jpg" 22 | GIF = "gif" 23 | WEBP = "webp" 24 | TIFF = "tiff" 25 | BMP = "bmp" 26 | AVIF = "avif" 27 | 28 | imageMimeType = "image/" 29 | imageType = "image" 30 | 31 | // Documents. 32 | PDF = "pdf" 33 | 34 | documentMimeType = "application/" 35 | documentType = "document" 36 | 37 | // letters is constant that used as a pool of letters to generate a random string. 38 | letters = "abcdefghijklmnopqrstuvwxyz" + 39 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 40 | ) 41 | 42 | var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) 43 | 44 | // toPDF returns pdf file as an slice of bytes. 45 | // Receives an image.Image as a parameter. 46 | func toPDF(img image.Image) ([]byte, error) { 47 | // Sets a Rectangle based on the size of the image. 48 | imgRect := gopdf.Rect{ 49 | W: float64(img.Bounds().Dx()), 50 | H: float64(img.Bounds().Dy()), 51 | } 52 | 53 | // Init the pdf obkect. 54 | pdf := gopdf.GoPdf{} 55 | 56 | // Sets the size of the every pdf page, 57 | // based on the dimensions of the image. 58 | pdf.Start( 59 | gopdf.Config{ 60 | PageSize: imgRect, 61 | }, 62 | ) 63 | 64 | // Add a page to the PDF. 65 | pdf.AddPage() 66 | 67 | // Draws the image on the rectangle on the page above created. 68 | if err := pdf.ImageFrom(img, 0, 0, &imgRect); err != nil { 69 | return nil, err 70 | } 71 | 72 | // Creates a bytes.Buffer and writes the pdf data to it. 73 | buf := new(bytes.Buffer) 74 | if _, err := pdf.WriteTo(buf); err != nil { 75 | return nil, err 76 | } 77 | 78 | // Returns the pdf data as slice of bytes. 79 | return buf.Bytes(), nil 80 | } 81 | 82 | func ParseMimeType(mimetype string) string { 83 | if !strings.Contains(mimetype, imageMimeType) { 84 | return mimetype 85 | } 86 | 87 | return strings.TrimPrefix(mimetype, imageMimeType) 88 | } 89 | 90 | // stringWithCharset returns a random string based a length and a charset. 91 | func stringWithCharset(length int, charset string) string { 92 | b := make([]byte, length) 93 | for i := range b { 94 | b[i] = charset[seededRand.Intn(len(charset))] 95 | } 96 | return string(b) 97 | } 98 | 99 | // randString returns a random string calling stringWithCharset and using the letters constant. 100 | func randString(length int) string { 101 | return stringWithCharset(length, letters) 102 | } 103 | 104 | // convertToImage retuns an image as io.Reader and error if something goes wrong. 105 | // It gets the target format as input alongside the image to be converted to that format. 106 | func convertToImage(target string, file io.Reader) (io.Reader, error) { 107 | // Create a buffer meant to store the input file data. 108 | buf := new(bytes.Buffer) 109 | if _, err := buf.ReadFrom(file); err != nil { 110 | return nil, fmt.Errorf( 111 | "error reading from the image file: %w", 112 | err, 113 | ) 114 | } 115 | 116 | // Get the bytes off the input image. 117 | inputReaderBytes := buf.Bytes() 118 | 119 | // Create a temporary empty file where the input image is gonna be stored. 120 | tmpInputImage, err := os.CreateTemp("/tmp", fmt.Sprintf("*.%s", target)) 121 | if err != nil { 122 | return nil, fmt.Errorf("error creating temporary image file: %w", err) 123 | } 124 | defer os.Remove(tmpInputImage.Name()) 125 | 126 | // Write the content of the input image into the temporary file. 127 | if _, err = tmpInputImage.Write(inputReaderBytes); err != nil { 128 | return nil, fmt.Errorf("error writting the input reader to the temporary image file") 129 | } 130 | 131 | tmpConvertedFilename := fmt.Sprintf("/tmp/%s.%s", randString(10), target) 132 | 133 | // Convert the input image to the target format. 134 | // This is calling the ffmpeg command under the hood. 135 | // The reason behind this is that we could avoid using different libraries, 136 | // when we can use a use a single tool for multiple things. 137 | if err = ffmpeg.Input(tmpInputImage.Name()). 138 | Output(tmpConvertedFilename). 139 | OverWriteOutput().ErrorToStdOut().Run(); err != nil { 140 | return nil, err 141 | } 142 | 143 | // Open the converted file to get the bytes out of it, 144 | // and then turning them into a io.Reader. 145 | cf, err := os.Open(tmpConvertedFilename) 146 | if err != nil { 147 | return nil, err 148 | } 149 | defer os.Remove(cf.Name()) 150 | 151 | fileBytes, err := io.ReadAll(cf) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return bytes.NewReader(fileBytes), nil 157 | } 158 | 159 | func convertToDocument(target string, img image.Image) ([]byte, error) { 160 | var err error 161 | var result []byte 162 | 163 | switch target { 164 | case PDF: 165 | result, err = toPDF(img) 166 | if err != nil { 167 | return nil, err 168 | } 169 | } 170 | 171 | return result, nil 172 | } 173 | -------------------------------------------------------------------------------- /pkg/files/images/images_test.go: -------------------------------------------------------------------------------- 1 | package images_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/gabriel-vasile/mimetype" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/danvergara/morphos/pkg/files/images" 13 | ) 14 | 15 | type filer interface { 16 | SupportedFormats() map[string][]string 17 | ConvertTo(string, string, io.Reader) (io.Reader, error) 18 | } 19 | 20 | type imager interface { 21 | filer 22 | ImageType() string 23 | } 24 | 25 | func TestConvertImage(t *testing.T) { 26 | type input struct { 27 | filename string 28 | mimetype string 29 | targetFileType string 30 | targetFormat string 31 | imager imager 32 | } 33 | type expected struct { 34 | mimetype string 35 | supportedFormats map[string][]string 36 | } 37 | var tests = []struct { 38 | name string 39 | input input 40 | expected expected 41 | }{ 42 | { 43 | name: "avif to jpeg", 44 | input: input{ 45 | filename: "testdata/fox.avif", 46 | mimetype: "image/avif", 47 | targetFileType: "Image", 48 | targetFormat: "jpeg", 49 | imager: images.NewAvif(), 50 | }, 51 | expected: expected{ 52 | mimetype: "image/jpeg", 53 | supportedFormats: map[string][]string{ 54 | "Image": { 55 | images.JPG, 56 | images.JPEG, 57 | images.PNG, 58 | images.GIF, 59 | images.WEBP, 60 | images.TIFF, 61 | images.BMP, 62 | }, 63 | }, 64 | }, 65 | }, 66 | { 67 | name: "png to jpeg", 68 | input: input{ 69 | filename: "testdata/gopher_pirate.png", 70 | mimetype: "image/png", 71 | targetFileType: "Image", 72 | targetFormat: "jpeg", 73 | imager: images.NewPng(), 74 | }, 75 | expected: expected{ 76 | mimetype: "image/jpeg", 77 | supportedFormats: map[string][]string{ 78 | "Image": { 79 | images.AVIF, 80 | images.JPG, 81 | images.JPEG, 82 | images.GIF, 83 | images.WEBP, 84 | images.TIFF, 85 | images.BMP, 86 | }, 87 | "Document": { 88 | images.PDF, 89 | }, 90 | }, 91 | }, 92 | }, 93 | { 94 | name: "jpeg to png", 95 | input: input{ 96 | filename: "testdata/Golang_Gopher.jpg", 97 | mimetype: "image/jpeg", 98 | targetFileType: "Image", 99 | targetFormat: "png", 100 | imager: images.NewJpeg(), 101 | }, 102 | expected: expected{ 103 | mimetype: "image/png", 104 | supportedFormats: map[string][]string{ 105 | "Image": { 106 | images.AVIF, 107 | images.PNG, 108 | images.GIF, 109 | images.WEBP, 110 | images.TIFF, 111 | images.BMP, 112 | }, 113 | "Document": { 114 | images.PDF, 115 | }, 116 | }, 117 | }, 118 | }, 119 | { 120 | name: "webp to png", 121 | input: input{ 122 | filename: "testdata/gopher.webp", 123 | mimetype: "image/webp", 124 | targetFileType: "Image", 125 | targetFormat: "png", 126 | imager: images.NewWebp(), 127 | }, 128 | expected: expected{ 129 | mimetype: "image/png", 130 | supportedFormats: map[string][]string{ 131 | "Image": { 132 | images.AVIF, 133 | images.JPG, 134 | images.JPEG, 135 | images.PNG, 136 | images.GIF, 137 | images.TIFF, 138 | images.BMP, 139 | }, 140 | "Document": { 141 | images.PDF, 142 | }, 143 | }, 144 | }, 145 | }, 146 | { 147 | name: "png to webp", 148 | input: input{ 149 | filename: "testdata/gopher_pirate.png", 150 | mimetype: "image/png", 151 | targetFileType: "Image", 152 | targetFormat: "webp", 153 | imager: images.NewPng(), 154 | }, 155 | expected: expected{ 156 | mimetype: "image/webp", 157 | supportedFormats: map[string][]string{ 158 | "Image": { 159 | images.AVIF, 160 | images.JPG, 161 | images.JPEG, 162 | images.GIF, 163 | images.WEBP, 164 | images.TIFF, 165 | images.BMP, 166 | }, 167 | "Document": { 168 | images.PDF, 169 | }, 170 | }, 171 | }, 172 | }, 173 | { 174 | name: "webp to tiff", 175 | input: input{ 176 | filename: "testdata/gopher.webp", 177 | mimetype: "image/webp", 178 | targetFileType: "Image", 179 | targetFormat: "tiff", 180 | imager: images.NewWebp(), 181 | }, 182 | expected: expected{ 183 | mimetype: "image/tiff", 184 | supportedFormats: map[string][]string{ 185 | "Image": { 186 | images.AVIF, 187 | images.JPG, 188 | images.JPEG, 189 | images.PNG, 190 | images.GIF, 191 | images.TIFF, 192 | images.BMP, 193 | }, 194 | "Document": { 195 | images.PDF, 196 | }, 197 | }, 198 | }, 199 | }, 200 | { 201 | name: "bmp to png", 202 | input: input{ 203 | filename: "testdata/sunset.bmp", 204 | mimetype: "image/bmp", 205 | targetFileType: "Image", 206 | targetFormat: "png", 207 | imager: images.NewBmp(), 208 | }, 209 | expected: expected{ 210 | mimetype: "image/png", 211 | supportedFormats: map[string][]string{ 212 | "Image": { 213 | images.AVIF, 214 | images.JPG, 215 | images.JPEG, 216 | images.PNG, 217 | images.GIF, 218 | images.TIFF, 219 | images.WEBP, 220 | }, 221 | "Document": { 222 | images.PDF, 223 | }, 224 | }, 225 | }, 226 | }, 227 | { 228 | name: "jpg to bmp", 229 | input: input{ 230 | filename: "testdata/Golang_Gopher.jpg", 231 | mimetype: "image/jpeg", 232 | targetFileType: "Image", 233 | targetFormat: "bmp", 234 | imager: images.NewJpeg(), 235 | }, 236 | expected: expected{ 237 | mimetype: "image/bmp", 238 | supportedFormats: map[string][]string{ 239 | "Image": { 240 | images.AVIF, 241 | images.PNG, 242 | images.GIF, 243 | images.WEBP, 244 | images.TIFF, 245 | images.BMP, 246 | }, 247 | "Document": { 248 | images.PDF, 249 | }, 250 | }, 251 | }, 252 | }, 253 | } 254 | 255 | for _, tc := range tests { 256 | tc := tc 257 | t.Run(tc.name, func(t *testing.T) { 258 | t.Parallel() 259 | 260 | inputImg, err := os.ReadFile(tc.input.filename) 261 | require.NoError(t, err) 262 | 263 | detectedFileType := mimetype.Detect(inputImg) 264 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 265 | 266 | convertedImg, err := tc.input.imager.ConvertTo( 267 | tc.input.targetFileType, 268 | tc.input.targetFormat, 269 | bytes.NewReader(inputImg), 270 | ) 271 | 272 | require.NoError(t, err) 273 | 274 | buf := new(bytes.Buffer) 275 | _, err = buf.ReadFrom(convertedImg) 276 | require.NoError(t, err) 277 | 278 | convertedImgBytes := buf.Bytes() 279 | 280 | detectedFileType = mimetype.Detect(convertedImgBytes) 281 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 282 | formats := tc.input.imager.SupportedFormats() 283 | require.EqualValues(t, tc.expected.supportedFormats, formats) 284 | }) 285 | } 286 | } 287 | 288 | func TestConvertImageToDocument(t *testing.T) { 289 | type input struct { 290 | filename string 291 | mimetype string 292 | targetFileType string 293 | targetFormat string 294 | imager imager 295 | } 296 | type expected struct { 297 | mimetype string 298 | supportedFormats map[string][]string 299 | } 300 | 301 | var tests = []struct { 302 | name string 303 | input input 304 | expected expected 305 | }{ 306 | { 307 | name: "png to pdf", 308 | input: input{ 309 | filename: "testdata/gopher_pirate.png", 310 | mimetype: "image/png", 311 | targetFileType: "Document", 312 | targetFormat: "pdf", 313 | imager: images.NewPng(), 314 | }, 315 | expected: expected{ 316 | mimetype: "application/pdf", 317 | supportedFormats: map[string][]string{ 318 | "Image": { 319 | images.AVIF, 320 | images.JPG, 321 | images.JPEG, 322 | images.GIF, 323 | images.WEBP, 324 | images.TIFF, 325 | images.BMP, 326 | }, 327 | "Document": { 328 | images.PDF, 329 | }, 330 | }, 331 | }, 332 | }, 333 | { 334 | name: "jpg to pdf", 335 | input: input{ 336 | filename: "testdata/Golang_Gopher.jpg", 337 | mimetype: "image/jpeg", 338 | targetFileType: "Document", 339 | targetFormat: "pdf", 340 | imager: images.NewJpeg(), 341 | }, 342 | expected: expected{ 343 | mimetype: "application/pdf", 344 | supportedFormats: map[string][]string{ 345 | "Image": { 346 | images.AVIF, 347 | images.PNG, 348 | images.GIF, 349 | images.WEBP, 350 | images.TIFF, 351 | images.BMP, 352 | }, 353 | "Document": { 354 | images.PDF, 355 | }, 356 | }, 357 | }, 358 | }, 359 | { 360 | name: "bmp to pdf", 361 | input: input{ 362 | filename: "testdata/sunset.bmp", 363 | mimetype: "image/bmp", 364 | targetFileType: "Document", 365 | targetFormat: "pdf", 366 | imager: images.NewBmp(), 367 | }, 368 | expected: expected{ 369 | mimetype: "application/pdf", 370 | supportedFormats: map[string][]string{ 371 | "Image": { 372 | images.AVIF, 373 | images.JPG, 374 | images.JPEG, 375 | images.PNG, 376 | images.GIF, 377 | images.TIFF, 378 | images.WEBP, 379 | }, 380 | "Document": { 381 | images.PDF, 382 | }, 383 | }, 384 | }, 385 | }, 386 | { 387 | name: "gif to pdf", 388 | input: input{ 389 | filename: "testdata/dancing-gopher.gif", 390 | mimetype: "image/gif", 391 | targetFileType: "Document", 392 | targetFormat: "pdf", 393 | imager: images.NewGif(), 394 | }, 395 | expected: expected{ 396 | mimetype: "application/pdf", 397 | supportedFormats: map[string][]string{ 398 | "Image": { 399 | images.AVIF, 400 | images.JPG, 401 | images.JPEG, 402 | images.PNG, 403 | images.WEBP, 404 | images.TIFF, 405 | images.BMP, 406 | }, 407 | "Document": { 408 | images.PDF, 409 | }, 410 | }, 411 | }, 412 | }, 413 | { 414 | name: "webp to pdf", 415 | input: input{ 416 | filename: "testdata/gopher.webp", 417 | mimetype: "image/webp", 418 | targetFileType: "Document", 419 | targetFormat: "pdf", 420 | imager: images.NewWebp(), 421 | }, 422 | expected: expected{ 423 | mimetype: "application/pdf", 424 | supportedFormats: map[string][]string{ 425 | "Image": { 426 | images.AVIF, 427 | images.JPG, 428 | images.JPEG, 429 | images.PNG, 430 | images.GIF, 431 | images.TIFF, 432 | images.BMP, 433 | }, 434 | "Document": { 435 | images.PDF, 436 | }, 437 | }, 438 | }, 439 | }, 440 | } 441 | 442 | for _, tc := range tests { 443 | tc := tc 444 | t.Run(tc.name, func(t *testing.T) { 445 | t.Parallel() 446 | 447 | inputImg, err := os.ReadFile(tc.input.filename) 448 | require.NoError(t, err) 449 | 450 | detectedFileType := mimetype.Detect(inputImg) 451 | require.Equal(t, tc.input.mimetype, detectedFileType.String()) 452 | 453 | convertedImg, err := tc.input.imager.ConvertTo( 454 | tc.input.targetFileType, 455 | tc.input.targetFormat, 456 | bytes.NewReader(inputImg), 457 | ) 458 | 459 | require.NoError(t, err) 460 | buf := new(bytes.Buffer) 461 | 462 | _, err = buf.ReadFrom(convertedImg) 463 | require.NoError(t, err) 464 | 465 | convertedImgBytes := buf.Bytes() 466 | 467 | detectedFileType = mimetype.Detect(convertedImgBytes) 468 | require.Equal(t, tc.expected.mimetype, detectedFileType.String()) 469 | formats := tc.input.imager.SupportedFormats() 470 | require.EqualValues(t, tc.expected.supportedFormats, formats) 471 | }) 472 | } 473 | } 474 | 475 | func TestParseMimeType(t *testing.T) { 476 | parsedType := images.ParseMimeType("image/png") 477 | require.Equal(t, parsedType, "png") 478 | } 479 | -------------------------------------------------------------------------------- /pkg/files/images/jpeg.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/draw" 8 | "image/jpeg" 9 | "io" 10 | "slices" 11 | "strings" 12 | ) 13 | 14 | // Jpeg struct implements the File and Image interface from the files pkg. 15 | type Jpeg struct { 16 | compatibleFormats map[string][]string 17 | compatibleMIMETypes map[string][]string 18 | } 19 | 20 | // NewJpeg returns a pointer to a Jpeg instance. 21 | // The Jpeg object is set with a map with list of supported file formats. 22 | func NewJpeg() *Jpeg { 23 | j := Jpeg{ 24 | compatibleFormats: map[string][]string{ 25 | "Image": { 26 | AVIF, 27 | PNG, 28 | GIF, 29 | WEBP, 30 | TIFF, 31 | BMP, 32 | }, 33 | "Document": { 34 | PDF, 35 | }, 36 | }, 37 | 38 | compatibleMIMETypes: map[string][]string{ 39 | "Image": { 40 | AVIF, 41 | PNG, 42 | GIF, 43 | WEBP, 44 | TIFF, 45 | BMP, 46 | }, 47 | "Document": { 48 | PDF, 49 | }, 50 | }, 51 | } 52 | 53 | return &j 54 | } 55 | 56 | // SupportedFormats returns a map with a slice of supported files. 57 | // Every key of the map represents a kind of a file. 58 | func (j *Jpeg) SupportedFormats() map[string][]string { 59 | return j.compatibleFormats 60 | } 61 | 62 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 63 | func (j *Jpeg) SupportedMIMETypes() map[string][]string { 64 | return j.compatibleMIMETypes 65 | } 66 | 67 | // ConvertTo method converts a given file to a target format. 68 | // This method returns a file in form of a slice of bytes. 69 | // The methd receives a file type and the sub-type of the target format and the file as array of bytes. 70 | func (j *Jpeg) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 71 | var result []byte 72 | 73 | compatibleFormats, ok := j.SupportedFormats()[fileType] 74 | if !ok { 75 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 76 | } 77 | 78 | if !slices.Contains(compatibleFormats, subType) { 79 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 80 | } 81 | 82 | switch strings.ToLower(fileType) { 83 | case imageType: 84 | convertedImage, err := convertToImage(subType, file) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return convertedImage, nil 90 | case documentType: 91 | img, err := jpeg.Decode(file) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | rgba := image.NewRGBA(img.Bounds()) 97 | draw.Draw(rgba, img.Bounds(), img, image.Point{}, draw.Src) 98 | 99 | result, err = convertToDocument(subType, rgba) 100 | if err != nil { 101 | return nil, fmt.Errorf( 102 | "ConvertTo: error at converting image to another format: %w", 103 | err, 104 | ) 105 | } 106 | } 107 | 108 | return bytes.NewReader(result), nil 109 | } 110 | 111 | // ImageType returns the file format of the current image. 112 | // This method implements the Image interface. 113 | func (j *Jpeg) ImageType() string { 114 | return JPEG 115 | } 116 | -------------------------------------------------------------------------------- /pkg/files/images/png.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/draw" 8 | "image/png" 9 | "io" 10 | "slices" 11 | "strings" 12 | ) 13 | 14 | // Png struct implements the File and Image interface from the files pkg. 15 | type Png struct { 16 | compatibleFormats map[string][]string 17 | compatibleMIMETypes map[string][]string 18 | } 19 | 20 | // NewPng returns a pointer to a Png instance. 21 | // The Png object is set with a map with list of supported file formats. 22 | func NewPng() *Png { 23 | p := Png{ 24 | compatibleFormats: map[string][]string{ 25 | "Image": { 26 | AVIF, 27 | JPG, 28 | JPEG, 29 | GIF, 30 | WEBP, 31 | TIFF, 32 | BMP, 33 | }, 34 | "Document": { 35 | PDF, 36 | }, 37 | }, 38 | compatibleMIMETypes: map[string][]string{ 39 | "Image": { 40 | AVIF, 41 | JPG, 42 | JPEG, 43 | GIF, 44 | WEBP, 45 | TIFF, 46 | BMP, 47 | }, 48 | "Document": { 49 | PDF, 50 | }, 51 | }, 52 | } 53 | 54 | return &p 55 | } 56 | 57 | // SupportedFormats returns a map with a slice of supported files. 58 | // Every key of the map represents the kind of a file. 59 | func (p *Png) SupportedFormats() map[string][]string { 60 | return p.compatibleFormats 61 | } 62 | 63 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 64 | func (p *Png) SupportedMIMETypes() map[string][]string { 65 | return p.compatibleMIMETypes 66 | } 67 | 68 | // ConvertTo method converts a given file to a target format. 69 | // This method returns a file in form of a slice of bytes. 70 | func (p *Png) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 71 | var result []byte 72 | 73 | compatibleFormats, ok := p.SupportedFormats()[fileType] 74 | if !ok { 75 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 76 | } 77 | 78 | if !slices.Contains(compatibleFormats, subType) { 79 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 80 | } 81 | 82 | switch strings.ToLower(fileType) { 83 | case imageType: 84 | convertedImage, err := convertToImage(subType, file) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return convertedImage, nil 90 | case documentType: 91 | img, err := png.Decode(file) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | rgba := image.NewRGBA(img.Bounds()) 97 | draw.Draw(rgba, img.Bounds(), img, image.Point{}, draw.Src) 98 | 99 | result, err = convertToDocument(subType, rgba) 100 | if err != nil { 101 | return nil, fmt.Errorf( 102 | "ConvertTo: error at converting image to another format: %w", 103 | err, 104 | ) 105 | } 106 | } 107 | 108 | return bytes.NewReader(result), nil 109 | } 110 | 111 | // ImageType returns the file format of the current image. 112 | // This method implements the Image interface. 113 | func (p *Png) ImageType() string { 114 | return PNG 115 | } 116 | -------------------------------------------------------------------------------- /pkg/files/images/testdata/Golang_Gopher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/Golang_Gopher.jpg -------------------------------------------------------------------------------- /pkg/files/images/testdata/dancing-gopher.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/dancing-gopher.gif -------------------------------------------------------------------------------- /pkg/files/images/testdata/fox.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/fox.avif -------------------------------------------------------------------------------- /pkg/files/images/testdata/gopher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/gopher.webp -------------------------------------------------------------------------------- /pkg/files/images/testdata/gopher_pirate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/gopher_pirate.png -------------------------------------------------------------------------------- /pkg/files/images/testdata/sunset.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/pkg/files/images/testdata/sunset.bmp -------------------------------------------------------------------------------- /pkg/files/images/tiff.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "slices" 8 | "strings" 9 | 10 | "golang.org/x/image/tiff" 11 | ) 12 | 13 | // Tiff struct implements the File and Image interface from the files pkg. 14 | type Tiff struct { 15 | compatibleFormats map[string][]string 16 | compatibleMIMETypes map[string][]string 17 | } 18 | 19 | // NewTiff returns a pointer to a Tiff instance. 20 | // The Tiff object is set with a map with list of supported file formats. 21 | func NewTiff() *Tiff { 22 | t := Tiff{ 23 | compatibleFormats: map[string][]string{ 24 | "Image": { 25 | AVIF, 26 | JPG, 27 | JPEG, 28 | PNG, 29 | GIF, 30 | WEBP, 31 | BMP, 32 | }, 33 | "Document": { 34 | PDF, 35 | }, 36 | }, 37 | compatibleMIMETypes: map[string][]string{ 38 | "Image": { 39 | AVIF, 40 | JPG, 41 | JPEG, 42 | PNG, 43 | GIF, 44 | WEBP, 45 | BMP, 46 | }, 47 | "Document": { 48 | PDF, 49 | }, 50 | }, 51 | } 52 | 53 | return &t 54 | } 55 | 56 | // SupportedFormats method returns a map with a slice of supported files. 57 | // Every key of the map represents the kind of a file. 58 | func (t *Tiff) SupportedFormats() map[string][]string { 59 | return t.compatibleFormats 60 | } 61 | 62 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 63 | func (t *Tiff) SupportedMIMETypes() map[string][]string { 64 | return t.compatibleMIMETypes 65 | } 66 | 67 | // ConvertTo method converts a given file to a target format. 68 | // This method returns a file in form of a slice of bytes. 69 | func (t *Tiff) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 70 | 71 | var result []byte 72 | 73 | compatibleFormats, ok := t.SupportedFormats()[fileType] 74 | if !ok { 75 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 76 | } 77 | 78 | if !slices.Contains(compatibleFormats, subType) { 79 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 80 | } 81 | 82 | switch strings.ToLower(fileType) { 83 | case imageType: 84 | convertedImage, err := convertToImage(subType, file) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return convertedImage, nil 90 | case documentType: 91 | img, err := tiff.Decode(file) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | result, err = convertToDocument(subType, img) 97 | if err != nil { 98 | return nil, fmt.Errorf( 99 | "ConvertTo: error at converting image to another format: %w", 100 | err, 101 | ) 102 | } 103 | } 104 | 105 | return bytes.NewReader(result), nil 106 | } 107 | 108 | // ImageType returns the file format of the current image. 109 | // This method implements the Image interface. 110 | func (t *Tiff) ImageType() string { 111 | return TIFF 112 | } 113 | -------------------------------------------------------------------------------- /pkg/files/images/webp.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/draw" 8 | "io" 9 | "slices" 10 | "strings" 11 | 12 | "golang.org/x/image/webp" 13 | ) 14 | 15 | // Webp struct implements the File and Image interface from the files pkg. 16 | type Webp struct { 17 | compatibleFormats map[string][]string 18 | compatibleMIMETypes map[string][]string 19 | } 20 | 21 | // NewWebp returns a pointer to a Webp instance. 22 | // The Webp object is set with a map with list of supported file formats. 23 | func NewWebp() *Webp { 24 | w := Webp{ 25 | compatibleFormats: map[string][]string{ 26 | "Image": { 27 | AVIF, 28 | JPG, 29 | JPEG, 30 | PNG, 31 | GIF, 32 | TIFF, 33 | BMP, 34 | }, 35 | "Document": { 36 | PDF, 37 | }, 38 | }, 39 | compatibleMIMETypes: map[string][]string{ 40 | "Image": { 41 | AVIF, 42 | JPG, 43 | JPEG, 44 | PNG, 45 | GIF, 46 | TIFF, 47 | BMP, 48 | }, 49 | "Document": { 50 | PDF, 51 | }, 52 | }, 53 | } 54 | 55 | return &w 56 | } 57 | 58 | // SupportedFormats returns a map with a slice of supported files. 59 | // Every key of the map represents the kind of a file. 60 | func (w *Webp) SupportedFormats() map[string][]string { 61 | return w.compatibleFormats 62 | } 63 | 64 | // SupportedMIMETypes returns a map with a slice of supported MIME types. 65 | func (w *Webp) SupportedMIMETypes() map[string][]string { 66 | return w.compatibleMIMETypes 67 | } 68 | 69 | // ConvertTo method converts a given file to a target format. 70 | // This method returns a file in form of a slice of bytes. 71 | func (w *Webp) ConvertTo(fileType, subType string, file io.Reader) (io.Reader, error) { 72 | 73 | var result []byte 74 | 75 | compatibleFormats, ok := w.SupportedFormats()[fileType] 76 | if !ok { 77 | return nil, fmt.Errorf("ConvertTo: file type not supported: %s", fileType) 78 | } 79 | 80 | if !slices.Contains(compatibleFormats, subType) { 81 | return nil, fmt.Errorf("ConvertTo: file sub-type not supported: %s", subType) 82 | } 83 | 84 | switch strings.ToLower(fileType) { 85 | case imageType: 86 | convertedImage, err := convertToImage(subType, file) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | return convertedImage, nil 92 | case documentType: 93 | img, err := webp.Decode(file) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | rgba := image.NewRGBA(img.Bounds()) 99 | draw.Draw(rgba, img.Bounds(), img, image.Point{}, draw.Src) 100 | 101 | result, err = convertToDocument(subType, rgba) 102 | if err != nil { 103 | return nil, fmt.Errorf( 104 | "ConvertTo: error at converting image to another format: %w", 105 | err, 106 | ) 107 | } 108 | } 109 | 110 | return bytes.NewReader(result), nil 111 | } 112 | 113 | // ImageType method returns the file format of the current image. 114 | // This method implements the Image interface. 115 | func (w *Webp) ImageType() string { 116 | return WEBP 117 | } 118 | -------------------------------------------------------------------------------- /pkg/files/mimetypes.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // TypeAndSupType returns a the type and the sub-type of a 9 | // given mimetype. 10 | // e.g. image/png 11 | // type: image 12 | // subtype: png 13 | func TypeAndSupType(mimetype string) (string, string, error) { 14 | types := strings.Split(mimetype, "/") 15 | 16 | if len(types) != 2 { 17 | return "", "", fmt.Errorf("%s not valid", mimetype) 18 | } 19 | 20 | return types[0], types[1], nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/files/mimetypes_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestTypeAndSupType(t *testing.T) { 10 | type expected struct { 11 | fileType string 12 | subType string 13 | hasErr bool 14 | } 15 | 16 | var tests = []struct { 17 | name string 18 | mimetype string 19 | expected expected 20 | }{} 21 | 22 | for _, tc := range tests { 23 | tc := tc 24 | t.Run(tc.name, func(t *testing.T) { 25 | fileType, subType, err := TypeAndSupType(tc.mimetype) 26 | if tc.expected.hasErr { 27 | require.Error(t, err) 28 | } 29 | 30 | require.NoError(t, err) 31 | require.Equal(t, tc.expected.fileType, fileType) 32 | require.Equal(t, tc.expected.subType, subType) 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "archive/zip" 5 | "bufio" 6 | "bytes" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | ) 15 | 16 | // EbookConvert calls the ebook-convert binary from the Calibre project. 17 | // It receives a inpunt format which is the format of the file passed to be converted, 18 | // and a target format which is the the format that the file is going to be converted to. 19 | // The function also receives the input file as an slice of bytes, which is the file that is 20 | // going to be converted. 21 | func EbookConvert(filename, inputFormat, outputFormat string, inputFile []byte) (io.Reader, error) { 22 | tmpInputFile, err := os.Create( 23 | fmt.Sprintf( 24 | "/tmp/%s.%s", 25 | strings.TrimSuffix(filename, filepath.Ext(filename)), 26 | inputFormat, 27 | ), 28 | ) 29 | if err != nil { 30 | return nil, fmt.Errorf("error creating temporary file: %w", err) 31 | } 32 | 33 | defer os.Remove(tmpInputFile.Name()) 34 | 35 | // Write the content of the input file into the temporary file. 36 | if _, err = tmpInputFile.Write(inputFile); err != nil { 37 | return nil, fmt.Errorf( 38 | "error writting the input reader to the temporary file", 39 | ) 40 | } 41 | if err := tmpInputFile.Close(); err != nil { 42 | return nil, err 43 | } 44 | 45 | // Parse the name of the output file. 46 | tmpOutputFileName := fmt.Sprintf( 47 | "%s.%s", 48 | strings.TrimSuffix(tmpInputFile.Name(), filepath.Ext(tmpInputFile.Name())), 49 | outputFormat, 50 | ) 51 | 52 | // run the ebook-convert command with the input file and the name of the output file. 53 | cmd := exec.Command("ebook-convert", tmpInputFile.Name(), tmpOutputFileName) 54 | 55 | // Capture stdout. 56 | stdout, err := cmd.StdoutPipe() 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | // Capture stderr. 62 | stderr, err := cmd.StderrPipe() 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | // Start the command. 68 | if err := cmd.Start(); err != nil { 69 | return nil, err 70 | } 71 | 72 | // Create readers to read stdout and stderr. 73 | stdoutScanner := bufio.NewScanner(stdout) 74 | stderrScanner := bufio.NewScanner(stderr) 75 | 76 | // Read stdout line by line. 77 | go func() { 78 | for stdoutScanner.Scan() { 79 | log.Println("STDOUT:", stdoutScanner.Text()) 80 | } 81 | }() 82 | 83 | // Read stderr line by line. 84 | go func() { 85 | for stderrScanner.Scan() { 86 | log.Println("STDERR:", stderrScanner.Text()) 87 | } 88 | }() 89 | 90 | // Wait for the command to finish. 91 | if err := cmd.Wait(); err != nil { 92 | return nil, err 93 | } 94 | 95 | // Open the converted file to get the bytes out of it, 96 | // and then turning them into a io.Reader. 97 | cf, err := os.Open(tmpOutputFileName) 98 | if err != nil { 99 | return nil, err 100 | } 101 | defer os.Remove(cf.Name()) 102 | 103 | // Parse the file name of the Zip file. 104 | zipFileName := fmt.Sprintf( 105 | "%s.zip", 106 | strings.TrimSuffix(filename, filepath.Ext(filename)), 107 | ) 108 | 109 | // Parse the output file name. 110 | outputFilename := fmt.Sprintf( 111 | "%s.%s", 112 | strings.TrimSuffix(filename, filepath.Ext(filename)), 113 | outputFormat, 114 | ) 115 | 116 | // Creates the zip file that will be returned. 117 | archive, err := os.CreateTemp("", zipFileName) 118 | if err != nil { 119 | return nil, fmt.Errorf( 120 | "error at creating the zip file to store the file: %w", 121 | err, 122 | ) 123 | } 124 | 125 | defer os.Remove(archive.Name()) 126 | 127 | // Creates a Zip Writer to add files later on. 128 | zipWriter := zip.NewWriter(archive) 129 | 130 | // Adds the image to the zip file. 131 | w1, err := zipWriter.Create(outputFilename) 132 | if err != nil { 133 | return nil, fmt.Errorf( 134 | "error creating the zip writer: %w", 135 | err, 136 | ) 137 | } 138 | 139 | // Copy the content of the converted file to the zip file. 140 | if _, err := io.Copy(w1, cf); err != nil { 141 | return nil, fmt.Errorf( 142 | "error at writing the file content to the zip writer: %w", 143 | err, 144 | ) 145 | } 146 | 147 | // Closes both zip writer and the zip file after its done with the writing. 148 | zipWriter.Close() 149 | archive.Close() 150 | 151 | // Reads the zip file as an slice of bytes. 152 | zipFile, err := os.ReadFile(archive.Name()) 153 | if err != nil { 154 | return nil, fmt.Errorf("error reading zip file: %v", err) 155 | } 156 | 157 | return bytes.NewReader(zipFile), nil 158 | } 159 | -------------------------------------------------------------------------------- /screenshots/download_file_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/download_file_morphos.png -------------------------------------------------------------------------------- /screenshots/file_converted_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/file_converted_morphos.png -------------------------------------------------------------------------------- /screenshots/file_uploaded_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/file_uploaded_morphos.png -------------------------------------------------------------------------------- /screenshots/modal_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/modal_morphos.png -------------------------------------------------------------------------------- /screenshots/morphos.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/morphos.jpg -------------------------------------------------------------------------------- /screenshots/morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/morphos.png -------------------------------------------------------------------------------- /screenshots/select_options_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/select_options_morphos.png -------------------------------------------------------------------------------- /screenshots/upload_file_morphos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/screenshots/upload_file_morphos.png -------------------------------------------------------------------------------- /static/htmx.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Y={onLoad:t,process:Pt,on:Z,off:K,trigger:fe,ajax:wr,find:E,findAll:f,closest:v,values:function(e,t){var r=nr(e,t||"post");return r.values},remove:U,addClass:B,removeClass:n,toggleClass:V,takeClass:j,defineExtension:qr,removeExtension:Hr,logAll:X,logNone:F,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false},parseInterval:d,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Y.config.wsBinaryType;return t},version:"1.9.6"};var r={addTriggerHandler:St,bodyContains:oe,canAccessLocalStorage:M,findThisElement:de,filterValues:lr,hasAttribute:o,getAttributeValue:ee,getClosestAttributeValue:re,getClosestMatch:c,getExpressionVars:xr,getHeaders:sr,getInputValues:nr,getInternalData:ie,getSwapSpecification:fr,getTriggerSpecs:Ze,getTarget:ge,makeFragment:l,mergeObjects:se,makeSettleInfo:T,oobSwap:ye,querySelectorExt:le,selectAndSwap:Fe,settleImmediately:Wt,shouldCancel:tt,triggerEvent:fe,triggerErrorEvent:ue,withExtensions:C};var b=["get","post","put","delete","patch"];var w=b.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function Q(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function ee(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){return e.parentElement}function te(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function O(e,t,r){var n=ee(t,r);var i=ee(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function re(t,r){var n=null;c(t,function(e){return n=O(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=te().createDocumentFragment()}return i}function H(e){return e.match(/",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i(""+e+"
",1);case"col":return i(""+e+"
",2);case"tr":return i(""+e+"
",2);case"td":case"th":return i(""+e+"
",3);case"script":case"style":return i("
"+e+"
",1);default:return i(e,0)}}}function ne(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ie(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r=0}function oe(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return te().body.contains(e.getRootNode().host)}else{return te().body.contains(e)}}function k(e){return e.trim().split(/\s+/)}function se(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){y(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return gr(te().body,function(){return eval(e)})}function t(t){var e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){Y.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function F(){Y.logger=null}function E(e,t){if(t){return e.querySelector(t)}else{return E(te(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(te(),e)}}function U(e,t){e=s(e);if(t){setTimeout(function(){U(e);e=null},t)}else{e.parentElement.removeChild(e)}}function B(e,t,r){e=s(e);if(r){setTimeout(function(){B(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function V(e,t){e=s(e);e.classList.toggle(t)}function j(e,t){e=s(e);ae(e.parentElement.children,function(e){n(e,t)});B(e,t)}function v(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function _(e,t){return e.substring(e.length-t.length)===t}function z(e){var t=e.trim();if(g(t,"<")&&_(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function W(e,t){if(t.indexOf("closest ")===0){return[v(e,z(t.substr(8)))]}else if(t.indexOf("find ")===0){return[E(e,z(t.substr(5)))]}else if(t.indexOf("next ")===0){return[$(e,z(t.substr(5)))]}else if(t.indexOf("previous ")===0){return[G(e,z(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return te().querySelectorAll(z(t))}}var $=function(e,t){var r=te().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function le(e,t){if(t){return W(e,t)[0]}else{return W(te().body,e)[0]}}function s(e){if(L(e,"String")){return E(e)}else{return e}}function J(e,t,r){if(A(t)){return{target:te().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function Z(t,r,n){Nr(function(){var e=J(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=A(r);return e?r:n}function K(t,r,n){Nr(function(){var e=J(t,r,n);e.target.removeEventListener(e.event,e.listener)});return A(r)?r:n}var he=te().createElement("output");function ve(e,t){var r=re(e,t);if(r){if(r==="this"){return[de(e,t)]}else{var n=W(e,r);if(n.length===0){y('The selector "'+r+'" on '+t+" returned no matches!");return[he]}else{return n}}}}function de(e,t){return c(e,function(e){return ee(e,t)!=null})}function ge(e){var t=re(e,"hx-target");if(t){if(t==="this"){return de(e,"hx-target")}else{return le(e,t)}}else{var r=ie(e);if(r.boosted){return te().body}else{return e}}}function me(e){var t=Y.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=te().querySelectorAll(t);if(r){ae(r,function(e){var t;var r=i.cloneNode(true);t=te().createDocumentFragment();t.appendChild(r);if(!xe(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!fe(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){De(o,e,e,t,a)}ae(a.elts,function(e){fe(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);ue(te().body,"htmx:oobErrorNoTarget",{content:i})}return e}function be(e,t,r){var n=re(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();pe(e,i);s.tasks.push(function(){pe(e,a)})}}})}function Ee(e){return function(){n(e,Y.config.addedClass);Pt(e);Ct(e);Ce(e);fe(e,"htmx:load")}}function Ce(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Se(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;B(i,Y.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Ee(i))}}}function Te(e,t){var r=0;while(r-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function Fe(e,t,r,n,i,a){i.title=Xe(n);var o=l(n);if(o){be(r,o,i);o=Me(r,o,a);we(o);return De(e,r,t,o,i)}}function Ue(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=S(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!N(o)){o={value:o}}fe(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=gr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){ue(te().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if($e(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function x(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}var Je="input, textarea, select";function Ze(e){var t=ee(e,"hx-trigger");var r=[];if(t){var n=We(t);do{x(n,ze);var i=n.length;var a=x(n,/[,\[\s]/);if(a!==""){if(a==="every"){var o={trigger:"every"};x(n,ze);o.pollInterval=d(x(n,/[,\[\s]/));x(n,ze);var s=Ge(e,n,"event");if(s){o.eventFilter=s}r.push(o)}else if(a.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:a.substr(4)})}else{var l={trigger:a};var s=Ge(e,n,"event");if(s){l.eventFilter=s}while(n.length>0&&n[0]!==","){x(n,ze);var u=n.shift();if(u==="changed"){l.changed=true}else if(u==="once"){l.once=true}else if(u==="consume"){l.consume=true}else if(u==="delay"&&n[0]===":"){n.shift();l.delay=d(x(n,p))}else if(u==="from"&&n[0]===":"){n.shift();var f=x(n,p);if(f==="closest"||f==="find"||f==="next"||f==="previous"){n.shift();f+=" "+x(n,p)}l.from=f}else if(u==="target"&&n[0]===":"){n.shift();l.target=x(n,p)}else if(u==="throttle"&&n[0]===":"){n.shift();l.throttle=d(x(n,p))}else if(u==="queue"&&n[0]===":"){n.shift();l.queue=x(n,p)}else if((u==="root"||u==="threshold")&&n[0]===":"){n.shift();l[u]=x(n,p)}else{ue(e,"htmx:syntax:error",{token:n.shift()})}}r.push(l)}}if(n.length===i){ue(e,"htmx:syntax:error",{token:n.shift()})}x(n,ze)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,Je)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function Ke(e){ie(e).cancelled=true}function Ye(e,t,r){var n=ie(e);n.timeout=setTimeout(function(){if(oe(e)&&n.cancelled!==true){if(!nt(r,e,Mt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}Ye(e,t,r)}},r.pollInterval)}function Qe(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function et(t,r,e){if(t.tagName==="A"&&Qe(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=Q(t,"href")}else{var a=Q(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=Q(t,"action")}e.forEach(function(e){it(t,function(e,t){if(v(e,Y.config.disableSelector)){m(e);return}ce(n,i,e,t)},r,e,true)})}}function tt(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function rt(e,t){return ie(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function nt(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){ue(te().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function it(a,o,e,s,l){var u=ie(a);var t;if(s.from){t=W(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ie(e);t.lastValue=e.value})}ae(t,function(n){var i=function(e){if(!oe(a)){n.removeEventListener(s.trigger,i);return}if(rt(a,e)){return}if(l||tt(e,a)){e.preventDefault()}if(nt(s,a,e)){return}var t=ie(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ie(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{fe(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var at=false;var ot=null;function st(){if(!ot){ot=function(){at=true};window.addEventListener("scroll",ot);setInterval(function(){if(at){at=false;ae(te().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){lt(e)})}},200)}}function lt(t){if(!o(t,"data-hx-revealed")&&P(t)){t.setAttribute("data-hx-revealed","true");var e=ie(t);if(e.initHash){fe(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){fe(t,"revealed")},{once:true})}}}function ut(e,t,r){var n=k(r);for(var i=0;i=0){var t=vt(n);setTimeout(function(){ft(s,r,n+1)},t)}};t.onopen=function(e){n=0};ie(s).webSocket=t;t.addEventListener("message",function(e){if(ct(s)){return}var t=e.data;C(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=I(n.children);for(var a=0;a0){fe(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(tt(e,u)){e.preventDefault()}})}else{ue(u,"htmx:noWebSocketSourceError")}}function vt(e){var t=Y.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}y('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function dt(e,t,r){var n=k(r);for(var i=0;i0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Ht(o)}for(var l in r){Lt(e,l,r[l])}}}function Nt(t){Oe(t);for(var e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Bt(e){if(!M()){return null}e=D(e);var t=S(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){fe(te().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Ft();var r=T(t);var n=Xe(this.response);if(n){var i=E("title");if(i){i.innerHTML=n}else{window.document.title=n}}ke(t,e,r);Wt(r.tasks);Xt=a;fe(te().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{ue(te().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function Gt(e){jt();e=e||location.pathname+location.search;var t=Bt(e);if(t){var r=l(t.content);var n=Ft();var i=T(n);ke(n,r,i);Wt(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Xt=e;fe(te().body,"htmx:historyRestore",{path:e,item:t})}else{if(Y.config.refreshOnHistoryMiss){window.location.reload(true)}else{$t(e)}}}function Jt(e){var t=ve(e,"hx-indicator");if(t==null){t=[e]}ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Y.config.requestClass)});return t}function Zt(e){var t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function Kt(e,t){ae(e,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Y.config.requestClass)}});ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function Yt(e,t){for(var r=0;r=0}function fr(e,t){var r=t?t:re(e,"hx-swap");var n={swapStyle:ie(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(ie(e).boosted&&!ur(e)){n["show"]="top"}if(r){var i=k(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{y("Unknown modifier in hx-swap: "+o)}}}}return n}function cr(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function hr(t,r,n){var i=null;C(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(cr(r)){return or(n)}else{return ar(n)}}}function T(e){return{tasks:[],elts:[e]}}function vr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=le(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=le(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function dr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=ee(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=gr(e,function(){return Function("return ("+a+")")()},{})}else{s=S(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return dr(u(e),t,r,n)}function gr(e,t,r){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return r}}function mr(e,t){return dr(e,"hx-vars",true,t)}function pr(e,t){return dr(e,"hx-vals",false,t)}function xr(e){return se(mr(e),pr(e))}function yr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function br(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return e.getAllResponseHeaders().match(t)}function wr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||L(r,"String")){return ce(e,t,null,null,{targetOverride:s(r),returnPromise:true})}else{return ce(e,t,s(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:s(r.target),swapOverride:r.swap,returnPromise:true})}}else{return ce(e,t,null,null,{returnPromise:true})}}function Sr(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function Er(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Y.config.selfRequestsOnly){if(!n){return false}}return fe(e,"htmx:validateUrl",se({url:i,sameHost:n},r))}function ce(e,t,n,r,i,M){var a=null;var o=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var s=new Promise(function(e,t){a=e;o=t})}if(n==null){n=te().body}var D=i.handler||Tr;if(!oe(n)){ne(a);return s}var l=i.targetOverride||ge(n);if(l==null||l==he){ue(n,"htmx:targetError",{target:ee(n,"hx-target")});ne(o);return s}var u=ie(n);var f=u.lastButtonClicked;if(f){var c=Q(f,"formaction");if(c!=null){t=c}var h=Q(f,"formmethod");if(h!=null){e=h}}if(!M){var X=function(){return ce(e,t,n,r,i,true)};var F={target:l,elt:n,path:t,verb:e,triggeringEvent:r,etc:i,issueRequest:X};if(fe(n,"htmx:confirm",F)===false){ne(a);return s}}var v=n;var d=re(n,"hx-sync");var g=null;var m=false;if(d){var p=d.split(":");var x=p[0].trim();if(x==="this"){v=de(n,"hx-sync")}else{v=le(n,x)}d=(p[1]||"drop").trim();u=ie(v);if(d==="drop"&&u.xhr&&u.abortable!==true){ne(a);return s}else if(d==="abort"){if(u.xhr){ne(a);return s}else{m=true}}else if(d==="replace"){fe(v,"htmx:abort")}else if(d.indexOf("queue")===0){var U=d.split(" ");g=(U[1]||"last").trim()}}if(u.xhr){if(u.abortable){fe(v,"htmx:abort")}else{if(g==null){if(r){var y=ie(r);if(y&&y.triggerSpec&&y.triggerSpec.queue){g=y.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){ce(e,t,n,r,i)})}else if(g==="all"){u.queuedRequests.push(function(){ce(e,t,n,r,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){ce(e,t,n,r,i)})}ne(a);return s}}var b=new XMLHttpRequest;u.xhr=b;u.abortable=m;var w=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){var e=u.queuedRequests.shift();e()}};var B=re(n,"hx-prompt");if(B){var S=prompt(B);if(S===null||!fe(n,"htmx:prompt",{prompt:S,target:l})){ne(a);w();return s}}var V=re(n,"hx-confirm");if(V){if(!confirm(V)){ne(a);w();return s}}var E=sr(n,l,S);if(i.headers){E=se(E,i.headers)}var j=nr(n,e);var C=j.errors;var T=j.values;if(i.values){T=se(T,i.values)}var _=xr(n);var z=se(T,_);var R=lr(z,n);if(e!=="get"&&!cr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(Y.config.getCacheBusterParam&&e==="get"){R["org.htmx.cache-buster"]=Q(l,"id")||"true"}if(t==null||t===""){t=te().location.href}var O=dr(n,"hx-request");var W=ie(n).boosted;var q=Y.config.methodsThatUseUrlParams.indexOf(e)>=0;var H={boosted:W,useUrlParams:q,parameters:R,unfilteredParameters:z,headers:E,target:l,verb:e,errors:C,withCredentials:i.credentials||O.credentials||Y.config.withCredentials,timeout:i.timeout||O.timeout||Y.config.timeout,path:t,triggeringEvent:r};if(!fe(n,"htmx:configRequest",H)){ne(a);w();return s}t=H.path;e=H.verb;E=H.headers;R=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){fe(n,"htmx:validation:halted",H);ne(a);w();return s}var $=t.split("#");var G=$[0];var L=$[1];var A=t;if(q){A=G;var J=Object.keys(R).length!==0;if(J){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=ar(R);if(L){A+="#"+L}}}if(!Er(n,A,H)){ue(n,"htmx:invalidPath",H);ne(o);return s}b.open(e.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var Z=E[N];yr(b,N,Z)}}}var I={xhr:b,target:l,requestConfig:H,etc:i,boosted:W,pathInfo:{requestPath:t,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Sr(n);I.pathInfo.responsePath=br(b);D(n,I);Kt(P,k);fe(n,"htmx:afterRequest",I);fe(n,"htmx:afterOnLoad",I);if(!oe(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(oe(r)){t=r}}if(t){fe(t,"htmx:afterRequest",I);fe(t,"htmx:afterOnLoad",I)}}ne(a);w()}catch(e){ue(n,"htmx:onLoadError",se({error:e},I));throw e}};b.onerror=function(){Kt(P,k);ue(n,"htmx:afterRequest",I);ue(n,"htmx:sendError",I);ne(o);w()};b.onabort=function(){Kt(P,k);ue(n,"htmx:afterRequest",I);ue(n,"htmx:sendAbort",I);ne(o);w()};b.ontimeout=function(){Kt(P,k);ue(n,"htmx:afterRequest",I);ue(n,"htmx:timeout",I);ne(o);w()};if(!fe(n,"htmx:beforeRequest",I)){ne(a);w();return s}var P=Jt(n);var k=Zt(n);ae(["loadstart","loadend","progress","abort"],function(t){ae([b,b.upload],function(e){e.addEventListener(t,function(e){fe(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});fe(n,"htmx:beforeSend",I);var K=q?null:hr(b,n,R);b.send(K);return s}function Cr(e,t){var r=t.xhr;var n=null;var i=null;if(R(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(R(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(R(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=re(e,"hx-push-url");var l=re(e,"hx-replace-url");var u=ie(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Tr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;if(!fe(l,"htmx:beforeOnLoad",u))return;if(R(f,/HX-Trigger:/i)){Ue(f,"HX-Trigger",l)}if(R(f,/HX-Location:/i)){jt();var r=f.getResponseHeader("HX-Location");var h;if(r.indexOf("{")===0){h=S(r);r=h["path"];delete h["path"]}wr("GET",r,h).then(function(){_t(r)});return}var n=R(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(R(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(R(f,/HX-Retarget:/i)){u.target=te().querySelector(f.getResponseHeader("HX-Retarget"))}var v=Cr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var d=f.response;var a=f.status>=400;var g=Y.config.ignoreTitle;var o=se({shouldSwap:i,serverResponse:d,isError:a,ignoreTitle:g},u);if(!fe(c,"htmx:beforeSwap",o))return;c=o.target;d=o.serverResponse;a=o.isError;g=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){Ke(l)}C(l,function(e){d=e.transformResponse(d,f,l)});if(v.type){jt()}var s=e.swapOverride;if(R(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var h=fr(l,s);if(h.hasOwnProperty("ignoreTitle")){g=h.ignoreTitle}c.classList.add(Y.config.swappingClass);var m=null;var p=null;var x=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(R(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}var n=T(c);Fe(h.swapStyle,c,l,d,n,r);if(t.elt&&!oe(t.elt)&&Q(t.elt,"id")){var i=document.getElementById(Q(t.elt,"id"));var a={preventScroll:h.focusScroll!==undefined?!h.focusScroll:!Y.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Y.config.swappingClass);ae(n.elts,function(e){if(e.classList){e.classList.add(Y.config.settlingClass)}fe(e,"htmx:afterSwap",u)});if(R(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!oe(l)){o=te().body}Ue(f,"HX-Trigger-After-Swap",o)}var s=function(){ae(n.tasks,function(e){e.call()});ae(n.elts,function(e){if(e.classList){e.classList.remove(Y.config.settlingClass)}fe(e,"htmx:afterSettle",u)});if(v.type){if(v.type==="push"){_t(v.path);fe(te().body,"htmx:pushedIntoHistory",{path:v.path})}else{zt(v.path);fe(te().body,"htmx:replacedInHistory",{path:v.path})}}if(u.pathInfo.anchor){var e=E("#"+u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!g){var t=E("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}vr(n.elts,h);if(R(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!oe(l)){r=te().body}Ue(f,"HX-Trigger-After-Settle",r)}ne(m)};if(h.settleDelay>0){setTimeout(s,h.settleDelay)}else{s()}}catch(e){ue(l,"htmx:swapError",u);ne(p);throw e}};var y=Y.config.globalViewTransitions;if(h.hasOwnProperty("transition")){y=h.transition}if(y&&fe(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var b=new Promise(function(e,t){m=e;p=t});var w=x;x=function(){document.startViewTransition(function(){w();return b})}}if(h.swapDelay>0){setTimeout(x,h.swapDelay)}else{x()}}if(a){ue(l,"htmx:responseError",se({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Rr={};function Or(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function qr(e,t){if(t.init){t.init(r)}Rr[e]=se(Or(),t)}function Hr(e){delete Rr[e]}function Lr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=ee(e,"hx-ext");if(t){ae(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Rr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Lr(u(e),r,n)}var Ar=false;te().addEventListener("DOMContentLoaded",function(){Ar=true});function Nr(e){if(Ar||te().readyState==="complete"){e()}else{te().addEventListener("DOMContentLoaded",e)}}function Ir(){if(Y.config.includeIndicatorStyles!==false){te().head.insertAdjacentHTML("beforeend","")}}function Pr(){var e=te().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function kr(){var e=Pr();if(e){Y.config=se(Y.config,e)}}Nr(function(){kr();Ir();var e=te().body;Pt(e);var t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ie(t);if(r&&r.xhr){r.xhr.abort()}});var r=window.onpopstate;window.onpopstate=function(e){if(e.state&&e.state.htmx){Gt();ae(t,function(e){fe(e,"htmx:restored",{document:te(),triggerEvent:fe})})}else{if(r){r(e)}}};setTimeout(function(){fe(e,"htmx:load",{});e=null},0)});return Y}()}); -------------------------------------------------------------------------------- /static/response-targets.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | /** @type {import("../htmx").HtmxInternalApi} */ 4 | var api; 5 | 6 | var attrPrefix = 'hx-target-'; 7 | 8 | // IE11 doesn't support string.startsWith 9 | function startsWith(str, prefix) { 10 | return str.substring(0, prefix.length) === prefix 11 | } 12 | 13 | /** 14 | * @param {HTMLElement} elt 15 | * @param {number} respCode 16 | * @returns {HTMLElement | null} 17 | */ 18 | function getRespCodeTarget(elt, respCodeNumber) { 19 | if (!elt || !respCodeNumber) return null; 20 | 21 | var respCode = respCodeNumber.toString(); 22 | 23 | // '*' is the original syntax, as the obvious character for a wildcard. 24 | // The 'x' alternative was added for maximum compatibility with HTML 25 | // templating engines, due to ambiguity around which characters are 26 | // supported in HTML attributes. 27 | // 28 | // Start with the most specific possible attribute and generalize from 29 | // there. 30 | var attrPossibilities = [ 31 | respCode, 32 | 33 | respCode.substr(0, 2) + '*', 34 | respCode.substr(0, 2) + 'x', 35 | 36 | respCode.substr(0, 1) + '*', 37 | respCode.substr(0, 1) + 'x', 38 | respCode.substr(0, 1) + '**', 39 | respCode.substr(0, 1) + 'xx', 40 | 41 | '*', 42 | 'x', 43 | '***', 44 | 'xxx', 45 | ]; 46 | if (startsWith(respCode, '4') || startsWith(respCode, '5')) { 47 | attrPossibilities.push('error'); 48 | } 49 | 50 | for (var i = 0; i < attrPossibilities.length; i++) { 51 | var attr = attrPrefix + attrPossibilities[i]; 52 | var attrValue = api.getClosestAttributeValue(elt, attr); 53 | if (attrValue) { 54 | if (attrValue === "this") { 55 | return api.findThisElement(elt, attr); 56 | } else { 57 | return api.querySelectorExt(elt, attrValue); 58 | } 59 | } 60 | } 61 | 62 | return null; 63 | } 64 | 65 | /** @param {Event} evt */ 66 | function handleErrorFlag(evt) { 67 | if (evt.detail.isError) { 68 | if (htmx.config.responseTargetUnsetsError) { 69 | evt.detail.isError = false; 70 | } 71 | } else if (htmx.config.responseTargetSetsError) { 72 | evt.detail.isError = true; 73 | } 74 | } 75 | 76 | htmx.defineExtension('response-targets', { 77 | 78 | /** @param {import("../htmx").HtmxInternalApi} apiRef */ 79 | init: function (apiRef) { 80 | api = apiRef; 81 | 82 | if (htmx.config.responseTargetUnsetsError === undefined) { 83 | htmx.config.responseTargetUnsetsError = true; 84 | } 85 | if (htmx.config.responseTargetSetsError === undefined) { 86 | htmx.config.responseTargetSetsError = false; 87 | } 88 | if (htmx.config.responseTargetPrefersExisting === undefined) { 89 | htmx.config.responseTargetPrefersExisting = false; 90 | } 91 | if (htmx.config.responseTargetPrefersRetargetHeader === undefined) { 92 | htmx.config.responseTargetPrefersRetargetHeader = true; 93 | } 94 | }, 95 | 96 | /** 97 | * @param {string} name 98 | * @param {Event} evt 99 | */ 100 | onEvent: function (name, evt) { 101 | if (name === "htmx:beforeSwap" && 102 | evt.detail.xhr && 103 | evt.detail.xhr.status !== 200) { 104 | if (evt.detail.target) { 105 | if (htmx.config.responseTargetPrefersExisting) { 106 | evt.detail.shouldSwap = true; 107 | handleErrorFlag(evt); 108 | return true; 109 | } 110 | if (htmx.config.responseTargetPrefersRetargetHeader && 111 | evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) { 112 | evt.detail.shouldSwap = true; 113 | handleErrorFlag(evt); 114 | return true; 115 | } 116 | } 117 | if (!evt.detail.requestConfig) { 118 | return true; 119 | } 120 | var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status); 121 | if (target) { 122 | handleErrorFlag(evt); 123 | evt.detail.shouldSwap = true; 124 | evt.detail.target = target; 125 | } 126 | return true; 127 | } 128 | } 129 | }); 130 | })(); 131 | -------------------------------------------------------------------------------- /static/zip-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danvergara/morphos/13ee176a273e1bcee62bc78b1fe1b0f59f66bd35/static/zip-icon.png -------------------------------------------------------------------------------- /templates/base.tmpl: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | 6 | {{template "title" .}} 7 | {{template "htmx" .}} 8 | {{template "style" .}} 9 | 10 | 11 | {{template "nav" .}} 12 |
13 | {{template "content" .}} 14 |
15 | {{template "modal"}} 16 | {{template "js" .}} 17 | 18 | 19 | {{end}} 20 | -------------------------------------------------------------------------------- /templates/partials/active_modal.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}Download your file{{end}} 2 | {{define "content"}} 3 | 29 | {{end}} 30 | -------------------------------------------------------------------------------- /templates/partials/card_file.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}Download your file{{end}} 2 | {{define "content"}} 3 |
4 |
5 |
6 |
7 |
8 | {{ .Filename }} 9 |
10 |
11 |
12 |
13 | Finished 14 |
15 |
16 |
17 |
18 | 19 | 20 | 30 |
31 |
32 |
33 |
34 | 35 | 36 | 37 |
38 |
39 |
40 |
41 |
42 | {{end}} 43 | -------------------------------------------------------------------------------- /templates/partials/error.tmpl: -------------------------------------------------------------------------------- 1 | {{ block "error" .}} 2 |
3 | 6 |
7 | {{ end }} 8 | -------------------------------------------------------------------------------- /templates/partials/form.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}Upload your file{{end}} 2 | 3 | {{define "content"}} 4 |
5 |
6 |
12 |

File Converter

13 |
14 |
15 | 16 | 24 |
25 |
26 | 27 | 38 |
39 |
40 | 44 | 45 |
46 |
47 |
48 |
49 |
50 |
51 | {{end}} 52 | -------------------------------------------------------------------------------- /templates/partials/htmx.tmpl: -------------------------------------------------------------------------------- 1 | {{define "htmx"}} 2 | 3 | 4 | {{end}} 5 | -------------------------------------------------------------------------------- /templates/partials/js.tmpl: -------------------------------------------------------------------------------- 1 | {{define "js"}} 2 | 3 | 8 | 31 | 69 | {{end}} 70 | -------------------------------------------------------------------------------- /templates/partials/modal.tmpl: -------------------------------------------------------------------------------- 1 | {{define "modal"}} 2 | 11 | {{end}} 12 | -------------------------------------------------------------------------------- /templates/partials/nav.tmpl: -------------------------------------------------------------------------------- 1 | {{define "nav"}} 2 | 21 | {{end}} 22 | -------------------------------------------------------------------------------- /templates/partials/style.tmpl: -------------------------------------------------------------------------------- 1 | {{define "style"}} 2 | 3 | 10 | {{end}} 11 | --------------------------------------------------------------------------------