Full Code of randombenj/docat for AI

main 9b58a9553c0f cached
91 files
221.8 KB
59.3k tokens
165 symbols
1 requests
Download .txt
Showing preview only (244K chars total). Download the full file or copy to clipboard to get everything.
Repository: randombenj/docat
Branch: main
Commit: 9b58a9553c0f
Files: 91
Total size: 221.8 KB

Directory structure:
gitextract_7bq8lh13/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── docat.yml
├── .prettierrc
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── doc/
│   └── getting-started.md
├── docat/
│   ├── .gitignore
│   ├── Makefile
│   ├── README.md
│   ├── docat/
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   ├── app.py
│   │   ├── models.py
│   │   ├── nginx/
│   │   │   └── default
│   │   └── utils.py
│   ├── pyproject.toml
│   └── tests/
│       ├── __init__.py
│       ├── conftest.py
│       ├── test_claim.py
│       ├── test_delete.py
│       ├── test_hide_show.py
│       ├── test_project.py
│       ├── test_rename.py
│       ├── test_stats.py
│       ├── test_upload.py
│       ├── test_upload_icon.py
│       └── test_utils.py
└── web/
    ├── .gitignore
    ├── .prettierignore
    ├── .prettierrc.json
    ├── README.md
    ├── eslint.config.js
    ├── index.html
    ├── package.json
    ├── src/
    │   ├── App.tsx
    │   ├── components/
    │   │   ├── DataSelect.tsx
    │   │   ├── DocumentControlButtons.tsx
    │   │   ├── FavoriteStar.tsx
    │   │   ├── FileInput.tsx
    │   │   ├── Footer.tsx
    │   │   ├── Header.tsx
    │   │   ├── IFrame.tsx
    │   │   ├── InfoBanner.tsx
    │   │   ├── NavigationTitle.tsx
    │   │   ├── PageLayout.tsx
    │   │   ├── Project.tsx
    │   │   ├── ProjectList.tsx
    │   │   ├── SearchBar.tsx
    │   │   └── StyledForm.tsx
    │   ├── data-providers/
    │   │   ├── ConfigDataProvider.tsx
    │   │   ├── MessageBannerProvider.tsx
    │   │   ├── ProjectDataProvider.tsx
    │   │   ├── RandomId.tsx
    │   │   ├── SearchProvider.tsx
    │   │   └── StatsDataProvider.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   ├── models/
    │   │   ├── ProjectDetails.ts
    │   │   └── ProjectsResponse.ts
    │   ├── pages/
    │   │   ├── Claim.tsx
    │   │   ├── Delete.tsx
    │   │   ├── Docs.tsx
    │   │   ├── Help.tsx
    │   │   ├── Home.tsx
    │   │   ├── LoadingPage.tsx
    │   │   ├── NotFound.tsx
    │   │   └── Upload.tsx
    │   ├── react-app-env.d.ts
    │   ├── repositories/
    │   │   └── ProjectRepository.ts
    │   ├── style/
    │   │   ├── components/
    │   │   │   ├── ControlButtons.module.css
    │   │   │   ├── DocumentControlButtons.module.css
    │   │   │   ├── FileInput.module.css
    │   │   │   ├── Footer.module.css
    │   │   │   ├── Header.module.css
    │   │   │   ├── IFrame.module.css
    │   │   │   ├── NavigationTitle.module.css
    │   │   │   ├── PageLayout.module.css
    │   │   │   ├── Project.module.css
    │   │   │   ├── ProjectList.module.css
    │   │   │   ├── SearchBar.module.css
    │   │   │   └── StyledForm.module.css
    │   │   └── pages/
    │   │       ├── Help.module.css
    │   │       ├── Home.module.css
    │   │       ├── NotFound.module.css
    │   │       └── Upload.module.css
    │   └── tests/
    │       └── repositories/
    │           └── ProjectRepository.test.ts
    ├── tsconfig.json
    ├── vite-env.d.ts
    └── vite.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
.git
*.pyc
docat/env
docat/__pycache__
docat/upload
docat/.tox
docat/tests
web/node_modules
web/build
web/.env*


================================================
FILE: .github/workflows/docat.yml
================================================
name: docat ci

on: [push, pull_request]

jobs:
  python-test:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 4
      matrix:
        python-version: ["3.14"]

    steps:
      - uses: actions/checkout@v5
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          python-version: ${{ matrix.python-version }}

      - name: install dependencies
        working-directory: docat
        run: uv sync --locked --all-extras --dev

      - name: run backend linter
        working-directory: docat
        run: |
          uv run ruff check
          uv run ruff format --check

      - name: run backend static code analysis
        working-directory: docat
        run: |
          uv run mypy .

      - name: run backend tests
        working-directory: docat
        run: |
          uv run pytest

  javascript-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
      - name: install JavaScript dependencies
        working-directory: web
        run: yarn install

      - name: building frontend
        working-directory: web
        run: yarn build

      - name: run linter against code
        working-directory: web
        run: yarn lint

      - name: run test suite
        working-directory: web
        run: yarn test

  container-image:
    runs-on: ubuntu-latest
    needs: [python-test, javascript-test]

    strategy:
      max-parallel: 2
      matrix:
        registry:
          - name: ghcr.io
            org: ${{ github.repository_owner }}
            token: GITHUB_TOKEN
          - name: docker.io
            org: randombenj
            token: DOCKERHUB

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Build Image
        run: |
          docker build . --build-arg DOCAT_VERSION=$(git describe --tags --always) --tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }}
          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:unstable

      - name: tag latest and version on release
        run: |
          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:$(git describe --tags)
          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:latest
        if: startsWith(github.event.ref, 'refs/tags')

      - name: Registry Login
        uses: docker/login-action@v3
        with:
          registry: ${{ matrix.registry.name }}
          username: ${{ matrix.registry.org }}
          password: ${{ secrets[matrix.registry.token] }}
        # Note(Fliiiix): Only login and push on main repo where the secrets are available
        if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')"

      - name: Publish Image
        run: |
          docker push --all-tags ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat
        if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')"


================================================
FILE: .prettierrc
================================================
{
    "semi": false,
    "singleQuote": true,
    "trailingComma": "none"
}

================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to docat

Thanks for contributing to docat!
In order to keep the quality of the source-code high,
please follow those rules when submitting a change.

If you just want to fix a bug or make a small improvement
feel free to just send a pull request.

Please first discuss any big new features you wish to make via issue, email,
or any other method with the owners of this repository before making a change.

## Pull Request Process

Commits should be the following format

```
type(scope): commit title

commit body (if any)
this should document api breaks

fixes # (if any)
```

Type could be one of *feat, docs, fix, ...* and scope could be *docat, web, ...*
you don't have to provide a scope when the change is for the whole repository like README updates.

Execute linters by running `make lint` in the back-end or `yarn lint`.

A pull request will only be merged when the pipeline runs through.


================================================
FILE: Dockerfile
================================================
# building frontend
FROM node:24-slim AS frontend
WORKDIR /app/frontend

COPY web/package.json web/yarn.lock ./
RUN yarn install --frozen-lockfile

# fix docker not following symlinks
COPY web ./
COPY doc/getting-started.md ./src/assets/

ARG DOCAT_VERSION=unknown
ENV VITE_DOCAT_VERSION=$DOCAT_VERSION

RUN yarn build

# setup Python
FROM python:3.14-slim AS backend

# configure docker container
ENV PYTHONDONTWRITEBYTECODE=1

COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/

COPY /docat/pyproject.toml /docat/uv.lock /app/

# Install the application
WORKDIR /app/docat
RUN uv sync --no-install-project --no-dev --color never

# production
FROM python:3.14-slim

# defaults
ENV MAX_UPLOAD_SIZE=100M

# set up the system
RUN apt-get update && \
    apt-get install --yes nginx dumb-init libmagic1 gettext && \
    rm -rf /var/lib/apt/lists/*

RUN mkdir -p /var/docat/doc

# install the application
RUN mkdir -p /var/www/html
COPY --from=frontend /app/frontend/dist /var/www/html
COPY docat /app/docat
WORKDIR /app/docat

# Copy the build artifact (.venv)
COPY --from=backend /app /app/docat

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["sh", "-c", "envsubst '$MAX_UPLOAD_SIZE' < /app/docat/docat/nginx/default > /etc/nginx/sites-enabled/default && nginx && .venv/bin/python -m uvicorn --host 0.0.0.0 --port 5000 docat.app:app"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2019 https://github.com/docat-org/docat

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
![docat](doc/assets/docat-teaser.png)

**Host your docs. Simple. Versioned. Fancy.**

[![build](https://github.com/docat-org/docat/workflows/docat%20ci/badge.svg)](https://github.com/docat-org/docat/actions)
[![Gitter](https://badges.gitter.im/docat-docs-hosting/community.svg)](https://gitter.im/docat-docs-hosting/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)

## Why DOCAT?

When generating static documentation using
[mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ...
hosting just one version of the docs might not be enough.
Many users might still use older versions and might need to read
those versions of the documentation.

Docat solves this problem by providing a simple tool that
hosts multiple documentation projects with multiple versions.

*The main design decision with docat was to keep the tool as simple as possible.*

## Getting started

The simplest way to get started is to run the docker container,
you can optionally use volumes to persist state:

```sh
# run container in background and persist data (docs, nginx configs and tokens database)
# use 'ghcr.io/docat-org/docat:unstable' to get the latest changes
mkdir -p docat-run/doc
docker run \
  --detach \
  --volume $PWD/docat-run:/var/docat/ \
  --publish 8000:80 \
  ghcr.io/docat-org/docat
```

Go to [localhost:8000](http://localhost:8000) to view your docat instance:

<img src="doc/assets/docat.gif" width="100%" />

### Using DOCAT

> 🛈 Please note that docat does not provide any way to write documentation.
> It's sole responsibility is to host documentation.
>
> There are many awesome tools to write documenation:
> - [mkdocs](https://www.mkdocs.org/)
> - [sphinx](http://www.sphinx-doc.org/en/master/)
> - [mdbook](https://rust-lang.github.io/mdBook/)
> - ...


A CLI tool called [docatl](https://github.com/docat-org/docatl) is available
for easy interaction with the docat server.
However, interacting with docat can also be done through [`curl`](doc/getting-started.md).

To push documentation (and tag as `latest`) in the folder `docs/` simply run:

```sh
docatl push --host http://localhost:8000 ./docs PROJECT VERSION --tag latest
```

More detailed instructions can be found in the [**getting started guide**](doc/getting-started.md).

## Authentication

By default, anyone can upload new documentation or add a new version to documentation.
A project can be claimed. A claim returns a token that then must be used
to add or delete versions.

When hosting docat publicly, it is recommended to use
[http basic auth](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/)
for all `POST`/`PUT` and `DELETE` http calls.

<details>
  <summary>docat http basic authentication example</summary>

This example shows how to configure the NGINX inside the docker image
to be password protected using http basic auth.

1) Create your [`.htpasswd` file](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/#creating-a-password-file).
2) And a custom `default` NGINX config:

  ```
  upstream python_backend {
      server 127.0.0.1:5000;
  }

  server {
      listen 80 default_server;
      listen [::]:80 default_server;

      root /var/www/html;

      add_header Content-Security-Policy "frame-ancestors 'self';";
      index index.html index.htm index.pdf /index.html;

      server_name _;

      location /doc {
          root /var/docat;
      }

      location /api {
          limit_except GET HEAD {
              auth_basic 'Restricted';
              auth_basic_user_file /etc/nginx/.htpasswd;
          }

          client_max_body_size $MAX_UPLOAD_SIZE;
          proxy_pass http://python_backend;
      }

      location / {
          try_files $uri $uri/ =404;
      }
  }
  ```

1) Mounted to the correct location inside the container:

  ```
  docker run \
    --detach \
    --volume $PWD/docat-run:/var/docat/ \
    --volume $PWD/nginx/default:/app/docat/docat/nginx/default \
    --volume $PWD/nginx/.htpasswd:/etc/nginx/.htpasswd \
    --publish 8000:80 \
    ghcr.io/docat-org/docat
  ```
</details>

## Configuring DOCAT

#### Frontend Config

It is possible to configure some things after the fact.

1. Create a `config.json` file
2. Mount it inside your docker container `--volume $PWD/config.json:/var/docat/doc/config.json`

Supported config options:

```json
{
  "headerHTML": "<h1 style='color: #115fbf;'>Custom Header HTML!</h1>",
  "footerHTML": "CONTACT: <a href='mailto:maintainer@mail.invalid'>Maintainers</a>"
}
```

#### System Config

Further proxy configurations can be done through the following environmental variables:

| Variable | Default | Description |
|---|---|---|
| `MAX_UPLOAD_SIZE` | [100M](./Dockerfile) | Limits the size of individual archives posted to the API |


## Local Development

For local development, first configure and start the backend (inside the `docat/` folder):

```sh
# create a folder for local development (uploading docs)
DEV_DOCAT_PATH="$(mktemp -d)"

# install dependencies
uv venv .venv
uv sync

# run the local development version
DOCAT_SERVE_FILES=1 DOCAT_STORAGE_PATH="$DEV_DOCAT_PATH" uv run python -m docat
```

After this you need to start the frontend (inside the `web/` folder):

```sh
# install dependencies
yarn install --frozen-lockfile

# run the web app
yarn start
```

For more advanced options, have a look at the
[backend](docat/README.md) and [web](web/README.md) docs.


================================================
FILE: doc/getting-started.md
================================================
## Getting Started with DOCAT




### Using `docatl`, the docat CLI 🙀

The most convenient way to interact with docat is with it's official CLI tool,
[docatl](https://github.com/docat-org/docatl).

You can download a standalone binary of the latest release for your platform
[here](https://github.com/docat-org/docatl/releases/latest) or
[use go](https://github.com/docat-org/docatl#using-go) or
[docker](https://github.com/docat-org/docatl#using-docker) to install it.

The commands below contain examples both using `curl` and `docatl`.
Do note that the host address and api-key can be omitted if specified in a `.docatl.yml` file.
See the [docatl documentation](https://github.com/docat-org/docatl/blob/main/README.md) for more information.

Use `docatl --help` to discover all commands available to manage your `docat` documentation!

### API endpoints

The following sections document the RAW API endpoints you can `curl`.

The API specification is exposed as an [OpenAPI Documentation](/api/v1/openapi.json),
via Swagger UI at [/api/docs](/api/docs) and
as a pure documentation with redoc at [/api/redoc](/api/redoc).

#### Upload your documentation

You can upload any static HTML page by zipping it and uploading the zip file.

> Note: if an `index.html` file is present in the root of the zip file
  it will be served automatically.

For example to upload the file `docs.zip` as version `1.0.0` for `awesome-project` using `curl`:

```sh
curl -X POST -F "file=@docs.zip" http://localhost:8000/api/awesome-project/1.0.0
```

Using `docatl`:

```sh
docatl push docs.zip awesome-project 1.0.0 --host http://localhost:8000
```

Any file type can be uploaded. To view an uploaded pdf, specify it's full path:

`http://localhost:8000/awesome-project/1.0.0/my_awesome.pdf`

You can also manually upload your documentation.
A very simple web form can be found under [upload](/upload).

#### Tag documentation

After uploading you can tag a specific version. This can be useful when
the latest version should be available as `http://localhost:8000/docs/awesome-project/latest`

To tag the version `1.0.0` as `latest` for `awesome-project`:

```sh
curl -X PUT http://localhost:8000/api/awesome-project/1.0.0/tags/latest
```

Using `docatl`:

```sh
docatl tag awesome-project 1.0.0 latest --host http://localhost:8000
```

#### Claim Project

Claiming a Project returns a `token` which can be used for actions
which require authentication (for example for deleting a version).
Each Project can be claimed **exactly once**, so best store the token safely.

```sh
curl -X GET http://localhost:8000/api/awesome-project/claim
```

Using `docatl`:

```sh
docatl claim awesome-project --host http://localhost:8000
```

#### Authentication

To make an authenticated call, specify a header with the key `Docat-Api-Key` and your token as the value:

```sh
curl -X DELETE --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/1.0.0
```

Using `docatl`:

```sh
docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key <token>
```

#### Delete Version

To delete a Project version you need to be authenticated.

To remove the version `1.0.0` from `awesome-project`:

```sh
curl -X DELETE --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/1.0.0
```

Using `docatl`:

```sh
docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key <token>
```

#### Upload Project Icon

To upload a icon, you don't need a token, except if you want to replace an existing icon.

To set `example-image.png` as the icon for `awesome-project`, which already has an icon:

```sh
curl -X POST -F "file=@example-image.png" --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/icon
```

Using `docatl`:

```sh
docatl push-icon awesome-project example-image.png --host http://localhost:8000 --api-key <token>
```

#### Rename a Project

To rename a Project, you need a token.

To rename `awesome-project` to `new-awesome-project`:

```sh
curl -X PUT --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/rename/new-awesome-project
```

Using `docatl`:

```sh
docatl rename awesome-project new-awesome-project --host http://localhost:8000 --api-key <token>
```

#### Hide a Version

If you want to hide a version from the version select as well as the search results,
you can hide it. You need to be authenticated to do this.

To hide version `0.0.1` of `awesome-project`:

```sh
curl -X POST --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/0.0.1/hide
```

Using `docatl`:

```sh
docatl hide awesome-project 0.0.1 --host http://localhost:8000 --api-key <token>
```

#### Show a Version

This is the reverse of `hide`, and also requires a token.

To show version `0.0.1` of `awesome-project` again:

```sh
curl -X POST --header "Docat-Api-Key: <token>" http://localhost:8000/api/awesome-project/0.0.1/show
```

Using `docatl`:

```sh
docatl show awesome-project 0.0.1 --host http://localhost:8000 --api-key <token>
```


================================================
FILE: docat/.gitignore
================================================
*.pyc
env
__pycache__
upload
.tox
.coverage
db.json
.python-version
docat.egg-info/


================================================
FILE: docat/Makefile
================================================
.PHONY: all
all: format lint typing pytest

format:
	uv run ruff check --fix
	uv run ruff format
lint:
	uv run ruff check
typing:
	uv run mypy .
pytest:
	uv run pytest


================================================
FILE: docat/README.md
================================================
# docat backend

The backend hosts the documentation and an api to push documentation and
tag versions of the documentation.

## development enviroment

You will need to install [uv](https://docs.astral.sh/uv/#installation) `curl -LsSf https://astral.sh/uv/install.sh | sh
`.

Install the dependencies and run the application:

```sh
# install dependencies
uv venv .venv
uv sync

# run the app
[DOCAT_SERVE_FILES=1] [DOCAT_STORAGE_PATH=/tmp] [PORT=8888] uv run python -m docat
```

### Config Options

* **DOCAT_SERVE_FILES**: Serve static documentation instead of a nginx (for testing)
* **DOCAT_STORAGE_PATH**: Upload directory for static files (needs to match nginx config)
* **PORT**: Port for the Python backend (needs to match nginx config for production)

## Usage

See [getting-started.md](../doc/getting-started.md)


================================================
FILE: docat/docat/__init__.py
================================================


================================================
FILE: docat/docat/__main__.py
================================================
import os

import uvicorn

from docat.app import app

if __name__ == "__main__":
    try:
        port = int(os.environ.get("PORT", "5000"))
    except ValueError:
        port = 5000

    uvicorn.run(app, host="0.0.0.0", port=port)


================================================
FILE: docat/docat/app.py
================================================
"""
docat
~~~~~

Host your docs. Simple. Versioned. Fancy.

:copyright: (c) 2019 by docat, https://github.com/docat-org/docat
:license: MIT, see LICENSE for more details.
"""

import logging
import os
import secrets
import shutil
from contextlib import asynccontextmanager
from pathlib import Path

import magic
from fastapi import APIRouter, Depends, FastAPI, File, Header, Response, UploadFile, status
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
from tinydb import Query, TinyDB

from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, Stats, TokenStatus
from docat.utils import (
    DB_PATH,
    UPLOAD_FOLDER,
    calculate_token,
    create_symlink,
    extract_archive,
    get_all_projects,
    get_dir_size,
    get_project_details,
    get_system_stats,
    is_forbidden_project_name,
    remove_docs,
)

DOCAT_STORAGE_PATH = Path(os.getenv("DOCAT_STORAGE_PATH", Path("/var/docat")))
DOCAT_DB_PATH = DOCAT_STORAGE_PATH / DB_PATH
DOCAT_UPLOAD_FOLDER = DOCAT_STORAGE_PATH / UPLOAD_FOLDER

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(_: FastAPI):
    # Create the folders if they don't exist
    DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
    yield


def get_db() -> TinyDB:
    """Return the cached TinyDB instance."""
    return TinyDB(DOCAT_DB_PATH)


#: Holds the FastAPI application
app = FastAPI(
    title="docat",
    description="API for docat, https://github.com/docat-org/docat",
    openapi_url="/api/v1/openapi.json",
    docs_url="/api/docs",
    redoc_url="/api/redoc",
    lifespan=lifespan,
)
router = APIRouter()


@router.get("/api/stats", response_model=Stats, status_code=status.HTTP_200_OK)
def get_stats():
    if not DOCAT_UPLOAD_FOLDER.exists():
        return Projects(projects=[])
    return get_system_stats(DOCAT_UPLOAD_FOLDER)


@router.get("/api/projects", response_model=Projects, status_code=status.HTTP_200_OK)
def get_projects(include_hidden: bool = False):
    if not DOCAT_UPLOAD_FOLDER.exists():
        return Projects(projects=[])
    return get_all_projects(DOCAT_UPLOAD_FOLDER, include_hidden)


@router.get(
    "/api/projects/{project}",
    response_model=ProjectDetail,
    status_code=status.HTTP_200_OK,
    responses={status.HTTP_404_NOT_FOUND: {"model": ApiResponse}},
)
def get_project(project, include_hidden: bool = False):
    details = get_project_details(DOCAT_UPLOAD_FOLDER, project, include_hidden)

    if not details:
        return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": f"Project {project} does not exist"})

    return details


@router.post("/api/{project}/icon", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def upload_icon(
    project: str,
    response: Response,
    file: UploadFile = File(...),
    docat_api_key: str | None = Header(None),
    db: TinyDB = Depends(get_db),
):
    project_base_path = DOCAT_UPLOAD_FOLDER / project
    icon_path = project_base_path / "logo"

    if not project_base_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Project {project} not found")

    mime_type_checker = magic.Magic(mime=True)
    mime_type = mime_type_checker.from_buffer(file.file.read())

    if not mime_type.startswith("image/"):
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message="Icon must be an image")

    # require a token if the project already has an icon
    if icon_path.is_file():
        token_status = check_token_for_project(db, docat_api_key, project)
        if not token_status.valid:
            response.status_code = status.HTTP_401_UNAUTHORIZED
            return ApiResponse(message=token_status.reason)

        # remove the old icon
        os.remove(icon_path)

    # save the uploaded icon
    file.file.seek(0)
    with icon_path.open("wb") as buffer:
        shutil.copyfileobj(file.file, buffer)

    # force cache revalidation
    get_system_stats.cache_clear()
    get_dir_size.cache_clear()

    return ApiResponse(message="Icon successfully uploaded")


@router.post("/api/{project}/{version}/hide", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def hide_version(
    project: str,
    version: str,
    response: Response,
    docat_api_key: str | None = Header(None),
    db: TinyDB = Depends(get_db),
):
    project_base_path = DOCAT_UPLOAD_FOLDER / project
    version_path = project_base_path / version
    hidden_file = version_path / ".hidden"

    if not project_base_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Project {project} not found")

    if not version_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Version {version} not found")

    if hidden_file.exists():
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message=f"Version {version} is already hidden")

    token_status = check_token_for_project(db, docat_api_key, project)
    if not token_status.valid:
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return ApiResponse(message=token_status.reason)

    with open(hidden_file, "w") as f:
        f.close()

    return ApiResponse(message=f"Version {version} is now hidden")


@router.post("/api/{project}/{version}/show", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def show_version(
    project: str,
    version: str,
    response: Response,
    docat_api_key: str | None = Header(None),
    db: TinyDB = Depends(get_db),
):
    project_base_path = DOCAT_UPLOAD_FOLDER / project
    version_path = project_base_path / version
    hidden_file = version_path / ".hidden"

    if not project_base_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Project {project} not found")

    if not version_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Version {version} not found")

    if not hidden_file.exists():
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message=f"Version {version} is not hidden")

    token_status = check_token_for_project(db, docat_api_key, project)
    if not token_status.valid:
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return ApiResponse(message=token_status.reason)

    os.remove(hidden_file)

    return ApiResponse(message=f"Version {version} is now shown")


@router.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def upload(
    project: str,
    version: str,
    response: Response,
    file: UploadFile = File(...),
    docat_api_key: str | None = Header(None),
    db: TinyDB = Depends(get_db),
):
    if is_forbidden_project_name(project):
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message=f'Project name "{project}" is forbidden, as it conflicts with pages in docat web.')

    if file.filename is None:
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message="Uploaded file is None aborting upload.")

    project_base_path = DOCAT_UPLOAD_FOLDER / project
    base_path = project_base_path / version
    target_file = base_path / str(file.filename)

    if base_path.is_symlink():
        # disallow overwriting of tags (symlinks) with new uploads
        response.status_code = status.HTTP_409_CONFLICT
        return ApiResponse(message="Cannot overwrite existing tag with new version.")

    if base_path.exists():
        token_status = check_token_for_project(db, docat_api_key, project)
        if not token_status.valid:
            response.status_code = status.HTTP_401_UNAUTHORIZED
            return ApiResponse(message=token_status.reason)

        remove_docs(project, version, DOCAT_UPLOAD_FOLDER)

    # ensure directory for the uploaded doc exists
    base_path.mkdir(parents=True, exist_ok=True)

    # save the uploaded documentation
    file.file.seek(0)
    with target_file.open("wb") as buffer:
        shutil.copyfileobj(file.file, buffer)

    try:
        extract_archive(target_file, base_path)
    except Exception:
        logger.exception("Failed to unzip {target_file=}")
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message="Cannot extract zip file.")

    # force cache revalidation
    get_system_stats.cache_clear()
    get_dir_size.cache_clear()

    if not (base_path / "index.html").exists():
        return ApiResponse(message="Documentation uploaded successfully, but no index.html found at root of archive.")

    return ApiResponse(message="Documentation uploaded successfully")


@router.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def tag(project: str, version: str, new_tag: str, response: Response):
    destination = DOCAT_UPLOAD_FOLDER / project / new_tag
    source = DOCAT_UPLOAD_FOLDER / project / version

    if not source.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Version {version} not found")

    if not create_symlink(version, destination):
        response.status_code = status.HTTP_409_CONFLICT
        return ApiResponse(message=f"Tag {new_tag} would overwrite an existing version!")

    return ApiResponse(message=f"Tag {new_tag} -> {version} successfully created")


@router.get(
    "/api/{project}/claim",
    response_model=ClaimResponse,
    status_code=status.HTTP_201_CREATED,
    responses={status.HTTP_409_CONFLICT: {"model": ApiResponse}},
)
def claim(project: str, db: TinyDB = Depends(get_db)):
    Project = Query()
    table = db.table("claims")
    result = table.search(Project.name == project)
    if result:
        return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": f"Project {project} is already claimed!"})

    token = secrets.token_hex(16)
    salt = os.urandom(32)
    token_hash = calculate_token(token, salt)
    table.insert({"name": project, "token": token_hash, "salt": salt.hex()})

    return ClaimResponse(message=f"Project {project} successfully claimed", token=token)


@router.put("/api/{project}/rename/{new_project_name}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def rename(
    project: str,
    new_project_name: str,
    response: Response,
    docat_api_key: str = Header(None),
    db: TinyDB = Depends(get_db),
):
    if is_forbidden_project_name(new_project_name):
        response.status_code = status.HTTP_400_BAD_REQUEST
        return ApiResponse(message=f'New project name "{new_project_name}" is forbidden, as it conflicts with pages in docat web.')

    project_base_path = DOCAT_UPLOAD_FOLDER / project
    new_project_base_path = DOCAT_UPLOAD_FOLDER / new_project_name

    if not project_base_path.exists():
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=f"Project {project} not found")

    if new_project_base_path.exists():
        response.status_code = status.HTTP_409_CONFLICT
        return ApiResponse(message=f"New project name {new_project_name} already in use")

    token_status = check_token_for_project(db, docat_api_key, project)
    if not token_status.valid:
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return ApiResponse(message=token_status.reason)

    # update the claim to the new project name
    Project = Query()
    claims_table = db.table("claims")
    claims_table.update({"name": new_project_name}, Project.name == project)

    os.rename(project_base_path, new_project_base_path)

    response.status_code = status.HTTP_200_OK
    return ApiResponse(message=f"Successfully renamed project {project} to {new_project_name}")


@router.delete("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def delete(
    project: str,
    version: str,
    response: Response,
    docat_api_key: str = Header(None),
    db: TinyDB = Depends(get_db),
):
    token_status = check_token_for_project(db, docat_api_key, project)
    if not token_status.valid:
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return ApiResponse(message=token_status.reason)

    message = remove_docs(project, version, DOCAT_UPLOAD_FOLDER)
    if message:
        response.status_code = status.HTTP_404_NOT_FOUND
        return ApiResponse(message=message)

    # force cache revalidation
    get_system_stats.cache_clear()
    get_dir_size.cache_clear()

    return ApiResponse(message=f"Successfully deleted version '{version}'")


def check_token_for_project(db, token, project) -> TokenStatus:
    Project = Query()
    table = db.table("claims")
    result = table.search(Project.name == project)

    if result and token:
        token_hash = calculate_token(token, bytes.fromhex(result[0]["salt"]))
        if result[0]["token"] == token_hash:
            return TokenStatus(True, "Docat-Api-Key token is valid")
        else:
            return TokenStatus(False, f"Docat-Api-Key token is not valid for {project}")
    else:
        return TokenStatus(False, f"Please provide a header with a valid Docat-Api-Key token for {project}")


# serve_local_docs for local testing without a nginx
if os.environ.get("DOCAT_SERVE_FILES"):
    DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
    app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER, html=True), name="docs")

app.include_router(router)


================================================
FILE: docat/docat/models.py
================================================
from dataclasses import dataclass
from datetime import datetime

from pydantic import BaseModel


@dataclass(frozen=True)
class TokenStatus:
    valid: bool
    reason: str


class ApiResponse(BaseModel):
    message: str


class ClaimResponse(ApiResponse):
    token: str


class ProjectVersion(BaseModel):
    name: str
    timestamp: datetime
    tags: list[str]
    hidden: bool


class Project(BaseModel):
    name: str
    logo: bool
    storage: str
    versions: list[ProjectVersion]


class Projects(BaseModel):
    projects: list[Project]


class Stats(BaseModel):
    n_projects: int
    n_versions: int
    storage: str


class ProjectDetail(BaseModel):
    name: str
    storage: str
    versions: list[ProjectVersion]


================================================
FILE: docat/docat/nginx/default
================================================
upstream python_backend {
    server 127.0.0.1:5000;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /var/www/html;

    add_header Content-Security-Policy "frame-ancestors 'self';";
    index index.html index.htm index.pdf /index.html;

    server_name _;

    location /doc {
        root /var/docat;
        absolute_redirect off;
    }

    location /api {
        client_max_body_size $MAX_UPLOAD_SIZE;
        proxy_pass http://python_backend;
    }

    location / {
        try_files $uri $uri/ /index.html =404;
    }
}


================================================
FILE: docat/docat/utils.py
================================================
"""
docat utilities
"""

import hashlib
import os
import shutil
from datetime import datetime
from functools import cache
from pathlib import Path
from zipfile import ZipFile, ZipInfo

from docat.models import Project, ProjectDetail, Projects, ProjectVersion, Stats

NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
UPLOAD_FOLDER = "doc"
DB_PATH = "db.json"


def is_dir(self):
    """Return True if this archive member is a directory."""
    if self.filename.endswith("/"):
        return True
    # The ZIP format specification requires to use forward slashes
    # as the directory separator, but in practice some ZIP files
    # created on Windows can use backward slashes.  For compatibility
    # with the extraction code which already handles this:
    if os.path.altsep:
        return self.filename.endswith((os.path.sep, os.path.altsep))
    return False


# Patch is_dir to allow windows zip files to be
# extracted correctly
# see: https://github.com/python/cpython/issues/117084
ZipInfo.is_dir = is_dir  # type: ignore[method-assign]


def create_symlink(source, destination):
    """
    Create a symlink from source to destination, if the
    destination is already a symlink, it will be overwritten.

    Args:
        source (pathlib.Path): path to the source
        destination (pathlib.Path): path to the destination
    """
    if not destination.exists() or (destination.exists() and destination.is_symlink()):
        if destination.is_symlink():
            destination.unlink()  # overwrite existing tag
        destination.symlink_to(source)
        return True
    else:
        return False


def extract_archive(target_file, destination):
    """
    Extracts the given archive to the directory
    and deletes the source afterwards.

    Args:
        target_file (pathlib.Path): target archive
        destination: (pathlib.Path): destination of the extracted archive
    """
    if target_file.suffix == ".zip":
        # this is required to extract zip files created
        # on windows machines (https://stackoverflow.com/a/52091659/12356463)
        os.path.altsep = "\\"
        with ZipFile(target_file, "r") as zipf:
            zipf.extractall(path=destination)
        target_file.unlink()  # remove the zip file


def remove_docs(project: str, version: str, upload_folder_path: Path):
    """
    Delete documentation

    Args:
        project (str): name of the project
        version (str): project version
    """
    docs = upload_folder_path / project / version
    if docs.exists():
        # remove the requested version
        # rmtree can not remove a symlink
        if docs.is_symlink():
            docs.unlink()
        else:
            shutil.rmtree(docs)

        # remove dead symlinks
        for link in (s for s in docs.parent.iterdir() if s.is_symlink()):
            if not link.resolve().exists():
                link.unlink()

        # remove size info
        (upload_folder_path / project / ".size").unlink(missing_ok=True)

        # remove empty projects
        if not [d for d in docs.parent.iterdir() if d.is_dir()]:
            docs.parent.rmdir()
            nginx_config = NGINX_CONFIG_PATH / f"{project}-doc.conf"
            if nginx_config.exists():
                nginx_config.unlink()
    else:
        return f"Could not find version '{docs}'"


def calculate_token(password, salt):
    """
    Wrapper function for pbkdf2_hmac to ensure consistent use of
    hash digest algorithm and iteration count.

    Args:
        password (str): the password to hash
        salt (byte): the salt used for the password
    """
    return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000).hex()


def is_forbidden_project_name(name: str) -> bool:
    """
    Checks if the given project name is forbidden.
    The project name is forbidden if it conflicts with
    a page on the docat website.
    """
    name = name.lower().strip()
    return name in ["upload", "claim", "delete", "help", "doc", "api"]


UNITS_MAPPING = [
    (1 << 50, " PB"),
    (1 << 40, " TB"),
    (1 << 30, " GB"),
    (1 << 20, " MB"),
    (1 << 10, " KB"),
    (1, " byte"),
]


def readable_size(bytes: int) -> str:
    """
    Get human-readable file sizes.
    simplified version of https://pypi.python.org/pypi/hurry.filesize/

    https://stackoverflow.com/a/12912296/12356463
    """
    size_suffix = ""
    for factor, suffix in UNITS_MAPPING:
        if bytes >= factor:
            size_suffix = suffix
            break

    amount = int(bytes / factor)
    if size_suffix == " byte" and amount > 1:
        size_suffix = size_suffix + "s"

    if amount == 0:
        size_suffix = " bytes"

    return str(amount) + size_suffix


@cache
def get_dir_size(path: Path) -> int:
    """
    Calculate the total size of a directory.

    Results are cached (memoizing) by path.
    """
    total = 0
    with os.scandir(path) as it:
        for entry in it:
            if entry.is_symlink():
                # skip symlinks
                pass
            elif entry.is_file():
                total += entry.stat().st_size
            elif entry.is_dir():
                total += get_dir_size(entry.path)
    return total


@cache
def get_system_stats(upload_folder_path: Path) -> Stats:
    """
    Return all docat statistics.

    Results are cached (memoizing) by path.
    """
    dirs = 0
    versions = 0
    size = 0
    # Note: Not great nesting with the deep nesting
    # but it needs to run fast, consider speed when refactoring!
    with os.scandir(upload_folder_path) as root:
        for f in root:
            if f.is_dir():
                dirs += 1
                with os.scandir(f.path) as project:
                    for v in project:
                        if v.is_dir() and not v.is_symlink():
                            size += get_dir_size(v.path)
                            versions += 1

    return Stats(
        n_projects=dirs,
        n_versions=versions,
        storage=readable_size(size),
    )


def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects:
    """
    Returns all projects in the upload folder.
    """
    projects: list[Project] = []

    for project in sorted(upload_folder_path.iterdir()):
        if not project.is_dir():
            continue

        details = get_project_details(upload_folder_path, project.name, include_hidden)

        if details is None:
            continue

        if len(details.versions) < 1:
            continue

        project_name = str(project.relative_to(upload_folder_path))
        project_has_logo = (upload_folder_path / project / "logo").exists()
        projects.append(
            Project(
                name=project_name,
                logo=project_has_logo,
                versions=details.versions,
                storage=readable_size(get_dir_size(upload_folder_path / project)),
            )
        )

    return Projects(projects=projects)


def get_version_timestamp(version_folder: Path) -> datetime:
    """
    Returns the timestamp of a version
    """
    return datetime.fromtimestamp(version_folder.stat().st_ctime)


def get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool) -> ProjectDetail | None:
    """
    Returns all versions and tags for a project.
    """
    docs_folder = upload_folder_path / project_name

    if not docs_folder.exists():
        return None

    tags = [x for x in docs_folder.iterdir() if x.is_dir() and x.is_symlink()]

    def should_include(name: str) -> bool:
        if include_hidden:
            return True

        return not (docs_folder / name / ".hidden").exists()

    return ProjectDetail(
        name=project_name,
        storage=readable_size(get_dir_size(docs_folder)),
        versions=sorted(
            [
                ProjectVersion(
                    name=str(x.relative_to(docs_folder)),
                    tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],
                    timestamp=get_version_timestamp(x),
                    hidden=(docs_folder / x.name / ".hidden").exists(),
                )
                for x in docs_folder.iterdir()
                if x.is_dir() and not x.is_symlink() and should_include(x.name)
            ],
            key=lambda k: k.name,
            reverse=True,
        ),
    )


================================================
FILE: docat/pyproject.toml
================================================
[project]
name = "docat"
version = "0.0.0"
description = "Host your docs. Simple. Versioned. Fancy."
authors = [
    { name = "Felix", email = "hi@l33t.name" },
    { name = "Benj", email = "randombenj@gmail.com" }
]
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = [
    "tinydb",
    "fastapi[all]",
    "uvicorn",
    "python-multipart",
    "python-magic",
]

[dependency-groups]
dev = [
    "ruff",
    "pytest",
    "pytest-cov",
    "requests",
    "mypy",
]

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
minversion = "6.0"
addopts = "--ff -ra -v"
testpaths = [
    "tests"
]

[[tool.mypy.overrides]]
module = [
    "tinydb",
    "tinydb.storages",
    "uvicorn"
]
ignore_missing_imports = true

[tool.ruff]
line-length = 140
# Rule descriptions: https://docs.astral.sh/ruff/rules/
lint.select = ["I", "E", "B", "F", "W", "N", "C4", "C90", "ARG", "PL", "RUF", "UP"]
# TODO: Should be reduct to no global exceptions
lint.ignore = ["B008", "N806", "PLR0911", "PLR0913"]

[tool.ruff.lint.per-file-ignores]
# Ignore for all tests (Magic value used in comparison)
# We use magic values in tests
"tests/*" = ["PLR2004"]


================================================
FILE: docat/tests/__init__.py
================================================


================================================
FILE: docat/tests/conftest.py
================================================
import tempfile
from pathlib import Path

import pytest
from fastapi.testclient import TestClient
from tinydb import TinyDB

import docat.app as docat
from docat.utils import create_symlink


@pytest.fixture(autouse=True)
def setup_docat_paths():
    """
    Set up the temporary paths for the docat app.
    """

    temp_dir = tempfile.TemporaryDirectory()
    docat.DOCAT_STORAGE_PATH = Path(temp_dir.name)
    docat.DOCAT_DB_PATH = Path(temp_dir.name) / "db.json"
    docat.DOCAT_UPLOAD_FOLDER = Path(temp_dir.name) / "doc"

    yield

    temp_dir.cleanup()


@pytest.fixture
def client():
    docat.db = TinyDB(docat.DOCAT_DB_PATH)

    yield TestClient(docat.app)

    docat.app.db = None


@pytest.fixture
def client_with_claimed_project(client):
    table = docat.db.table("claims")
    token_hash_1234 = b"\xe0\x8cS\xa3)\xb4\xb5\xa5\xda\xc3K\x96\xf6).\xdd-\xacR\x8e3Q\x17\x87\xfb\x94\x0c-\xc2h\x1c\xf3"
    table.insert({"name": "some-project", "token": token_hash_1234.hex(), "salt": ""})
    yield client


@pytest.fixture
def temp_project_version():
    def __create(project, version):
        version_docs = docat.DOCAT_UPLOAD_FOLDER / project / version
        version_docs.mkdir(parents=True)
        (version_docs / "index.html").touch()

        create_symlink(version_docs, docat.DOCAT_UPLOAD_FOLDER / project / "latest")

        return docat.DOCAT_UPLOAD_FOLDER

    yield __create


================================================
FILE: docat/tests/test_claim.py
================================================
def test_successfully_claim_token(client):
    response = client.get("/api/some-project/claim")
    response_data = response.json()
    assert response.status_code == 201
    assert response_data["message"] == "Project some-project successfully claimed"
    assert "token" in response_data


def test_already_claimed(client):
    client.get("/api/some-project/claim")
    response = client.get("/api/some-project/claim")
    response_data = response.json()
    assert response.status_code == 409
    assert response_data["message"] == "Project some-project is already claimed!"


================================================
FILE: docat/tests/test_delete.py
================================================
from unittest.mock import patch


def test_successfully_delete(client_with_claimed_project):
    with patch("docat.app.remove_docs", return_value="remove mock"):
        response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "1234"})
        assert b"remove mock" in response.content


def test_no_valid_token_delete(client_with_claimed_project):
    with patch("docat.app.remove_docs", return_value="remove mock"):
        response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "abcd"})
        response_data = response.json()

        assert response.status_code == 401
        assert response_data["message"] == "Docat-Api-Key token is not valid for some-project"


def test_no_token_delete(client_with_claimed_project):
    with patch("docat.app.remove_docs", return_value="remove mock"):
        response = client_with_claimed_project.delete("/api/some-project/1.0.0")
        response_data = response.json()

        assert response.status_code == 401
        assert response_data["message"] == "Please provide a header with a valid Docat-Api-Key token for some-project"


================================================
FILE: docat/tests/test_hide_show.py
================================================
import io
from datetime import datetime
from unittest.mock import patch

import docat.app as docat


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide(_, client_with_claimed_project):
    """
    Tests that the version is marked as hidden when getting the details after hiding
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
    }

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check hidden
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [],
    }


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project):
    """
    Test that the project is not listed in the projects endpoint when the only version is hidden
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    projects_response = client_with_claimed_project.get("/api/projects")
    assert projects_response.status_code == 200
    assert projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": False,
                "storage": "20 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
            }
        ],
    }

    # hide the only version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check hidden
    projects_response = client_with_claimed_project.get("/api/projects")
    assert projects_response.status_code == 200
    assert projects_response.json() == {
        "projects": [],
    }

    # check versions hidden
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {"name": "some-project", "storage": "20 bytes", "versions": []}


def test_hide_creates_hidden_file(client_with_claimed_project):
    """
    Tests that the hidden file is created when hiding a version
    """
    hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / ".hidden"

    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check open was called at least once with the correct path
    with patch("docat.app.open") as open_file_mock:
        # hide
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 200
        assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

        open_file_mock.assert_called_once_with(hidden_file_path, "w")


def test_hide_fails_project_does_not_exist(client_with_claimed_project):
    """
    Tests that hiding a version fails when the project does not exist
    """
    with patch("docat.app.open") as open_file_mock:
        hide_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 404
        assert hide_response.json() == {"message": "Project does-not-exist not found"}

        open_file_mock.assert_not_called()


def test_hide_fails_version_does_not_exist(client_with_claimed_project):
    """
    Tests that hiding a version fails when the version does not exist
    """
    with patch("docat.app.open") as open_file_mock:
        # create a version
        create_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_response.status_code == 201

        # hide different version
        hide_response = client_with_claimed_project.post("/api/some-project/2.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 404
        assert hide_response.json() == {"message": "Version 2.0.0 not found"}

        open_file_mock.assert_not_called()


def test_hide_fails_already_hidden(client_with_claimed_project):
    """
    Tests that hiding a version fails when the version is already hidden
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # hide version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    with patch("docat.app.open") as open_file_mock:
        # hide version again
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 400
        assert hide_response.json() == {"message": "Version 1.0.0 is already hidden"}

        open_file_mock.assert_not_called()


def test_hide_fails_no_token(client_with_claimed_project):
    """
    Tests that hiding a version fails when no token is provided
    """
    with patch("docat.app.open") as open_file_mock:
        # create a version
        create_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_response.status_code == 201

        # hide version
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide")
        assert hide_response.status_code == 401
        assert hide_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}

        open_file_mock.assert_not_called()


def test_hide_fails_invalid_token(client_with_claimed_project):
    """
    Tests that hiding a version fails when an invalid token is provided
    """
    with patch("docat.app.open") as open_file_mock:
        # create a version
        create_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_response.status_code == 201

        # hide version
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "invalid"})
        assert hide_response.status_code == 401
        assert hide_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}

        open_file_mock.assert_not_called()


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_show(_, client_with_claimed_project):
    """
    Tests that the version is no longer marked as hidden after requesting show.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check hidden
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [],
    }

    # show the version
    show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
    assert show_response.status_code == 200
    assert show_response.json() == {"message": "Version 1.0.0 is now shown"}

    # check detected again
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
    }


def test_show_deletes_hidden_file(client_with_claimed_project):
    """
    Tests that the hidden file is deleted when requesting show.
    """
    hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / ".hidden"

    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check os.remove was called at least once with the correct path
    with patch("os.remove") as remove_file_mock:
        # show again
        show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
        assert show_response.status_code == 200
        assert show_response.json() == {"message": "Version 1.0.0 is now shown"}

        remove_file_mock.assert_called_once_with(hidden_file_path)


def test_show_fails_project_does_not_exist(client_with_claimed_project):
    """
    Tests that showing a version fails when the project does not exist
    """
    with patch("os.remove") as delete_file_mock:
        show_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert show_response.status_code == 404
        assert show_response.json() == {"message": "Project does-not-exist not found"}

        delete_file_mock.assert_not_called()


def test_show_fails_version_does_not_exist(client_with_claimed_project):
    """
    Tests that showing a version fails when the version does not exist
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    with patch("os.remove") as delete_file_mock:
        # show different version
        show_response = client_with_claimed_project.post("/api/some-project/2.0.0/show", headers={"Docat-Api-Key": "1234"})
        assert show_response.status_code == 404
        assert show_response.json() == {"message": "Version 2.0.0 not found"}

        delete_file_mock.assert_not_called()


def test_show_fails_already_shown(client_with_claimed_project):
    """
    Tests that showing a version fails when the version is already shown
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    with patch("os.remove") as delete_file_mock:
        # show version
        show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
        assert show_response.status_code == 400
        assert show_response.json() == {"message": "Version 1.0.0 is not hidden"}

        delete_file_mock.assert_not_called()


def test_show_fails_no_token(client_with_claimed_project):
    """
    Tests that showing a version fails when no token is provided
    """
    with patch("os.remove") as remove_file_mock:
        # create a version
        create_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_response.status_code == 201

        # hide version
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 200
        assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

        # try to show without token
        show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show")
        assert show_response.status_code == 401
        assert show_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}

        remove_file_mock.assert_not_called()


def test_show_fails_invalid_token(client_with_claimed_project):
    """
    Tests that showing a version fails when an invalid token is provided
    """
    with patch("os.remove") as remove_file_mock:
        # create a version
        create_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_response.status_code == 201

        # hide version
        hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
        assert hide_response.status_code == 200
        assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

        # try to show without token
        show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "invalid"})
        assert show_response.status_code == 401
        assert show_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}

        remove_file_mock.assert_not_called()


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide_and_show_with_tag(_, client_with_claimed_project):
    """
    Tests that the version is no longer marked as hidden after requesting show on a tag.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # create a tag
    create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
    assert create_tag_response.status_code == 201
    assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"}

    # hide the tag
    hide_response = client_with_claimed_project.post("/api/some-project/latest/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version latest is now hidden"}

    # check hidden
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [],
    }

    # show the version
    show_response = client_with_claimed_project.post("/api/some-project/latest/show", headers={"Docat-Api-Key": "1234"})
    assert show_response.status_code == 200
    assert show_response.json() == {"message": "Version latest is now shown"}

    # check detected again
    project_details_response = client_with_claimed_project.get("/api/projects/some-project")
    assert project_details_response.status_code == 200
    assert project_details_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}],
    }


================================================
FILE: docat/tests/test_project.py
================================================
import io
from datetime import datetime
from unittest.mock import patch

import httpx
from fastapi.testclient import TestClient

import docat.app as docat
from docat.models import ProjectDetail, ProjectVersion
from docat.utils import get_project_details

client = TestClient(docat.app)


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_project_api(_, temp_project_version):
    docs = temp_project_version("project", "1.0")
    docs = temp_project_version("different-project", "1.0")

    with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs):
        response = client.get("/api/projects")

        assert response.status_code == httpx.codes.OK
        assert response.json() == {
            "projects": [
                {
                    "name": "different-project",
                    "logo": False,
                    "storage": "0 bytes",
                    "versions": [
                        {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False},
                    ],
                },
                {
                    "name": "project",
                    "logo": False,
                    "storage": "0 bytes",
                    "versions": [
                        {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False},
                    ],
                },
            ]
        }


def test_project_api_without_any_projects():
    response = client.get("/api/projects")

    assert response.status_code == httpx.codes.OK
    assert response.json() == {"projects": []}


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_project_details_api(_, temp_project_version):
    project = "project"
    docs = temp_project_version(project, "1.0")
    symlink_to_latest = docs / project / "latest"
    assert symlink_to_latest.is_symlink()

    with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs):
        response = client.get(f"/api/projects/{project}")

        assert response.status_code == httpx.codes.OK
        assert response.json() == {
            "name": "project",
            "storage": "0 bytes",
            "versions": [{"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}],
        }


def test_project_details_api_with_a_project_that_does_not_exist():
    response = client.get("/api/projects/i-do-not-exist")

    assert not response.status_code == httpx.codes.OK
    assert response.json() == {"message": "Project i-do-not-exist does not exist"}


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_get_project_details_with_hidden_versions(_, client_with_claimed_project):
    """
    Make sure that get_project_details works when include_hidden is set to True.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True)
    assert details == ProjectDetail(
        name="some-project",
        storage="20 bytes",
        versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)],
    )

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check hidden
    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True)
    assert details == ProjectDetail(
        name="some-project",
        storage="20 bytes",
        versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)],
    )


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_project_details_without_hidden_versions(_, client_with_claimed_project):
    """
    Make sure that project_details works when include_hidden is set to False.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False)
    assert details == ProjectDetail(
        name="some-project",
        storage="20 bytes",
        versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)],
    )

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check hidden
    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False)
    assert details == ProjectDetail(name="some-project", storage="20 bytes", versions=[])


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_project):
    """
    Make sure that include_hidden has the desired effect on the /api/projects endpoint.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    get_projects_response = client_with_claimed_project.get("/api/projects")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": False,
                "storage": "20 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
            }
        ]
    }

    # check include_hidden=True
    get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": False,
                "storage": "20 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
            }
        ]
    }

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check include_hidden=False
    get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=false")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {"projects": []}

    # check include_hidden=True
    get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": False,
                "storage": "20 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}],
            }
        ]
    }


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed_project):
    """
    Make sure that include_hidden has the desired effect on the /api/project/{project} endpoint.
    """
    # create a version
    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    # check detected before hiding
    get_projects_response = client_with_claimed_project.get("/api/projects/some-project")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
    }

    # check include_hidden=True
    get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
    }

    # hide the version
    hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
    assert hide_response.status_code == 200
    assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}

    # check include_hidden=False
    get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=false")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [],
    }

    # check include_hidden=True
    get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true")
    assert get_projects_response.status_code == 200
    assert get_projects_response.json() == {
        "name": "some-project",
        "storage": "20 bytes",
        "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}],
    }


================================================
FILE: docat/tests/test_rename.py
================================================
import io
from pathlib import Path
from unittest.mock import call, patch

from tinydb import Query

import docat.app as docat


def test_rename_fail_project_does_not_exist(client_with_claimed_project):
    with patch("os.rename") as rename_mock:
        response = client_with_claimed_project.put("/api/does-not-exist/rename/new-project-name")
        assert response.status_code == 404
        assert response.json() == {"message": "Project does-not-exist not found"}

        assert rename_mock.mock_calls == []


def test_rename_fail_new_project_name_already_used(client_with_claimed_project):
    with patch("os.rename") as rename_mock:
        create_first_project_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_first_project_response.status_code == 201

        create_second_project_response = client_with_claimed_project.post(
            "/api/second-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_second_project_response.status_code == 201

        rename_response = client_with_claimed_project.put("/api/some-project/rename/second-project")
        assert rename_response.status_code == 409
        assert rename_response.json() == {"message": "New project name second-project already in use"}

        assert rename_mock.mock_calls == []


def test_rename_not_authenticated(client_with_claimed_project):
    with patch("os.rename") as rename_mock:
        create_project_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0",
            files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
        )
        assert create_project_response.status_code == 201

        rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name")
        assert rename_response.status_code == 401
        assert rename_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}

        assert rename_mock.mock_calls == []


def test_rename_success(client_with_claimed_project):
    with patch("os.rename") as rename_mock:
        create_project_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0",
            files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
        )
        assert create_project_response.status_code == 201

        rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name", headers={"Docat-Api-Key": "1234"})
        assert rename_response.status_code == 200
        assert rename_response.json() == {"message": "Successfully renamed project some-project to new-project-name"}

        old_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project")
        new_path = docat.DOCAT_UPLOAD_FOLDER / Path("new-project-name")
        assert rename_mock.mock_calls == [call(old_path, new_path)]

        Project = Query()
        table = docat.db.table("claims")
        claims_with_old_name = table.search(Project.name == "some-project")
        assert len(claims_with_old_name) == 0
        claims_with_new_name = table.search(Project.name == "new-project-name")
        assert len(claims_with_new_name) == 1


def test_rename_rejects_forbidden_project_name(client_with_claimed_project):
    """
    Names that conflict with pages in docat web are forbidden,
    and renaming a project to such a name should fail.
    """

    create_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_response.status_code == 201

    with patch("os.rename") as rename_mock:
        for project_name in ["upload", "claim", "Delete ", "help", "Doc", "API"]:
            rename_response = client_with_claimed_project.put(f"/api/some-project/rename/{project_name}", headers={"Docat-Api-Key": "1234"})
            assert rename_response.status_code == 400
            assert rename_response.json() == {
                "message": f'New project name "{project_name}" is forbidden, as it conflicts with pages in docat web.'
            }

            assert rename_mock.mock_calls == []


================================================
FILE: docat/tests/test_stats.py
================================================
import io
from datetime import datetime
from unittest.mock import patch

import pytest


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
@pytest.mark.parametrize(
    ("project_config", "n_projects", "n_versions", "storage"),
    [
        ([("some-project", ["1.0.0"])], 1, 1, "20 bytes"),
        ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"),
        ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"),
        ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "60 bytes"),
    ],
)
def test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project):
    """
    Make sure that get_stats works.
    """
    # create a version
    for project_name, versions in project_config:
        for version in versions:
            create_response = client_with_claimed_project.post(
                f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
            )
            assert create_response.status_code == 201

    # get system stats
    hide_response = client_with_claimed_project.get("/api/stats")
    assert hide_response.status_code == 200
    assert hide_response.json() == {"n_projects": n_projects, "n_versions": n_versions, "storage": storage}


================================================
FILE: docat/tests/test_upload.py
================================================
import io
from pathlib import Path
from unittest.mock import call, patch

import docat.app as docat


def test_successfully_upload(client):
    with patch("docat.app.remove_docs"):
        response = client.post("/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")})
        response_data = response.json()

        assert response.status_code == 201
        assert response_data["message"] == "Documentation uploaded successfully"
        assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists()


def test_successfully_override(client_with_claimed_project):
    with patch("docat.app.remove_docs") as remove_mock:
        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert response.status_code == 201

        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0",
            files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
            headers={"Docat-Api-Key": "1234"},
        )
        response_data = response.json()

        assert response.status_code == 201
        assert response_data["message"] == "Documentation uploaded successfully"
        assert remove_mock.mock_calls == [call("some-project", "1.0.0", docat.DOCAT_UPLOAD_FOLDER)]


def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project):
    with patch("docat.app.remove_docs") as remove_mock:
        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert response.status_code == 201

        response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
        assert response.status_code == 201

        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        response_data = response.json()

        assert response.status_code == 401
        assert response_data["message"] == "Please provide a header with a valid Docat-Api-Key token for some-project"
        assert remove_mock.mock_calls == []


def test_successful_tag_creation(client_with_claimed_project):
    with patch("docat.app.create_symlink") as create_symlink_mock:
        create_project_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert create_project_response.status_code == 201

        create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")

        assert create_tag_response.status_code == 201
        assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"}

        destination_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project") / Path("latest")
        assert create_symlink_mock.mock_calls == [call("1.0.0", destination_path), call().__bool__()]


def test_create_tag_fails_when_version_does_not_exist(client_with_claimed_project):
    with patch("docat.app.create_symlink") as create_symlink_mock:
        create_project_response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )

        assert create_project_response.status_code == 201

        create_tag_response = client_with_claimed_project.put("/api/some-project/non-existing-version/tags/new-tag")

        assert create_tag_response.status_code == 404
        assert create_tag_response.json() == {"message": "Version non-existing-version not found"}

        assert create_symlink_mock.mock_calls == []


def test_create_tag_fails_on_overwrite_of_version(client_with_claimed_project):
    """
    Create a tag with the same name as a version.
    """
    project_name = "some-project"
    version = "1.0.0"
    tag = "latest"

    create_first_project_response = client_with_claimed_project.post(
        f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_first_project_response.status_code == 201

    create_second_project_response = client_with_claimed_project.post(
        f"/api/{project_name}/{tag}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_second_project_response.status_code == 201

    create_tag_response = client_with_claimed_project.put(f"/api/{project_name}/{version}/tags/{tag}")
    assert create_tag_response.status_code == 409
    assert create_tag_response.json() == {"message": f"Tag {tag} would overwrite an existing version!"}


def test_create_fails_on_overwrite_of_tag(client_with_claimed_project):
    """
    Create a version with the same name as a tag.
    """
    project_name = "some-project"
    version = "1.0.0"
    tag = "some-tag"

    create_project_response = client_with_claimed_project.post(
        f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_project_response.status_code == 201

    create_tag_response = client_with_claimed_project.put(f"/api/{project_name}/{version}/tags/{tag}")
    assert create_tag_response.status_code == 201
    assert create_tag_response.json() == {"message": f"Tag {tag} -> {version} successfully created"}

    create_project_with_name_of_tag_response = client_with_claimed_project.post(
        f"/api/{project_name}/{tag}", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert create_project_with_name_of_tag_response.status_code == 409
    assert create_project_with_name_of_tag_response.json() == {"message": "Cannot overwrite existing tag with new version."}


def test_fails_with_invalid_token(client_with_claimed_project):
    with patch("docat.app.remove_docs") as remove_mock:
        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
        )
        assert response.status_code == 201

        response = client_with_claimed_project.post(
            "/api/some-project/1.0.0",
            files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
            headers={"Docat-Api-Key": "456"},
        )
        response_data = response.json()

        assert response.status_code == 401
        assert response_data["message"] == "Docat-Api-Key token is not valid for some-project"

        assert remove_mock.mock_calls == []


def test_upload_rejects_forbidden_project_name(client_with_claimed_project):
    """
    Names that conflict with pages in docat web are forbidden,
    and creating a project with such a name should fail.
    """

    with patch("docat.app.remove_docs") as remove_mock:
        for project_name in ["upload", "claim", " Delete ", "help", "DOC", "api"]:
            response = client_with_claimed_project.post(
                f"/api/{project_name}/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
            )
            assert response.status_code == 400
            assert response.json() == {"message": f'Project name "{project_name}" is forbidden, as it conflicts with pages in docat web.'}

            assert remove_mock.mock_calls == []


def test_upload_issues_warning_missing_index_file(client_with_claimed_project):
    """
    When a project is uploaded without an index.html file,
    a warning should be issued, but the upload should succeed.
    """

    response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("some-other-file.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    response_data = response.json()

    assert response.status_code == 201
    assert response_data["message"] == "Documentation uploaded successfully, but no index.html found at root of archive."
    assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "some-other-file.html").exists()
    assert not (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists()


================================================
FILE: docat/tests/test_upload_icon.py
================================================
import base64
import io
from datetime import datetime
from unittest.mock import call, patch

import docat.app as docat

ONE_PIXEL_PNG = base64.decodebytes(
    b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII="
)


def test_successful_icon_upload(client_with_claimed_project):
    upload_folder_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response = client_with_claimed_project.post(
            "/api/some-project/icon",
            files={"file": ("icon.jpg", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
        )

        assert upload_response.status_code == 200
        assert upload_response.json() == {"message": "Icon successfully uploaded"}
        assert remove_file_mock.mock_calls == []
        assert len(copyfileobj_mock.mock_calls) == 1


def test_icon_upload_fails_with_no_project(client_with_claimed_project):
    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response = client_with_claimed_project.post(
            "/api/non-existing-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
        )

        assert upload_response.status_code == 404
        assert upload_response.json() == {"message": "Project non-existing-project not found"}
        assert remove_file_mock.mock_calls == []
        assert copyfileobj_mock.mock_calls == []


def test_icon_upload_fails_no_token_and_existing_icon(client):
    """
    upload twice, first time should be successful (nothing replaced),
    second time should fail (would need token to replace)
    """

    upload_folder_response = client.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response_1 = client.post(
            "/api/some-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
        )
        assert upload_response_1.status_code == 200
        assert upload_response_1.json() == {"message": "Icon successfully uploaded"}
        assert remove_file_mock.mock_calls == []
        assert len(copyfileobj_mock.mock_calls) == 1

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response_2 = client.post(
            "/api/some-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
        )
        assert upload_response_2.status_code == 401
        assert upload_response_2.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
        assert remove_file_mock.mock_calls == []
        assert len(copyfileobj_mock.mock_calls) == 0


def test_icon_upload_successful_replacement_with_token(client_with_claimed_project):
    """
    upload twice, both times should be successful (token provided)
    """

    icon_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "logo"

    upload_folder_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response_1 = client_with_claimed_project.post(
            "/api/some-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
            headers={"Docat-Api-Key": "1234"},
        )
        assert upload_response_1.status_code == 200
        assert upload_response_1.json() == {"message": "Icon successfully uploaded"}
        assert remove_file_mock.mock_calls == []
        assert len(copyfileobj_mock.mock_calls) == 1

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response_1 = client_with_claimed_project.post(
            "/api/some-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
            headers={"Docat-Api-Key": "1234"},
        )
        assert upload_response_1.status_code == 200
        assert upload_response_1.json() == {"message": "Icon successfully uploaded"}
        assert remove_file_mock.mock_calls == [call(icon_path)]
        assert len(copyfileobj_mock.mock_calls) == 1


def test_icon_upload_successful_no_token_no_existing_icon(client):
    upload_folder_response = client.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response = client.post(
            "/api/some-project/icon",
            files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
        )

        assert upload_response.status_code == 200
        assert upload_response.json() == {"message": "Icon successfully uploaded"}
        assert remove_file_mock.mock_calls == []
        assert len(copyfileobj_mock.mock_calls) == 1


def test_icon_upload_fails_no_image(client_with_claimed_project):
    upload_folder_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
        upload_response = client_with_claimed_project.post(
            "/api/some-project/icon",
            files={"file": ("file.zip", io.BytesIO(b"not image data"), "application/zip")},
        )

        assert upload_response.status_code == 400
        assert upload_response.json() == {"message": "Icon must be an image"}
        assert remove_file_mock.mock_calls == []
        assert copyfileobj_mock.mock_calls == []


@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_get_project_recongizes_icon(_, client_with_claimed_project):
    """
    get_projects should return true, if the project has an icon
    """

    upload_folder_response = client_with_claimed_project.post(
        "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
    )
    assert upload_folder_response.status_code == 201

    projects_response = client_with_claimed_project.get("/api/projects")
    assert projects_response.status_code == 200
    assert projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": False,
                "storage": "20 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
            }
        ]
    }

    upload_response = client_with_claimed_project.post(
        "/api/some-project/icon",
        files={"file": ("icon.jpg", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
    )
    assert upload_response.status_code == 200

    projects_response = client_with_claimed_project.get("/api/projects")
    assert projects_response.status_code == 200
    assert projects_response.json() == {
        "projects": [
            {
                "name": "some-project",
                "logo": True,
                "storage": "103 bytes",
                "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
            }
        ]
    }


================================================
FILE: docat/tests/test_utils.py
================================================
from pathlib import Path
from unittest.mock import MagicMock, patch

import docat.app as docat
from docat.utils import create_symlink, extract_archive, get_dir_size, remove_docs


def test_symlink_creation():
    """
    Tests the creation of a symlink
    """
    source = MagicMock()
    destination = MagicMock()
    destination.exists.return_value = False
    destination.symlink_to.return_value = MagicMock()

    assert create_symlink(source, destination)

    destination.symlink_to.assert_called_once_with(source)


def test_symlink_creation_overwrite_destination():
    """
    Tests the creation of a symlink and overwriting
    of existing symlink
    """
    source = MagicMock()
    destination = MagicMock()
    destination.exists.return_value = True
    destination.is_symlink.return_value = True
    destination.unlink.return_value = MagicMock()
    destination.symlink_to.return_value = MagicMock()

    assert create_symlink(source, destination)

    destination.unlink.assert_called_once()
    destination.symlink_to.assert_called_once_with(source)


def test_symlink_creation_do_not_overwrite_destination():
    """
    Tests wether a symlinc is not created when it
    would overwrite an existing version
    """
    source = MagicMock()
    destination = MagicMock()
    destination.exists.return_value = True
    destination.is_symlink.return_value = False
    destination.unlink.return_value = MagicMock()
    destination.symlink_to.return_value = MagicMock()

    assert not create_symlink(source, destination)

    destination.unlink.assert_not_called()
    destination.symlink_to.assert_not_called()


def test_archive_artifact():
    target_file = Path("/some/zipfile.zip")
    destination = "/tmp/null"
    with patch.object(Path, "unlink") as mock_unlink, patch("docat.utils.ZipFile") as mock_zip:
        mock_zip_open = MagicMock()
        mock_zip.return_value.__enter__.return_value.extractall = mock_zip_open

        extract_archive(target_file, destination)

        mock_zip.assert_called_once_with(target_file, "r")
        mock_zip_open.assert_called_once()
        mock_unlink.assert_called_once()


def test_remove_version(temp_project_version):
    docs = temp_project_version("project", "1.0")
    remove_docs("project", "1.0", docat.DOCAT_UPLOAD_FOLDER)

    assert docs.exists()
    assert not (docs / "project").exists()


def test_remove_symlink_version(temp_project_version):
    project = "project"
    docs = temp_project_version(project, "1.0")
    symlink_to_latest = docs / project / "latest"
    assert symlink_to_latest.is_symlink()

    remove_docs(project, "latest", docat.DOCAT_UPLOAD_FOLDER)

    assert not symlink_to_latest.exists()


def test_broken_symlinks_in_projects(temp_project_version):
    project = "project"
    docs = temp_project_version(project, "1.0")

    create_symlink(docs / project / "broken", docs / project / "latest")

    get_dir_size(docs / project)


================================================
FILE: web/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build
/dist

# misc
.prettierrc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env
.env.local
.env.*.local


================================================
FILE: web/.prettierignore
================================================
node_modules
# Ignore artifacts:
build
coverage


================================================
FILE: web/.prettierrc.json
================================================
{
  "trailingComma": "none",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true
}


================================================
FILE: web/README.md
================================================
# docat web

## Project setup

```sh
yarn install [--pure-lockfile]
```

### Compiles and hot-reloads for development

The script for `yarn start` automatically sets `VITE_DOCAT_VERSION` to display the current version in the footer,
so you can just run:

```sh
yarn start
```

### Compiles and minifies for production

To display the current version of docat in the footer, use the following script to set `VITE_DOCAT_VERSION`.
This one liner uses the latest tag, if there is one on the current commit, and the current commit if not.

```sh
VITE_DOCAT_VERSION=$(git describe --tags --always) yarn build
```

Otherwise you can just use the following and the footer will show `unknown`.

```sh
yarn build
```

### Lints and fixes files

```sh
yarn lint
```

### Tests

```sh
yarn test
```

### Basic Header Theming

Not happy with the default Docat logo and header?
Just add your custom html header to the `/var/www/html/config.json` file.

```json
{
  "headerHTML": "<h1>MyCompany</h1>",
  "footerHTML": "Contact <a href='mailto:maintainers@contact.mail'>Maintainers</a>"
}
```


## Development

```sh
sudo docker run \
  --detach \
  --volume /path/to/doc:/var/docat/doc/ \
  --publish 8000:80 \
  docat
```

## Errors

If you get a 403 response when trying to read a version,
try changing the permissions of your docs folder on your host.

```sh
sudo chmod 777 /path/to/doc -r
```


================================================
FILE: web/eslint.config.js
================================================
import eslintReact from "@eslint-react/eslint-plugin";
import eslintJs from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import globals from 'globals'

export default defineConfig(
  {
    files: ["**/*.ts", "**/*.tsx"],
    ignores: ['dist/**', 'vite-env.d.ts', 'vite.config.ts'],

    // Extend recommended rule sets from:
    // 1. ESLint JS's recommended rules
    // 2. TypeScript ESLint recommended rules
    // 3. ESLint React's recommended-typescript rules
    // 4. Prettier (Must be last to disable conflicting rules)
    extends: [
      eslintJs.configs.recommended,
      tseslint.configs.recommended,
      eslintReact.configs["recommended-typescript"],
      eslintConfigPrettier,
    ],

    // Configure language/parsing options
    languageOptions: {
      ecmaVersion: 'latest', // Allow modern JS syntax
      globals: {
        ...globals.browser, // Allow browser globals like `window`
      },
      parser: tseslint.parser, // Your existing parser
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },

    // TODO: See if some of these could be fixed
    rules: {
      "@eslint-react/dom-no-dangerously-set-innerhtml": "off",
      "@eslint-react/exhaustive-deps": "off",
      "@eslint-react/set-state-in-effect": "off",
    },
  },
);


================================================
FILE: web/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="favicon.ico">
  </head>
  <body>
    <noscript>
      <strong>We're sorry docat web doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>


================================================
FILE: web/package.json
================================================
{
  "name": "docat-web",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "@emotion/react": "^11.14.0",
    "@emotion/styled": "^11.14.1",
    "@mui/icons-material": "^9.0.0",
    "@mui/material": "^9.0.0",
    "fuse.js": "^7.3.0",
    "react": "^19.2.5",
    "react-dom": "^19.2.5",
    "react-markdown": "^10.1.0",
    "react-router": "^7.14.0",
    "react-router-dom": "^7.14.0",
    "semver": "^7.7.4",
    "typescript": "^6.0.2",
    "vite": "^8.0.8"
  },
  "scripts": {
    "start": "VITE_DOCAT_VERSION=$(git describe --tags --always) vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest --watch=false",
    "lint": "eslint ."
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^6.0.1",
    "@eslint/js": "^10.0.1",
    "@types/semver": "^7.7.1",
    "@types/node": "^25.6.0",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "jsdom": "^29.0.2",
    "eslint": "^10.2.0",
    "eslint-config-prettier": "^10.1.8",
    "@eslint-react/eslint-plugin": "^4.2.3",
    "globals": "^17.4.0",
    "prettier": "^3.8.2",
    "typescript-eslint": "^8.58.1",
    "vitest": "^4.1.4"
  }
}


================================================
FILE: web/src/App.tsx
================================================
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { ConfigDataProvider } from './data-providers/ConfigDataProvider'
import { MessageBannerProvider } from './data-providers/MessageBannerProvider'
import { ProjectDataProvider } from './data-providers/ProjectDataProvider'
import { SearchProvider } from './data-providers/SearchProvider'
import { StatsDataProvider } from './data-providers/StatsDataProvider'
import Claim from './pages/Claim'
import Delete from './pages/Delete'
import Docs from './pages/Docs'
import Help from './pages/Help'
import Home from './pages/Home'
import NotFound from './pages/NotFound'
import Upload from './pages/Upload'

function App(): React.JSX.Element {
  const router = createBrowserRouter([
    {
      path: '/',
      errorElement: <NotFound />,
      children: [
        {
          path: '',
          element: <Home />
        },
        {
          path: 'upload',
          element: <Upload />
        },
        {
          path: 'claim',
          element: <Claim />
        },
        {
          path: 'delete',
          element: <Delete />
        },
        {
          path: 'help',
          element: <Help />
        },
        {
          path: ':project',
          children: [
            {
              path: '',
              element: <Docs />
            },
            {
              path: ':version',
              children: [
                {
                  path: '',
                  element: <Docs />
                },
                {
                  path: '*',
                  element: <Docs />
                }
              ]
            }
          ]
        }
      ]
    }
  ])

  return (
    <div className="App">
      <MessageBannerProvider>
        <ConfigDataProvider>
          <ProjectDataProvider>
            <StatsDataProvider>
              <SearchProvider>
                <RouterProvider router={router} />
              </SearchProvider>
            </StatsDataProvider>
          </ProjectDataProvider>
        </ConfigDataProvider>
      </MessageBannerProvider>
    </div>
  )
}

export default App


================================================
FILE: web/src/components/DataSelect.tsx
================================================
import { FormGroup, MenuItem, TextField } from '@mui/material'
import React, { useState } from 'react'

interface Props {
  emptyMessage: string
  errorMsg?: string
  value?: string
  label: string
  values: string[]
  onChange: (value: string) => void
}

export default function DataSelect(props: Props): React.JSX.Element {
  const [selectedValue, setSelectedValue] = useState<string>(
    props.value ?? 'none'
  )

  // clear field if selected value is not in options
  if (selectedValue !== 'none' && !props.values.includes(selectedValue)) {
    setSelectedValue('none')
  }

  return (
    <FormGroup>
      <TextField
        onChange={(e: { target: { value: string } }) => {
          setSelectedValue(e.target.value)
          props.onChange(e.target.value)
        }}
        value={props.values.length > 0 ? selectedValue : 'none'}
        label={props.label}
        error={props.errorMsg !== undefined && props.errorMsg !== ''}
        helperText={props.errorMsg}
        select
      >
        <MenuItem value="none" disabled>
          {props.emptyMessage}
        </MenuItem>

        {props.values.map((value) => {
          return (
            <MenuItem key={value} value={value}>
              {value}
            </MenuItem>
          )
        })}
      </TextField>
    </FormGroup>
  )
}


================================================
FILE: web/src/components/DocumentControlButtons.tsx
================================================
import { Home, Share } from '@mui/icons-material'
import {
  Checkbox,
  FormControl,
  FormControlLabel,
  FormGroup,
  MenuItem,
  Modal,
  Select,
  Tooltip
} from '@mui/material'
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import type ProjectDetails from '../models/ProjectDetails'

import styles from './../style/components/DocumentControlButtons.module.css'

interface Props {
  version: string
  versions: ProjectDetails[]
  onVersionChange: (version: string) => void
  getShareUrl: (options: { useLatest: boolean, hideUi: boolean }) => string
}

export default function DocumentControlButtons(props: Props): React.JSX.Element {
  const buttonStyle = { width: '25px', height: '25px' }

  const [shareModalOpen, setShareModalOpen] = useState<boolean>(false)
  const [shareModalUseLatest, setShareModalUseLatest] = useState<boolean>(false)
  const [shareModalHideUi, setShareModalHideUi] = useState<boolean>(false)

  // Cannot copy when page is served over HTTP
  const canCopy = navigator.clipboard !== undefined

  return (
    <div className={styles.controls}>
      <Tooltip title="Docs Overview" placement="top" arrow>
        <Link to="/" className={styles['home-button']}>
          <Home sx={buttonStyle} />
        </Link>
      </Tooltip>

      <FormControl>
        <Select
          sx={{
            "&.MuiOutlinedInput-root": {
              "&:hover fieldset": {
                borderColor: "rgba(0, 0, 0, 0.33)"
              },
              "&.Mui-focused fieldset": {
                borderColor: "rgba(0, 0, 0, 0.33)"
              }
            }
          }}
          className={styles['version-select']}
          onChange={(e) => {
            props.onVersionChange(e.target.value)
          }}
          value={
            props.versions.find((v) => v.name === props.version) !== undefined
              ? props.version
              : ''
          }
        >
          {props.versions
            .filter((v) => !v.hidden || v.name === props.version)
            .map((v) => (
              <MenuItem key={v.name} value={v.name}>
                {v.name + (v.tags.length > 0 ? ` (${v.tags.join(', ')})` : '')}
              </MenuItem>
            ))}
        </Select>
      </FormControl>

      <Tooltip title="Share Link" placement="top" arrow>
        <button
          className={styles['share-button']}
          onClick={() => {
            setShareModalOpen(true)
          }}
        >
          <Share sx={buttonStyle} />
        </button>
      </Tooltip>

      <Modal
        open={shareModalOpen}
        onClose={() => {
          setShareModalOpen(false)
        }}
        aria-labelledby="share-modal-title"
        aria-describedby="share-modal-description"
      >
        <div className={styles['share-modal']}>
          <div className={styles['share-modal-link-container']}>
            <p className={styles['share-modal-link']}>{props.getShareUrl({ useLatest: shareModalUseLatest, hideUi: shareModalHideUi })}</p>
            {canCopy && (
              <div className={styles['share-modal-copy-container']}>
                <button
                  className={styles['share-modal-copy']}
                  onClick={async () => {
                    const url = props.getShareUrl({
                      useLatest: shareModalUseLatest,
                      hideUi: shareModalHideUi
                    });
                    await navigator.clipboard.writeText(url);
                  }}
                >
                  Copy
                </button>
              </div>
            )}
          </div>

          <FormGroup>
            <FormControlLabel
              control={
                <Checkbox
                  checked={shareModalHideUi}
                  onChange={(e) => {
                    setShareModalHideUi(e.target.checked)
                  }}
                />
              }
              label="Hide Version Selector"
              className={styles['share-modal-label']}
            />
            <FormControlLabel
              control={
                <Checkbox
                  checked={shareModalUseLatest}
                  onChange={(e) => {
                    setShareModalUseLatest(e.target.checked)
                  }}
                />
              }
              label="Always use latest version"
              className={styles['share-modal-label']}
            />
          </FormGroup>

          <button
            className={styles['share-modal-close']}
            onClick={() => {
              setShareModalOpen(false)
            }}
          >
            Close
          </button>
        </div>
      </Modal>
    </div>
  )
}


================================================
FILE: web/src/components/FavoriteStar.tsx
================================================
import { Star, StarOutlined } from '@mui/icons-material'
import React, { useState } from 'react'
import ProjectRepository from '../repositories/ProjectRepository'

interface Props {
  projectName: string
  onFavoriteChanged: () => void
}

export default function FavoriteStar(props: Props): React.JSX.Element {
  const [isFavorite, setIsFavorite] = useState<boolean>(
    ProjectRepository.isFavorite(props.projectName)
  )

  const toggleFavorite = (): void => {
    const newIsFavorite = !isFavorite
    ProjectRepository.setFavorite(props.projectName, newIsFavorite)
    setIsFavorite(newIsFavorite)

    props.onFavoriteChanged()
  }

  const StarType = isFavorite ? Star : StarOutlined

  return (
    <StarType
      style={{ color: '#505050', cursor: 'pointer' }}
      onClick={toggleFavorite}
    />
  )
}


================================================
FILE: web/src/components/FileInput.tsx
================================================
import { InputLabel } from '@mui/material'
import React, { useRef, useState } from 'react'

import styles from './../style/components/FileInput.module.css'

interface Props {
  label: string
  okTypes: string[]
  file: File | undefined
  onChange: (file: File | undefined) => void
  isValid: (file: File) => boolean
}

export default function FileInput(props: Props): React.JSX.Element {
  const [fileName, setFileName] = useState<string>(
    props.file?.name !== undefined ? props.file.name : ''
  )
  const [dragActive, setDragActive] = useState<boolean>(false)
  const inputRef = useRef(null)

  /**
   * Checks if a file was selected and if it is valid
   * before it is selected.
   * @param files FileList from the event
   */
  const updateFileIfValid = (files: FileList | null): void => {
    if (files == null || files.length < 1 || files[0] == null) {
      return
    }

    const file = files[0]
    if (!props.isValid(file)) {
      return
    }

    setFileName(file.name)
    props.onChange(file)
  }

  /**
   * This updates the file upload container to show a custom style when
   * the user is dragging a file into or out of the container.
   * @param e drag enter event
   */
  const handleDragEvents = (e: React.DragEvent<HTMLDivElement>): void => {
    e.preventDefault()
    e.stopPropagation()

    if (e.type === 'dragenter' || e.type === 'dragover') {
      setDragActive(true)
    } else if (e.type === 'dragleave') {
      setDragActive(false)
    }
  }

  /**
   * Handles the drop event when the user drops a file into the container.
   * @param e DragEvent
   */
  const handleDrop = (e: React.DragEvent<HTMLDivElement>): void => {
    e.preventDefault()
    e.stopPropagation()
    setDragActive(false)

    if (e.dataTransfer?.files[0] == null) {
      return
    }

    updateFileIfValid(e.dataTransfer.files)
  }

  /**
   * Handles the file input via the file browser.
   * @param e change event
   */
  const handleSelect = (e: React.ChangeEvent<HTMLInputElement>): void => {
    e.preventDefault()

    updateFileIfValid(e.target.files)
  }

  /**
   * This triggers the input when the container is clicked.
   */
  const onButtonClick = (): void => {
    if (inputRef?.current != null) {
      // @ts-expect-error - the ref is not null, therefore the button should be able to be clicked
      inputRef.current.click()
    }
  }

  return (
    <div className={styles['file-upload-container']}>
      {!dragActive && (
        <InputLabel className={styles['file-upload-label']}>
          {props.label}
        </InputLabel>
      )}

      <div
        className={
          dragActive
            ? styles['file-drop-zone'] + ' ' + styles['drag-active']
            : styles['file-drop-zone']
        }
        onDragEnter={handleDragEvents}
        onClick={onButtonClick}
      >
        <input
          name="upload"
          type="file"
          className={styles['file-input']}
          ref={inputRef}
          accept={props.okTypes.join(',')}
          onChange={handleSelect}
        />

        {fileName !== '' && (
          <>
            <p>{fileName}</p>
            <p>-</p>
          </>
        )}

        <p>Drag zip file here or</p>

        <button className={styles['file-upload-button']} type="button">
          click to browse.
        </button>

        {dragActive && (
          <div
            className={styles['drag-file-element']}
            onDragEnter={handleDragEvents}
            onDragLeave={handleDragEvents}
            onDragOver={handleDragEvents}
            onDrop={handleDrop}
          ></div>
        )}
      </div>
    </div>
  )
}


================================================
FILE: web/src/components/Footer.tsx
================================================
import { Box } from '@mui/material'
import { useState, JSX } from 'react'
import { Link } from 'react-router-dom'
import { useConfig } from '../data-providers/ConfigDataProvider'
import styles from './../style/components/Footer.module.css'

export default function Footer(): JSX.Element {

  const defaultFooter = (
    <></>
  )

  const [footer, setFooter] = useState<JSX.Element>(defaultFooter)
  const config = useConfig()

  // set custom header if found in config
  if (config.footerHTML != null && footer === defaultFooter) {
    setFooter(<div dangerouslySetInnerHTML={{ __html: config.footerHTML }} />)
  }

  return (
    <div className={styles.footer}>
      <Link to="/help" className={styles['help-link']}>
        HELP
      </Link>

      <Box sx={{ fontSize: '1.05em', fontWeight: 300, opacity: 0.6, marginLeft: '8px', marginTop: 1 }}>
        {footer}
      </Box>

      <div className={styles['version-info']}>
        <Link to="https://github.com/docat-org/docat" target='_blank'>
          VERSION{'  '}
          {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'}
        </Link>
      </div>
    </div>
  )
}


================================================
FILE: web/src/components/Header.tsx
================================================
import { useState, JSX } from 'react'
import { Link } from 'react-router-dom'

import { useConfig } from '../data-providers/ConfigDataProvider'

import docatLogo from '../assets/logo.png'
import styles from './../style/components/Header.module.css'


export default function Header(): JSX.Element {
  const defaultHeader = (
    <>
      <img alt="docat logo" src={docatLogo} />
      <h1>DOCAT</h1>
    </>
  )

  const [header, setHeader] = useState<JSX.Element>(defaultHeader)
  const config = useConfig()

  // set custom header if found in config
  if (config.headerHTML != null && header === defaultHeader) {
    setHeader(<div dangerouslySetInnerHTML={{ __html: config.headerHTML }} />)
  }

  return (
    <div className={styles.header}>
      <Link to="/">{header}</Link>
    </div>
  )
}


================================================
FILE: web/src/components/IFrame.tsx
================================================
import React, { useRef } from 'react'
import { generateKey } from '../data-providers/RandomId'

import styles from '../style/components/IFrame.module.css'
interface Props {
  src: string
  onPageChanged: (page: string, hash: string, title?: string) => void
  onHashChanged: (hash: string) => void
  onTitleChanged: (title: string) => void
  onNotFound: () => void
  onFaviconChanged?: (faviconUrl: string | null) => void
}

export default function IFrame(props: Props): React.JSX.Element {
  const iFrameRef = useRef<HTMLIFrameElement>(null)

  const onIframeLoad = (): void => {
    if (iFrameRef.current === null) {
      console.error('iFrameRef is null')
      return
    }

    // remove the event listeners to prevent memory leaks
    iFrameRef.current.contentWindow?.removeEventListener(
      'hashchange',
      hashChangeEventListener
    )
    iFrameRef.current.contentWindow?.removeEventListener(
      'titlechange',
      titleChangeEventListener
    )

    const url = iFrameRef.current?.contentDocument?.location.href
    if (url == null) {
      console.warn('IFrame onload event triggered, but url is null')
      return
    }

    // make all external links in iframe open in new tab
    // and make internal links replace the iframe url so that change
    // doesn't show up in the page history (we'd need to click back twice)
    iFrameRef.current.contentDocument
      ?.querySelectorAll('a')
      .forEach((a: HTMLAnchorElement) => {
        if (typeof a.href === 'string' && !a.href.startsWith(window.location.origin)) {
          a.setAttribute('target', '_blank')
          return
        }

        const href = a.getAttribute('href') ?? ''
        if (href.trim() === '') {
          // ignore empty links, may be handled with js internally.
          // Will inevitably cause the user to have to click back
          // multiple times to get back to the previous page.
          return
        }

        // From here: https://www.ozzu.com/questions/358584/how-do-you-ignore-iframes-javascript-history
        a.onclick = () => {
          iFrameRef.current?.contentWindow?.location.replace(a.href)
          return false
        }
      })

    // React to page 404ing
    void (async (): Promise<void> => {
      const response = await fetch(url, { method: 'HEAD' })
      if (response.status === 404) {
        props.onNotFound()
      }
    })()

    // Add the event listener again
    iFrameRef.current.contentWindow?.addEventListener(
      'hashchange',
      hashChangeEventListener
    )
    iFrameRef.current.contentWindow?.addEventListener(
      'titlechange',
      titleChangeEventListener
    )

    const parts = url.split('/doc/').slice(1).join('/doc/').split('/')
    const urlPageAndHash = parts.slice(2).join('/')
    const hashIndex = urlPageAndHash.includes('#')
      ? urlPageAndHash.indexOf('#')
      : urlPageAndHash.length
    const urlPage = urlPageAndHash.slice(0, hashIndex)
    const urlHash = urlPageAndHash.slice(hashIndex)
    const title = iFrameRef.current?.contentDocument?.title

    props.onPageChanged(urlPage, urlHash, title)

    const favicon = extractFaviconUrl(iFrameRef.current.contentDocument)
    props.onFaviconChanged?.(favicon)
  }

  const extractFaviconUrl = (doc: Document | null | undefined): string | null => {
    if (doc == null) {
      return null
    }

    const link = doc.querySelector('link[rel="icon"]') as HTMLLinkElement | null
    if (!link?.href) {
      return null
    }

    return link.href
  }

  const hashChangeEventListener = (): void => {
    if (iFrameRef.current === null) {
      console.error('hashChangeEvent from iframe but iFrameRef is null')
      return
    }

    const url = iFrameRef.current?.contentDocument?.location.href
    if (url == null) {
      return
    }

    let hash = url.split('#')[1]
    if (hash !== null) {
      hash = `#${hash}`
    } else {
      hash = ''
    }

    props.onHashChanged(hash)
  }

  const titleChangeEventListener = (): void => {
    if (iFrameRef.current === null) {
      console.error('titleChangeEvent from iframe but iFrameRef is null')
      return
    }

    const title = iFrameRef.current?.contentDocument?.title
    if (title == null) {
      return
    }

    props.onTitleChanged(title)
  }

  return (
    <iframe
      ref={iFrameRef}
      key={generateKey()}
      className={styles['docs-iframe']}
      src={props.src}
      title="docs"
      onLoad={onIframeLoad}
    />
  )
}


================================================
FILE: web/src/components/InfoBanner.tsx
================================================
import { Alert, Snackbar } from '@mui/material'
import React, { useState } from 'react'
import { type Message } from '../data-providers/MessageBannerProvider'
import { generateKey } from '../data-providers/RandomId'

interface Props {
  message: Message
}

export default function Banner(props: Props): React.JSX.Element {
  const [show, setShow] = useState<boolean>(false)
  const [prevMessage, setPrevMessage] = useState(props.message);

  if (props.message !== prevMessage) {
    setPrevMessage(props.message);
    setShow(true);
  }

  return (
    <Snackbar
      key={generateKey()}
      open={show && props.message.content != null}
      autoHideDuration={props.message.showMs}
      onClose={() => {
        setShow(false)
      }}
    >
      <Alert
        onClose={() => {
          setShow(false)
        }}
        severity={props.message.type}
        sx={{ width: '100%' }}
      >
        {props.message.content}
      </Alert>
    </Snackbar>
  )
}


================================================
FILE: web/src/components/NavigationTitle.tsx
================================================
import React from 'react';
import { ArrowBackIos } from '@mui/icons-material'
import { Link } from 'react-router-dom'

import styles from './../style/components/NavigationTitle.module.css'

interface Props {
  title: string
  backLink?: string
  description?: string | React.JSX.Element
}

export default function NavigationTitle(props: Props): React.JSX.Element {
  return (
    <div className={styles['nav-title']}>
      <div className={styles['page-header']}>
        <Link
          to={props.backLink != null ? props.backLink : '/'}
          className={styles['back-link']}
        >
          <ArrowBackIos />
        </Link>
        <h1 className={styles['page-title']}>{props.title}</h1>
      </div>

      <div className={styles['page-description']}>{props.description}</div>
    </div>
  )
}


================================================
FILE: web/src/components/PageLayout.tsx
================================================
import { JSX } from 'react'
import styles from './../style/components/PageLayout.module.css'
import Footer from './Footer'
import Header from './Header'
import NavigationTitle from './NavigationTitle'

interface Props {
  title: string
  description?: string | JSX.Element
  showSearchBar?: boolean
  children: JSX.Element | JSX.Element[]
}

export default function PageLayout(props: Props): JSX.Element {
  return (
    <>
      <Header />
      <div className={styles.main}>
        <NavigationTitle title={props.title} description={props.description} />
        {props.children}
      </div>
      <Footer />
    </>
  )
}


================================================
FILE: web/src/components/Project.tsx
================================================
import React from 'react';
import { Link } from 'react-router-dom'
import { type Project as ProjectType } from '../models/ProjectsResponse'
import ProjectRepository from '../repositories/ProjectRepository'
import styles from './../style/components/Project.module.css'

import { Box, Tooltip, Typography } from '@mui/material'
import FavoriteStar from './FavoriteStar'

interface Props {
  project: ProjectType
  onFavoriteChanged: () => void
}

function timeSince(date: Date) {
  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
  let interval = seconds / 31536000;

  if (interval > 1) {
    return Math.floor(interval) + " years";
  }
  interval = seconds / 2592000;
  if (interval > 1) {
    return Math.floor(interval) + " months";
  }
  interval = seconds / 86400;
  if (interval > 1) {
    return Math.floor(interval) + " days";
  }
  interval = seconds / 3600;
  if (interval > 1) {
    return Math.floor(interval) + " hours";
  }
  interval = seconds / 60;
  if (interval > 1) {
    return Math.floor(interval) + " minutes";
  }
  return Math.floor(seconds) + " seconds";
}

export default function Project(props: Props): React.JSX.Element {
  const latestVersion = ProjectRepository.getLatestVersion(props.project.versions)

  return (
    <div className={styles['project-card']}>

        {props.project.logo ?
            <>
              <Link to={`${props.project.name}/latest`}>
                <img
                  className={styles['project-logo']}
                  src={ProjectRepository.getProjectLogoURL(props.project.name)}
                  alt={`${props.project.name} project logo`}
                />
              </Link>
            </> : <></>
        }

      <div className={styles['project-header']}>
        <Link to={`${props.project.name}/latest`}>
          <div className={styles['project-card-title']}>
            {props.project.name}{' '}
            <span className={styles['secondary-typography']}>
              {latestVersion.name}
            </span>
          </div>
        </Link>

        <Tooltip title={new Date(latestVersion.timestamp).toISOString().slice(0, -8).replace('T', ' ')} placement="left" arrow >
          <Box sx={{
              display: {
                xs: 'none',
                sm: 'inherit'
              }
            }} className={styles['secondary-typography']}>
            {timeSince(new Date(latestVersion.timestamp))} ago
          </Box>
        </Tooltip>
      </div>
      <div className={styles['project-header']}>
        <div className={styles.subhead}>
          {props.project.versions.length === 1
            ? `${props.project.versions.length} version`
            : `${props.project.versions.length} versions`}
            <Typography component={'span'} sx={{ marginLeft: 1.5, fontSize: '0.9em', fontWeight: 300 }}>{props.project.storage}</Typography>
        </div>

        <FavoriteStar
          projectName={props.project.name}
          onFavoriteChanged={props.onFavoriteChanged}
        />
      </div>
    </div>
  )
}


================================================
FILE: web/src/components/ProjectList.tsx
================================================
import React from 'react';
import Project from './Project'

import { type Project as ProjectType } from '../models/ProjectsResponse'
import styles from './../style/components/ProjectList.module.css'

interface Props {
  projects: ProjectType[]
  onFavoriteChanged: () => void
}

export default function ProjectList(props: Props): React.JSX.Element {
  if (props.projects.length === 0) {
    return <></>
  }

  return (
    <div className={styles['project-list']}>
      {props.projects.map((project) => (
        <Project
          project={project}
          key={project.name}
          onFavoriteChanged={() => {
            props.onFavoriteChanged()
          }}
        />
      ))}
    </div>
  )
}


================================================
FILE: web/src/components/SearchBar.tsx
================================================
import SearchIcon from '@mui/icons-material/Search';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import { Divider, IconButton, InputBase, Paper, Tooltip } from '@mui/material';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearch } from '../data-providers/SearchProvider';


interface Props {
  showFavourites: boolean
  onShowFavourites: (all: boolean) => void
}

export default function SearchBar(props: Props): React.JSX.Element {
  const [showFavourites, setShowFavourites] = useState(true);
  const [searchParams, setSearchParams] = useSearchParams();

  const { query, setQuery } = useSearch()
  const [searchQuery, setSearchQuery] = useState<string>(query)


  const updateSearch = (q: string) => {
    setSearchQuery(q)
    setQuery(q)

    if (q) {
      setSearchParams({q})
    } else {
      setSearchParams({})
    }
  }

  useEffect(() => {
    const q = searchParams.get("q")
    if (q) {
      updateSearch(q)
    }
    setShowFavourites(props.showFavourites)
  }, [props.showFavourites]);

  const onFavourites = (show: boolean): void => {
    setSearchParams({})
    setSearchQuery("")
    setQuery("")

    setShowFavourites(show)
    props.onShowFavourites(!show)
  }

  const onSearch = (e: React.ChangeEvent<HTMLInputElement>): void => {
    setShowFavourites(false)
    updateSearch(e.target.value)
  }

  return (
    <Paper
      component="form"
      sx={{
        p: '2px 4px',
        display: 'flex',
        alignItems: 'center',
        maxWidth: 600,
        marginLeft: '16px',
      }}
    >
      <InputBase
        sx={{ ml: 1, flex: 1 }}
        placeholder="Search Docs"
        inputProps={{ 'aria-label': 'search docs' }}
        value={searchQuery}
        onChange={onSearch}
        onKeyDown={(e): void => {
          if (e.key === 'Enter') {
            e.preventDefault()
            setQuery(searchQuery)
          }
        }}

      />
      <IconButton type="button" sx={{ p: '10px' }} aria-label="search">
        <SearchIcon />
      </IconButton>
      <Divider sx={{ height: 28, m: 0.5 }} orientation="vertical" />
      <Tooltip title={`Show ${showFavourites ? 'all docs' : 'favourites only'}`} placement="right" arrow>
        <IconButton onClick={() => onFavourites(!showFavourites)} sx={{ p: '10px' }} aria-label="directions">
          { showFavourites  ?  <StarIcon /> : <StarBorderIcon /> }
        </IconButton>
      </Tooltip>
    </Paper>
  )
}


================================================
FILE: web/src/components/StyledForm.tsx
================================================
import React from 'react';
import styles from './../style/components/StyledForm.module.css'

interface Props {
  children: React.JSX.Element[]
}

export default function StyledForm(props: Props): React.JSX.Element {
  if (props.children.length === 0) {
    return <></>
  }

  return <div className={styles.form}>{props.children}</div>
}


================================================
FILE: web/src/data-providers/ConfigDataProvider.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
  We need any, because we don't know the type of the children
*/

import { createContext, use, useEffect, useState, JSX } from 'react'

export interface Config {
  headerHTML?: string
  footerHTML?: string
}

const Context = createContext<Config>({})

/**
 * Provides the config from the backend for the whole application,
 * so it can be used in every component without it being reloaded the whole time.
 */
export const ConfigDataProvider = ({ children }: any): JSX.Element => {
  const [config, setConfig] = useState<Config>({})

  useEffect(() => {
    void (async () => {
      try {
        const res = await fetch('/doc/config.json')
        const data = (await res.json()) as Config
        setConfig(data)
      } catch (err) {
        console.error(err)
      }
    })()
  }, [])

  return <Context value={config}>{children}</Context>
}

export const useConfig = (): Config => use(Context)


================================================
FILE: web/src/data-providers/MessageBannerProvider.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
  We need any, because we don't know the type of the children
*/

import React, { useState, useCallback, use, JSX } from 'react'
import Banner from '../components/InfoBanner'

export interface Message {
  content: string | JSX.Element | undefined
  type: 'success' | 'info' | 'warning' | 'error'
  showMs: number | null // null = infinite
}

interface MessageBannerState {
  showMessage: (message: Message) => void
  clearMessages: () => void
}

export const Context = React.createContext<MessageBannerState>({
  showMessage: (): void => {
    console.warn('MessageBannerProvider not initialized')
  },
  clearMessages: (): void => {
    console.warn('MessageBannerProvider not initialized')
  }
})

export function MessageBannerProvider({ children }: any): JSX.Element {
  // We need to store the last timeout, so we can clear when a new message is shown
  const [lastTimeout, setLastTimeout] = useState<ReturnType<typeof setTimeout>>()
  const [message, setMessage] = useState<Message>({
    content: undefined,
    type: 'success',
    showMs: 6000
  })

  const showMessage = useCallback((message: Message) => {
    if (lastTimeout !== undefined) {
      clearTimeout(lastTimeout)
    }

    setMessage(message)

    if (message.showMs === null) {
      // don't hide message
      return
    }

    // Hide message after 6 seconds
    const newTimeout = setTimeout(() => {
      setMessage({
        content: undefined,
        type: 'success',
        showMs: 6000
      })
    }, message.showMs)

    setLastTimeout(newTimeout)
  }, [])

  const clearMessages = useCallback(() => {
    if (lastTimeout !== undefined) {
      clearTimeout(lastTimeout)
    }

    setMessage({
      content: undefined,
      type: 'success',
      showMs: 6000
    })
  }, [])

  return (
    <Context value={{ showMessage, clearMessages }}>
      <Banner message={message} />
      {children}
    </Context>
  )
}

export const useMessageBanner = (): MessageBannerState => use(Context)


================================================
FILE: web/src/data-providers/ProjectDataProvider.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
  We need any, because we don't know the type of the children
*/

import React, { createContext, use, useEffect, useState, JSX } from 'react'
import { type Project } from '../models/ProjectsResponse'
import type ProjectsResponse from '../models/ProjectsResponse'
import { useMessageBanner } from './MessageBannerProvider'

interface ProjectState {
  projects: Project[] | null
  loadingFailed: boolean
  reload: () => void
}

const Context = createContext<ProjectState>({
  projects: null,
  loadingFailed: false,
  reload: (): void => {
    console.warn('ProjectDataProvider not initialized')
  }
})

/**
 * Provides the projects for the whole application,
 * so that it can be used in every component without it being reloaded
 * the whole time or having to be passed down.
 *
 * If reloading is required, call the reload function.
 */
export function ProjectDataProvider({ children }: any): JSX.Element {
  const { showMessage } = useMessageBanner()

  const loadData = (): void => {
    void (async (): Promise<void> => {
      try {
        const response = await fetch('/api/projects?include_hidden=true')

        if (!response.ok) {
          throw new Error(
            `Failed to load projects, status code: ${response.status}`
          )
        }

        const data: ProjectsResponse = await response.json()
        setState({
          projects: data.projects,
          loadingFailed: false,
          reload: loadData
        })
      } catch (e) {
        console.error(e)

        showMessage({
          content: 'Failed to load projects',
          type: 'error',
          showMs: 6000
        })

        setState({
          projects: null,
          loadingFailed: true,
          reload: loadData
        })
      }
    })()
  }

  const [state, setState] = useState<ProjectState>({
    projects: null,
    loadingFailed: false,
    reload: loadData
  })

  useEffect(() => {
    loadData()
  }, [])

  return <Context value={state}>{children}</Context>
}

export const useProjects = (): ProjectState => use(Context)


================================================
FILE: web/src/data-providers/RandomId.tsx
================================================
// Generate a mostly random key
export const generateKey = () => {
  // Use the native function not available in http mode
  if (window.crypto && crypto.randomUUID) {
    return crypto.randomUUID();
  }

  // Fallback
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
}


================================================
FILE: web/src/data-providers/SearchProvider.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
  We need any, because we don't know the type of the children
*/

import React, { createContext, use, useEffect, useState, JSX } from 'react'
import { type Project } from '../models/ProjectsResponse'
import { useProjects } from './ProjectDataProvider'
import Fuse from 'fuse.js'

interface SearchState {
  filteredProjects: Project[] | null
  query: string
  setQuery: (query: string) => void
}

const Context = createContext<SearchState>({
  filteredProjects: null,
  query: '',
  setQuery: (): void => {
    console.warn('SearchDataProvider not initialized')
  }
})

export function SearchProvider({ children }: any): JSX.Element {
  const { projects } = useProjects()

  const filterProjects = (query: string): Project[] | null => {
    if (projects == null) {
      return null
    }

    if (query.trim() === '') {
      return projects
    }

    const fuse = new Fuse(projects, {
      keys: ['name'],
      includeScore: true
    })

    // sort by match score
    return fuse
      .search(query)
      .sort((x, y) => (x.score ?? 0) - (y.score ?? 0))
      .map((result) => result.item)
  }

  const setQuery = (query: string): void => {
    setState({
      query,
      filteredProjects: filterProjects(query),
      setQuery
    })
  }

  const [state, setState] = useState<SearchState>({
    filteredProjects: null,
    query: '',
    setQuery
  })

  useEffect(() => {
    setState({
      query: '',
      filteredProjects: filterProjects(''),
      setQuery
    })
  }, [projects])

  return <Context value={state}>{children}</Context>
}

export const useSearch = (): SearchState => use(Context)


================================================
FILE: web/src/data-providers/StatsDataProvider.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
  We need any, because we don't know the type of the children
*/

import { createContext, use, useEffect, useState, JSX } from 'react'
import { useMessageBanner } from './MessageBannerProvider'


type Stats = {
  n_projects: number
  n_versions: number
  storage: string
}

interface StatsState {
  stats: Stats | null
  loadingFailed: boolean
  reload: () => void
}

const Context = createContext<StatsState>({
  stats: null,
  loadingFailed: false,
  reload: (): void => {
    console.warn('StatsProvider not initialized')
  }
})

/**
 * Provides the stats of the docat instance
 * If reloading is required, call the reload function.
 */
export function StatsDataProvider({ children }: any): JSX.Element {
  const { showMessage } = useMessageBanner()

  const loadData = (): void => {
    void (async (): Promise<void> => {
      try {
        const response = await fetch('/api/stats')

        if (!response.ok) {
          throw new Error(
            `Failed to load stats, status code: ${response.status}`
          )
        }

        const data: Stats = await response.json()
        setState({
          stats: data,
          loadingFailed: false,
          reload: loadData
        })
      } catch (e) {
        console.error(e)

        showMessage({
          content: 'Failed to load stats',
          type: 'error',
          showMs: 6000
        })

        setState({
          stats: null,
          loadingFailed: true,
          reload: loadData
        })
      }
    })()
  }

  const [state, setState] = useState<StatsState>({
    stats: null,
    loadingFailed: false,
    reload: loadData
  })

  useEffect(() => {
    loadData()
  }, [])

  return <Context value={state}>{children}</Context>
}

export const useStats = (): StatsState => use(Context)


================================================
FILE: web/src/index.css
================================================
* {
  margin: 0;
  padding: 0;
  font-family: "Roboto", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;

  --primary-foreground: #383838;
  --secondary-foreground: #e8e8e8;
  --button-primary: #2c3e50;
  --icons: #505050;
}

h1 {
  font-size: 30px;
  font-weight: 300;
  color: var(--primary-foreground);
}

a,
u {
  text-decoration: none;
  color: black;
}

code {
  font-family: "Consolas", "Liberation Mono", Menlo, Courier, monospace;
}

.loading-spinner {
  --spinner-size: 40px;

  display: inline-block;
  width: var(--spinner-size);
  height: var(--spinner-size);
  margin-bottom: 5vh;

  position: relative;
  left: calc(50% - var(--spinner-size) / 2 - 8px);
}

.loading-spinner:after {
  content: " ";
  display: block;
  width: var(--spinner-size);
  height: var(--spinner-size);
  margin: 8px;
  border-radius: 50%;
  border: 6px solid var(--button-primary);
  border-color: var(--button-primary) transparent var(--button-primary)
    transparent;
  animation: loading-spinner 1.2s linear infinite;
}

@keyframes loading-spinner {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}


================================================
FILE: web/src/index.tsx
================================================
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)


================================================
FILE: web/src/models/ProjectDetails.ts
================================================
export default class ProjectDetails {
  name: string
  hidden: boolean
  timestamp: Date
  tags: string[]

  constructor(name: string, tags: string[], hidden: boolean, timestamp: Date) {
    this.name = name
    this.tags = tags
    this.hidden = hidden
    this.timestamp = timestamp
  }
}


================================================
FILE: web/src/models/ProjectsResponse.ts
================================================
import type ProjectDetails from './ProjectDetails'

export interface Project {
  name: string
  logo: boolean
  storage: string
  versions: ProjectDetails[]
}

export default interface ProjectsResponse {
  projects: Project[]
}


================================================
FILE: web/src/pages/Claim.tsx
================================================
import { TextField } from '@mui/material'
import React, { useEffect, useState, JSX } from 'react'
import DataSelect from '../components/DataSelect'
import PageLayout from '../components/PageLayout'
import StyledForm from '../components/StyledForm'
import { useMessageBanner } from '../data-providers/MessageBannerProvider'
import { useProjects } from '../data-providers/ProjectDataProvider'
import ProjectRepository from '../repositories/ProjectRepository'

export default function Claim(): JSX.Element {
  const { projects, loadingFailed } = useProjects()

  const { showMessage } = useMessageBanner()
  const [project, setProject] = useState<string>('none')
  const [token, setToken] = useState<string>('')

  const [projectMissing, setProjectMissing] = useState<boolean | null>(null)

  useEffect(() => {
     document.title = 'Claim Token | docat'
  }, []);

  const claim = async (): Promise<void> => {
    if (project == null || project === '' || project === 'none') {
      setProjectMissing(true)
      return
    }

    try {
      const response = await ProjectRepository.claim(project)
      setToken(response.token)
    } catch (e) {
      console.error(e)
      showMessage({
        content: (e as { message: string }).message,
        type: 'error',
        showMs: 6000
      })
    }
  }

  /**
   * Returns loaded project names for DataSelect
   * @returns project names as string[] or an empty array
   */
  const getProjects = (): string[] => {
    if (loadingFailed || projects == null) {
      return []
    }

    return projects.map((project) => project.name)
  }

  const onProjectSelect = (p: string): void => {
    if (p == null || p === '' || p === 'none') {
      setProjectMissing(true)
    } else {
      setProjectMissing(false)
    }

    setProject(p)
    setToken('')
  }

  return (
    <PageLayout
      title="Claim Token"
      description="Please make sure to store this token safely, as only one token can be generated per project and you will not be able to claim it again."
    >
      <StyledForm>
        <DataSelect
          emptyMessage="Please select a Project"
          label="Project"
          values={getProjects()}
          onChange={onProjectSelect}
          value={project ?? 'none'}
          errorMsg={
            projectMissing === true ? 'Please select a Project' : undefined
          }
        />

        {token !== '' ? (
          <TextField
            fullWidth
            label="Token"
            slotProps={{
              input: {
                readOnly: true,
              },
            }}
            value={token}
          >
            {token}
          </TextField>
        ) : (
          <></>
        )}

        <button
          type="submit"
          disabled={token !== ''}
          onClick={async () => {
            try {
              await claim();
            } catch (e) {
              console.error(e);
            }
          }}
        >
          Claim
        </button>
      </StyledForm>
    </PageLayout>
  )
}


================================================
FILE: web/src/pages/Delete.tsx
================================================
import { TextField } from '@mui/material'
import React, { useEffect, useState, JSX } from 'react'
import DataSelect from '../components/DataSelect'
import ProjectRepository from '../repositories/ProjectRepository'
import StyledForm from '../components/StyledForm'
import PageLayout from '../components/PageLayout'
import { useProjects } from '../data-providers/ProjectDataProvider'
import type ProjectDetails from '../models/ProjectDetails'
import { useMessageBanner } from '../data-providers/MessageBannerProvider'

interface Validation {
  projectMissing?: boolean
  versionMissing?: boolean
  tokenMissing?: boolean
}

export default function Delete(): JSX.Element {
  const { showMessage } = useMessageBanner()
  const [project, setProject] = useState<string>('none')
  const [version, setVersion] = useState<string>('none')
  const [token, setToken] = useState<string>('')
  const { projects, loadingFailed, reload } = useProjects()
  const [versions, setVersions] = useState<ProjectDetails[]>([])
  const [validation, setValidation] = useState<Validation>({})

  document.title = 'Delete Documentation | docat'

  useEffect(() => {
    if (project === '' || project === 'none') {
      setVersions([])
      return
    }

    setVersions(projects?.find((p) => p.name === project)?.versions ?? [])
  }, [project])

  const validate = (
    field: 'project' | 'version' | 'token',
    value: string
  ): boolean => {
    const valid = value !== 'none' && value !== ''
    setValidation({ ...validation, [`${field}Missing`]: !valid })
    return valid
  }

  const deleteDocumentation = (): void => {
    void (async () => {
      if (!validate('project', project)) return
      if (!validate('version', version)) return
      if (!validate('token', token)) return

      try {
        await ProjectRepository.deleteDoc(project, version, token)

        showMessage({
          type: 'success',
          content: `Documentation for ${project} (${version}) deleted successfully.`,
          showMs: 6000
        })
        setProject('none')
        setVersion('none')
        setToken('')
        reload()
      } catch (e) {
        console.error(e)

        showMessage({
          type: 'error',
          content: (e as { message: string }).message,
          showMs: 6000
        })
      }
    })()
  }

  /**
   * Returns loaded project names for DataSelect
   * @returns string[] or an empty array
   */
  const getProjects = (): string[] => {
    if (loadingFailed || projects == null) {
      return []
    }

    return projects.map((project) => project.name)
  }

  /**
   * Returns loaded Versions for DataSelect
   * @returns string[] or an empty array
   */
  const getVersions = (): string[] => {
    if (project === '' || project === 'none') {
      return []
    }

    return versions.map((v) => v.name)
  }

  return (
    <PageLayout title="Delete Documentation">
      <StyledForm>
        <DataSelect
          emptyMessage="Please select a Project"
          label="Project"
          values={getProjects()}
          onChange={(project) => {
            setProject(project)
            setVersion('none')
            validate('project', project)
          }}
          value={project ?? 'none'}
          errorMsg={
            validation.projectMissing === true
              ? 'Please select a Project'
              : undefined
          }
        />
        <DataSelect
          emptyMessage="Please select a Version"
          label="Version"
          values={getVersions()}
          onChange={(version) => {
            setVersion(version)
            validate('version', version)
          }}
          value={version ?? 'none'}
          errorMsg={
            validation.versionMissing === true
              ? 'Please select a Version'
              : undefined
          }
        />

        <TextField
          fullWidth
          label="Token"
          value={token}
          onChange={(e) => {
            setToken(e.target.value)
            validate('token', e.target.value)
          }}
          error={validation.tokenMissing}
          helperText={
            validation.tokenMissing === true
              ? 'Please enter a Token'
              : undefined
          }
        >
          {token}
        </TextField>

        <button type="submit" onClick={deleteDocumentation}>
          Delete
        </button>
      </StyledForm>
    </PageLayout>
  )
}


================================================
FILE: web/src/pages/Docs.tsx
================================================
import { useEffect, useMemo, useState, useRef, JSX } from 'react'
import ProjectRepository from '../repositories/ProjectRepository'
import type ProjectDetails from '../models/ProjectDetails'
import LoadingPage from './LoadingPage'
import NotFound from './NotFound'
import DocumentControlButtons from '../components/DocumentControlButtons'
import IFrame from '../components/IFrame'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { useMessageBanner } from '../data-providers/MessageBannerProvider'

export default function Docs(): JSX.Element {
  const params = useParams()
  const [searchParams] = useSearchParams()
  const location = useLocation()
  const { showMessage, clearMessages } = useMessageBanner()

  const [versions, setVersions] = useState<ProjectDetails[]>([])
  const [displayVersion, setDisplayVersion] = useState<ProjectDetails | null>(null)
  const [projectLoading, setProjectLoading] = useState<boolean>(true)
  const [notFound, setNotFound] = useState<boolean>(false)

  const pageRef = useRef(params['*'] ?? '')
  const hashRef = useRef(location.hash)

  const [project, setProject] = useState<string>(params.project ?? '')
  const [version, setVersion] = useState<string>(params.version ?? 'latest')
  const [hideUi, setHideUi] = useState<boolean>(searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true')
  const [iframeUpdateTrigger, setIframeUpdateTrigger] = useState<number>(0)

  // This provides the url for the iframe.
  // It is always the same, except when the version changes,
  // as this memo will trigger a re-render of the iframe, which
  // is not needed when only the page or hash changes, because
  // the iframe keeps track of that itself.
  const iFrameSrc = useMemo(() => {
    if (!displayVersion) {
      return ''
    }
    return ProjectRepository.getProjectDocsURL(
      project,
      displayVersion.name,
      pageRef.current,
      hashRef.current
    )
  }, [project, displayVersion, iframeUpdateTrigger])

  useEffect(() => {
    setProjectLoading(true)
    const loadProject = async () => {
      try {
        let allVersions = await ProjectRepository.getVersions(project)
        allVersions = allVersions.sort((a, b) =>
          ProjectRepository.compareVersions(a, b)
        )
        setVersions(allVersions)
      } catch (e) {
        console.error(e)
      }
    }
    loadProject().finally(() => {
      setProjectLoading(false)
    })
  }, [project]);

  const buildBrowserUrl = (project: string, version: string, page: string, hash: string, hideUi: boolean): string => {
    return `/${project}/${version}/${page}${hideUi ? '?hide-ui' : ''}${hash}`
  }

  const getShareUrl = (options: { useLatest: boolean, hideUi: boolean }): string => {
    return buildBrowserUrl(project, options.useLatest ? 'latest' : displayVersion?.name ?? 'latest', pageRef.current, hashRef.current, options.hideUi)
  }

  const updateUrl = (newProject: string, newVersion: string, hideUi: boolean): void => {
    window.history.pushState(null, '', buildBrowserUrl(newProject, newVersion, pageRef.current, hashRef.current, hideUi))
  }

  useEffect(() => {
    if (versions.length === 0) {
      return
    }

    if (version === 'latest') {
      const latestVersion = ProjectRepository.getLatestVersion(versions)
      setDisplayVersion(latestVersion)
    } else {
      const matchingVersion = versions.find((v) => v.name === version || v.tags.includes(version))
      if (matchingVersion) {
        setDisplayVersion(matchingVersion)
      } else {
        setNotFound(true)
        console.error(`Version '${version}' doesn't exist`)
      }
    }
  }, [versions, version])

  useEffect(() => {
    const latestVersion = ProjectRepository.getLatestVersion(versions)
    if (displayVersion === latestVersion) {
      clearMessages()
    } else {
      showMessage({
        content: 'You are viewing an outdated version of the documentation.',
        type: 'warning',
        showMs: null
      })
    }
  }, [displayVersion, versions, showMessage, clearMessages])

  const updateTitle = (newTitle: string): void => {
    document.title = newTitle
  }

  const iFramePageChanged = (urlPage: string, urlHash: string, title?: string): void => {
    if (title != null && title !== document.title) {
      updateTitle(title)
    }
    if (urlPage === pageRef.current) {
      return
    }
    pageRef.current = urlPage
    hashRef.current = urlHash
    updateUrl(project, version, hideUi)
  }

  const iFrameHashChanged = (newHash: string): void => {
    if (newHash === hashRef.current) {
      return
    }
    hashRef.current = newHash
    updateUrl(project, version, hideUi)
  }

  const iFrameNotFound = (): void => {
    setNotFound(true)
  }

  const iFrameFaviconChanged = (faviconUrl: string | null): void => {
    const favicon = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null
    if (favicon == null || faviconUrl == null) {
      return
    }
    favicon.href = faviconUrl
  }

  useEffect(() => {
    const urlProject = params.project ?? ''
    const urlVersion = params.version ?? 'latest'
    const urlPage = params['*'] ?? ''
    const urlHash = location.hash
    const urlHideUi = searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true'

    // update the state to the url params on first load
    setNotFound(false)
    setProject(urlProject)
    setVersion(urlVersion)
    setHideUi(urlHideUi)

    if (urlPage !== pageRef.current) {
      pageRef.current = urlPage
      setIframeUpdateTrigger((v) => v + 1)
    }
    if (urlHash !== hashRef.current) {
      hashRef.current = urlHash
      setIframeUpdateTrigger((v) => v + 1)
    }
  }, [location])

  if (projectLoading) {
    return <LoadingPage />
  }

  if (displayVersion == null || notFound) {
    return <NotFound />
  }

  return (
    <>
      <IFrame
        src={iFrameSrc}
        onPageChanged={iFramePageChanged}
        onHashChanged={iFrameHashChanged}
        onTitleChanged={updateTitle}
        onNotFound={iFrameNotFound}
        onFaviconChanged={iFrameFaviconChanged}
      />
      {!hideUi && (
        <DocumentControlButtons
          version={displayVersion.name}
          versions={versions}
          onVersionChange={(newVersion) => {
            updateUrl(project, newVersion, hideUi)
            setVersion(newVersion)
          }}
          getShareUrl={getShareUrl}
        />
      )}
    </>
  )
}


================================================
FILE: web/src/pages/Help.tsx
================================================
import { useEffect, useState, JSX } from 'react'
import ReactMarkdown from 'react-markdown'

// @ts-expect-error ts can't read symbols from a md file
import gettingStarted from './../assets/getting-started.md'

import Footer from '../components/Footer'
import Header from '../components/Header'
import LoadingPage from './LoadingPage'

import styles from './../style/pages/Help.module.css'

export default function Help(): JSX.Element {
  useEffect(() => {
     document.title = 'Help | docat';
  }, []);

  const [content, setContent] = useState<string>('')
  const [loading, setLoading] = useState<boolean>(true)

  /**
   * Replaces the links to "http://localhost:3000" with the current url of the page
   * @param text the contents of the markdown file
   * @returns the contents of the markdown file with the links replaced
   */
  const replaceLinks = (text: string): string => {
    const protocol = document.location.protocol
    const host = document.location.hostname
    const port =
      document.location.port !== '' ? `:${document.location.port}` : ''

    const currentUrl = `${protocol}//${host}${port}`

    return text.replaceAll('http://localhost:8000', currentUrl)
  }

  // Load the markdown file
  useEffect(() => {
    void (async (): Promise<void> => {
      try {
        // the import "gettingStarted" is just a path to the md file,
        // so we need to fetch the contents of the file manually

        const response = await fetch(gettingStarted as RequestInfo)
        const text = await response.text()
        const content = replaceLinks(text)
        setContent(content)
      } catch (e) {
        console.error(e)
      } finally {
        setLoading(false)
      }
    })()
  }, [])

  if (loading) {
    return <LoadingPage />
  }

  return (
    <>
      <Header />
      <div className={styles['markdown-container']}>
        <ReactMarkdown>
          {content}
        </ReactMarkdown>
      </div>
      <Footer />
    </>
  )
}


================================================
FILE: web/src/pages/Home.tsx
================================================
import { useEffect, useState, JSX } from 'react';
import { useNavigate } from 'react-router';

import { Delete, ErrorOutlined, FileUpload, KeyboardArrowDown, Lock } from '@mui/icons-material';
import { useProjects } from '../data-providers/ProjectDataProvider';
import { useSearch } from '../data-providers/SearchProvider';
import { type Project } from '../models/ProjectsResponse';

import Footer from '../components/Footer';
import Header from '../components/Header';
import ProjectList from '../components/ProjectList';
import ProjectRepository from '../repositories/ProjectRepository';
import LoadingPage from './LoadingPage';

import { Box, Button, IconButton, Tooltip, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import SearchBar from '../components/SearchBar';
import { useStats } from '../data-providers/StatsDataProvider';
import styles from './../style/pages/Home.module.css';


export default function Home(): JSX.Element {
  const navigate = useNavigate()
  const { loadingFailed } = useProjects()
  const { stats, loadingFailed: statsLoadingFailed } = useStats()
  const { filteredProjects: projects, query } = useSearch()
  const [showAll, setShowAll] = useState(false);
  const [favoriteProjects, setFavoriteProjects] = useState<Project[]>([])

  document.title = 'Home | docat'

  // Keep compatibility with hash-based URI
  if (location.hash.startsWith('#/')) {
    navigate(location.hash.replace('#', ''), { replace: true })
  }

  const updateFavorites = (): void => {
    if (projects == null) return

    setFavoriteProjects(
      projects.filter((project) => ProjectRepository.isFavorite(project.name))
    )
  }

  const onShowFavourites = (all: boolean): void => {
    setShowAll(all);
  }

  useEffect(() => {
    updateFavorites()
  }, [projects])

  if (loadingFailed || statsLoadingFailed) {
    return (
      <div className={styles.home}>
        <Header />
        <div className={styles['loading-error']}>
          <ErrorOutlined color="error" />
          <div>Failed to load projects</div>
        </div>
        <Footer />
      </div>
    )
  }

  if (projects == null || stats == null) {
    return <LoadingPage />
  }

  return (
    <div className={styles.home}>
      <Header />

      <div className={styles['project-overview']}>
        <Box sx={{ width: { sm: '80%' }, maxWidth: '800px'}}>


        <Box sx={{
          display: 'flex',
          marginTop: '24px',
          marginBottom: '32px',
          flexWrap: {
            sm: 'nowrap',
            xs: 'wrap'
          }
        }}>

          <Box sx={{
            width: {
              sm: '100%'
            },
            maxWidth: '600px',
            marginBottom: '8px'
          }}>
            <SearchBar showFavourites={!showAll} onShowFavourites={onShowFavourites} />
          </Box>

          <Box sx={{ display: 'flex' }}>
            <Tooltip title="Upload Documentation" placement="right" arrow>
              <IconButton
                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}
                href="/upload"
              >
                <FileUpload></FileUpload>
              </IconButton>
            </Tooltip>

            <Tooltip title="Claim a Project" placement="right" arrow>
              <IconButton
                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}
                href="/claim"
              >
                <Lock></Lock>
              </IconButton>
            </Tooltip>

            <Tooltip title="Delete a project version" placement="right" arrow>
              <IconButton
                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}
                href="/delete"
              >
                <Delete></Delete>
              </IconButton>
            </Tooltip>
          </Box>
        </Box>

        { projects.length === 0 ?
          <>{ query !== "" ?
            <Box sx={{marginLeft: '24px', color: '#6e6e6e'}}>
              Couldn&apos;t find any docs
            </Box> :
            <Box sx={{marginLeft: '24px'}}>
              Looks like you don&apos;t have any docs yet.
              <Button href="/help" onClick={() => onShowFavourites(true)}>
                Get started now!
              </Button>
            </Box>
          }</> :
          <>
          { (query || showAll) ?
            <ProjectList
              projects={projects}
              onFavoriteChanged={() => {
                updateFavorites()
              }}
            />
            :
            <>
              <Typography sx={{ marginLeft: '24px', marginBottom: 1.5, fontSize: 20, fontWeight: 300 }}>FAVOURITES</Typography>
              { (favoriteProjects.length === 0) ?
                <Box sx={{marginLeft: '24px'}}>
                  No docs favourited at the moment, search for docs or
                  <Button onClick={() => onShowFavourites(true)}>
                    Show all docs.
                  </Button>

                </Box> :
                <>
                  <ProjectList
                    projects={favoriteProjects}
                    onFavoriteChanged={() => {
                      updateFavorites()
                    }}
                  />

                  <Box sx={{ marginTop: 3, marginLeft: '24px', opacity: 0.6, '&:hover': {
                    opacity: 0.8,
                  }, }}>
                    <Link to={''} onClick={() => onShowFavourites(true)} >
                      <Typography component={'span'} sx={{ fontSize: '1.1em', fontWeight: 300 }}>SHOW ALL DOCS </Typography>
                      <KeyboardArrowDown sx={{ marginBottom: -0.6, marginLeft: 1 }} />
                    </Link>
                  </Box>
                </>
              }
            </>
          }
          </>
        }
        </Box>
        <Box sx={{
          display: {
            md: 'block',
            sm: 'none',
            xs: 'none'
          },
          borderLeft:
          '1px solid #efefef',
          paddingLeft: 3,
          marginTop: 15,
           width: '400px'
        }}>
          <Typography component={'span'} sx={{display: 'inline-block', fontSize: '1.1em', fontWeight: 300}}>INSTANCE STATS</Typography>
          <Box />

          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200}}># </Typography>
          <Typography component={'span'} sx={{width: 100, display: 'inline-block', marginTop: 1, fontSize: '1em', fontWeight: 300}}>DOCS </Typography>
          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200}} >{stats.n_projects}</Typography>

          <Box />
          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }}># </Typography>
          <Typography component={'span'} sx={{width: 100, display: 'inline-block',  marginTop: 0.4, fontSize: '1em', fontWeight: 300}}>VERSIONS </Typography>
          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }} >{stats.n_versions}</Typography>

          <Box />
          <Typography component={'span'} sx={{width: 115, display: 'inline-block',  marginTop: 0.4, fontSize: '1em', fontWeight: 300}}>STORAGE </Typography>
          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }} >{stats.storage}</Typography>
        </Box>
      </div>
      <Footer />
    </div>
  )
}


================================================
FILE: web/src/pages/LoadingPage.tsx
================================================
import React from 'react'
import Footer from '../components/Footer'
import Header from '../components/Header'

export default function LoadingPage(): React.JSX.Element {
  return (
    <>
      <Header />
      <div className="loading-spinner"></div>
      <Footer />
    </>
  )
}


================================================
FILE: web/src/pages/NotFound.tsx
================================================
import React from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
import Header from '../components/Header'
import styles from './../style/pages/NotFound.module.css'

export default function NotFound(): React.JSX.Element {
  return (
    <div className={styles['not-found']}>
      <Header />
      <div className={styles['not-found-container']}>
        <h1 className={styles['not-found-title']}>404 - Not Found</h1>
        <p className={styles['not-found-text']}>
          Sorry, the page you were looking for was not found.
        </p>
        <Link to="/" className={styles['not-found-link']}>
          Home
        </Link>
      </div>
      <Footer />
    </div>
  )
}


================================================
FILE: web/src/pages/Upload.tsx
================================================
import { TextField } from '@mui/material'
import React, { useState, useEffect, JSX } from 'react'

import FileInput from '../components/FileInput'
import PageLayout from '../components/PageLayout'
import StyledForm from '../components/StyledForm'
import { useMessageBanner } from '../data-providers/MessageBannerProvider'
import { useProjects } from '../data-providers/ProjectDataProvider'
import ProjectRepository from '../repositories/ProjectRepository'
import LoadingPage from './LoadingPage'

import styles from '../style/pages/Upload.module.css'

interface Validation {
  projectMsg?: string
  versionMsg?: string
  fileMsg?: string
}

const okFileTypes = [
  'application/zip',
  'zip',
  'application/octet-stream',
  'application/x-zip',
  'application/x-zip-compressed'
]

export default function Upload(): JSX.Element {
  useEffect(() => {
     document.title = 'Upload | docat';
  }, []);

  const { reload: reloadProjects } = useProjects()
  const { showMessage } = useMessageBanner()

  const [project, setProject] = useState<string>('')
  const [version, setVersion] = useState<string>('')
  const [file, setFile] = useState<File | undefined>(undefined)
  const [isUploading, setIsUploading] = useState<boolean>(false)
  const [validation, setValidation] = useState<Validation>({})

  const validateInput = (inputName: string, value: string): boolean => {
    const validationProp = `${inputName}Msg` as keyof typeof validation

    if (value.trim().length > 0) {
      setValidation({
        ...validation,
        [validationProp]: undefined
      })
      return true
    }

    const input = inputName.charAt(0).toUpperCase() + inputName.slice(1)
    const validationMsg = `${input} is required`

    setValidation({
      ...validation,
      [validationProp]: validationMsg
    })
    return false
  }

  const validateFile = (file: File | undefined): boolean => {
    if (file == null || file.name == null) {
      setValidation({
        ...validation,
        fileMsg: 'File is required'
      })
      return false
    }

    if (file.type == null) {
      setValidation({
        ...validation,
        fileMsg: 'Could not determine file type'
      })
      return false
    }

    if (okFileTypes.find((x) => x === file.type) === undefined) {
      setValidation({
        ...validation,
        fileMsg: 'This file type is not allowed'
      })
      return false
    }

    setValidation({
      ...validation,
      fileMsg: undefined
    })
    return true
  }

  const upload = (): void => {
    void (async () => {
      if (!validateInput('project', project)) return
      if (!validateInput('version', version)) return
      if (!validateFile(file) || file === undefined) return

      setIsUploading(true)
      const formData = new FormData()
      formData.append('file', file)

      const { success, message } = await ProjectRepository.upload(
        project,
        version,
        formData
      )

      if (!success) {
        console.error(message)
        showMessage({
          type: 'error',
          content: message,
          showMs: 6000
        })
        setIsUploading(false)
        return
      }

      // reset the form
      setProject('')
      setVersion('')
      setFile(undefined)
      setValidation({})

      showMessage({
        type: 'success',
        content: message,
        showMs: 6000
      })

      reloadProjects()
      setIsUploading(false)
    })()
  }

  if (isUploading) {
    return <LoadingPage />
  }

  const description = (
    <p>
      If you want to automate the upload of your documentation consider using{' '}
      <code>curl</code> to post it to the server. There are some examples in the{' '}
      <a
        href="https://github.com/docat-org/docat/"
        target="_blank"
        rel="noreferrer"
      >
        docat repository
      </a>
      .
    </p>
  )

  return (
    <PageLayout title="Upload Documentation" description={description}>
      <StyledForm>
        <TextField
          fullWidth
          autoComplete="off"
          label="Project"
          value={project}
          onChange={(e) => {
            const project = e.target.value
            setProject(project)
            validateInput('project', project)
          }}
          error={validation.projectMsg !== undefined}
          helperText={validation.projectMsg}
        >
          {project}
        </TextField>

        <TextField
          fullWidth
          autoComplete="off"
          label="Version"
          value={version}
          onChange={(e) => {
            const version = e.target.value
            setVersion(version)
            validateInput('version', version)
          }}
          error={validation.versionMsg !== undefined}
          helperText={validation.versionMsg}
        >
          {version}
        </TextField>

        <FileInput
          label="Zip File"
          file={file}
          onChange={(file) => {
            setFile(file)
          }}
          okTypes={okFileTypes}
          isValid={validateFile}
        ></FileInput>
        <p className={`${styles['validation-message']} ${styles.red}`}>
          {validation.fileMsg}
        </p>

        <button type="submit" onClick={upload} className={styles['upload-btn']}>
          Upload
        </button>
      </StyledForm>
    </PageLayout>
  )
}


================================================
FILE: web/src/react-app-env.d.ts
================================================
import 'react-scripts'


================================================
FILE: web/src/repositories/ProjectRepository.ts
================================================
import semver from 'semver'
import type ProjectDetails from '../models/ProjectDetails'
import { type Project } from '../models/ProjectsResponse'

const RESOURCE = 'doc'

function dateTimeReviver(key: string, value: unknown) {
  if (key === 'timestamp') {
    return new Date(value as string)
  }
  return value
}

function filterHiddenVersions(allProjects: Project[]): Project[] {
  // create deep-copy first
  const projects = JSON.parse(JSON.stringify(allProjects), dateTimeReviver) as Project[]

  projects.forEach((p) => {
    p.versions = p.versions.filter((v) => !v.hidden)
  })

  return projects.filter((p) => p.versions.length > 0)
}

/**
 * Returns a list of all versions of a project.
 * @param {string} projectName Name of the project
 */
async function getVersions(projectName: string): Promise<ProjectDetails[]> {
  const res = await fetch(`/api/projects/${projectName}?include_hidden=true`)

  if (!res.ok) {
    console.error(((await res.json()) as { message: string }).message)
    return []
  }

  const json = (await res.json()) as {
    versions: ProjectDetails[]
  }

  return json.versions
}

/**
 * Returns the latest version of a project.
 * Order of precedence: latest, latest tag, latest version
 * @param versions all versions of a project
 */
function getLatestVersion(versions: ProjectDetails[]): ProjectDetails {
  const latest = versions.find((v) => v.name.includes('latest'))
  if (latest != null) {
    return latest
  }

  const latestTag = versions.find((v) => v.tags.includes('latest'))
  if (latestTag != null) {
    return latestTag
  }

  const sortedVersions = versions.sort((a, b) => compareVersions(a, b))

  return sortedVersions[sortedVersions.length - 1]
}

/**
 * Returns the logo URL of a given project
 * @param {string} projectName Name of the project
 */
function getProjectLogoURL(projectName: string): string {
  return `/${RESOURCE}/${projectName}/logo`
}

/**
 * Returns the project documentation URL
 * @param {string} projectName Name of the project
 * @param {string} version Version name
 * @param {string?} docsPath Path to the documentation page
 * @param {string?} hash Hash part of the url (html id)
 */
function getProjectDocsURL(
  projectName: string,
  version: string,
  docsPath?: string,
  hash?: string
): string {
  return `/${RESOURCE}/${projectName}/${version}/${docsPath ?? ''}${hash ?? ''}`
}

/**
 * Uploads new project documentation
 * @param {string} projectName Name of the project
 * @param {string} version Name of the version
 * @param {FormData} body Data to upload
 * @returns {Promise<{ success: boolean, message: string }>} Success status and (error) message
 */
async function upload(
  projectName: string,
  version: string,
  body: FormData
): Promise<{ success: boolean; message: string }> {
  try {
    const resp = await fetch(`/api/${projectName}/${version}`, {
      method: 'POST',
      body
    })

    if (resp.ok) {
      const json = (await resp.json()) as { message: string }
      const msg = json.message
      return { success: true, message: msg }
    }

    switch (resp.status) {
      case 401:
        return {
          success: false,
          message: 'Failed to upload documentation: Version already exists'
        }
      case 504:
        return {
          success: false,
          message: 'Failed to upload documentation: Server unreachable'
        }
      default:
        return {
          success: false,
          message: `Failed to upload documentation: ${((await resp.json()) as { message: string }).message}`
        }
    }
  } catch (e) {
    return {
      success: false,
      message: `Failed to upload documentation: ${(e as { message: string }).message}`
    }
  }
}

/**
 * Claim the project token
 * @param {string} projectName Name of the project
 */
async function claim(projectName: string): Promise<{ token: string }> {
  const resp = await fetch(`/api/${projectName}/claim`)

  if (resp.ok) {
    const json = (await resp.json()) as { token: string }
    return json
  }

  switch (resp.status) {
    case 504:
      throw new Error('Failed to claim project: Server unreachable')
    default:
      throw new Error(
        `Failed to claim project: ${((await resp.json()) as { message: string }).message}`
      )
  }
}

/**
 * Deletes existing project documentation
 * @param {string} projectName Name of the project
 * @param {string} version Name of the version
 * @param {string} token Token to authenticate
 */
async function deleteDoc(
  projectName: string,
  version: string,
  token: string
): Promise<void> {
  const headers = { 'Docat-Api-Key': token }
  const resp = await fetch(`/api/${projectName}/${version}`, {
    method: 'DELETE',
    headers
  })

  if (resp.ok) return

  switch (resp.status) {
    case 401:
      throw new Error('Failed to delete documentation: Invalid token')
    case 504:
      throw new Error('Failed to delete documentation: Server unreachable')
    default:
      throw new Error(
        `Failed to delete documentation: ${((await resp.json()) as { message: string }).message}`
      )
  }
}

/**
 * Compare two versions according to semantic version (semver library)
 * Will always consider the version latest as higher version
 *
 * @param {Object} versionA first version to compare
 * @param {string} versionA.name version name
 * @param {string[] | undefined} versionA.tags optional tags for this vertion
 *
 * @param {Object} versionB second version to compare
 * @param {string} versionB.name version name
 * @param {string[] | undefined} versionB.tags optional tags for this vertion
 */
function compareVersions(
  versionA: { name: string; tags?: string[] },
  versionB: { name: string; tags?: string[] }
): number {
  if ((versionA.tags ?? []).includes('latest')) {
    return 1
  }

  if ((versionB.tags ?? []).includes('latest')) {
    return -1
  }

  const semverA = semver.coerce(versionA.name)
  const semverB = semver.coerce(versionB.name)

  if (semverA == null || semverB == null) {
    return versionA.name.localeCompare(versionB.name)
  }

  return semver.compare(semverA, semverB)
}

/**
 * Returns boolean indicating if the project name is part of the favorites.
 * @param {string} projectName name of the project
 * @returns {boolean} - true is project is favorite
 */
function isFavorite(projectName: string): boolean {
  return localStorage.getItem(projectName) === 'favorite'
}

/**
 * Sets favorite preference on project
 * @param {string} projectName
 * @param {boolean} shouldBeFavorite
 */
function setFavorite(projectName: string, shouldBeFavorite: boolean): void {
  if (shouldBeFavorite) {
    localStorage.setItem(projectName, 'favorite')
  } else {
    localStorage.removeItem(projectName)
  }
}

const exp = {
  getVersions,
  getLatestVersion,
  filterHiddenVersions,
  getProjectLogoURL,
  getProjectDocsURL,
  upload,
  claim,
  deleteDoc,
  compareVersions,
  isFavorite,
  setFavorite
}

export default exp


================================================
FILE: web/src/style/components/ControlButtons.module.css
================================================
.upload-button,
.claim-button,
.delete-button,
.single-control-button {
  width: 50px;
  height: 50px;
  border-radius: 50%;
  background-color: var(--primary-foreground);
  color: white;
  position: fixed;
  bottom: 20px;
  border: none;
  cursor: pointer;
  z-index: 1; /* Without this, the footer is on top of the controls */

  /* Center the icon */
  display: flex;
  justify-content: center;
  align-items: center;
}

.upload-button {
  right: 180px;
}

.claim-button {
  right: 100px;
}

.delete-button,
.single-control-button {
  right: 20px;
}


================================================
FILE: web/src/style/components/DocumentControlButtons.module.css
================================================

.controls {
  position: fixed;
  bottom: 32px;
  right: 32px;
  height: 50px;
  display: flex;
}

.home-button,
.share-button {
  height: 50px;
  width: 50px;
  color: rgba(0, 0, 0, 0.87);
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
}

.home-button:hover,
.share-button:hover {
  background-color: #dbdbdb;
}

.home-button {
  background-color: #efefef;
  border-top-left-radius: 0.5rem;
  border-bottom-left-radius: 0.5rem;
}

.share-button {
  color: rgba(0, 0, 0, 0.87);
  margin-left: 8px;
  border-radius: 0.5rem;
  cursor: pointer;
}

.version-select {
  background: white;
  overflow: hidden;

  padding: 9px;
  width: 200px;

  font-size: 1.05em;

  border-left-color: #efefef !important;
  border-top-left-radius: 0 !important;
  border-bottom-left-radius: 0 !important;

}


.version-select:focus-visible {
  outline: none;
}


.share-modal {
  display:flex;
  flex-direction: column;

  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);

  width: 400px;
  max-width: 80vw;
  height: 200px;
  overflow: hidden;

  background-color: #fff;
  border: none;
  border-radius: 0.5rem;
  padding: 1rem;
  outline: 0;
}

.share-modal > * {
  margin-bottom: 1rem;
}

.share-modal-link-container {
  display: flex;
}

.share-modal-link {
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
  border: 1px solid rgba(0, 0, 0, 0.42);
  word-break: break-all;
  user-select: all;
  -moz-user-select: all;
  -webkit-user-select: all;
  font-size: small;
  width: 100%;
}

.share-modal-copy-container {
  display: flex;
  align-items: center;
  margin-left: 1rem;
}

.share-modal-copy {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 0.5rem;
  background-color: var(--primary-foreground);
  color: #fff;
  cursor: pointer;
}

.share-modal-close {
  position: absolute;
  bottom: 0.5rem;
  right: 1rem;
  border: none;
  background-color: transparent;
  cursor: pointer;
  font-weight: bold;
}

.share-modal-label span {
  font-size: small !important;
}

@media only screen and (max-width: 380px) {
  .controls {
    left: 32px;
  }

  .version-select {
    width: calc(100vw - 100px - 64px)
  }

  .share-modal-link-container {
    flex-direction: column;
    align-items: center;
  }

  .share-modal-link {
    width: auto;
  }

  .share-modal-copy-container {
    margin-left: 0;
    margin-top: 1rem;
    width: 100%;
    justify-content: center;
  }

  .share-modal-copy {
    width: 80%;
  }
}


================================================
FILE: web/src/style/components/FileInput.module.css
================================================
.file-upload-container {
  display: flex;
  flex-direction: column;
  max-height: 250px;
}

.file-drop-zone {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: max(150px, 25vh);
  border-radius: 5px;
  border: 1px solid rgba(0, 0, 0, 0.23);
  cursor: pointer;
}

.file-drop-zone > * {
  color: grey;
}

.drag-active {
  background-color: var(--secondary-foreground);
}

.file-input {
  display: none;
}

.file-upload-button {
  cursor: pointer;
  padding: 0.25rem;
  border: none;
  font-size: 1em;
  background-color: transparent;
}

.drag-file-element {
  position: absolute;
  width: 100%;
  height: 100%;
  border-radius: 5px;
  top: 0px;
  right: 0px;
  bottom: 0px;
  left: 0px;
}

.file-upload-button:hover {
  text-decoration-line: underline;
}

.file-upload-label {
  background-color: white;
  width: fit-content;
  padding: 0 0.45em !important;
  font-size: 0.8em !important;
  z-index: 1;
  top: 10px;
  left: 0.75em;
}


================================================
FILE: web/src/style/components/Footer.module.css
================================================
.footer {
    margin-top: 32px;
    padding: 15px 0 15px 0;
}

.help-link {
    font-weight: 200;
    text-decoration: none;
    font-size: 20px;
    padding-left: 8px;
}

.version-info {
    font-weight: 200;
    font-size: 14px;
    margin-top: 0.8em;
    color: var(--primary-foreground);
    padding-left: 8px;
}

@media only screen and (min-width: 1000px) {
    .footer {
        padding-left: calc(15% + 16px);
    }
}

@media only screen and (max-width: 1000px) {
    .footer {
        padding-left: 30px;
    }
}

@media only screen and (max-width: 300px) {
    .footer {
        padding-left: 10px;
    }
}


================================================
FILE: web/src/style/components/Header.module.css
================================================
.header {
  --header-height: 74px;

  min-width: 230px;
  height: var(--header-height);
  border-bottom: 1px solid var(--secondary-foreground);
}

@media only screen and (min-width: 1000px) {
  .header {
    padding-left: calc(15% + 16px);
  }
}

@media only screen and (max-width: 1000px) {
  .header {
    padding-left: 30px;
  }
}

@media only screen and (max-width: 300px) {
  .header {
    padding-left: 10px;
  }
}

img {
  height: var(--header-height);
  float: left;
}

h1 {
  float: left;
  margin-top: calc(var(--header-height) / 2 - 15px);
  margin-left: 10px;
  font-size: 30px;
  font-weight: 00;
}


================================================
FILE: web/src/style/components/IFrame.module.css
================================================
html {
  overflow: auto;
}

html,
body,
iframe {
  margin: 0px;
  padding: 0px;
  height: 100%;
  border: none;
}

iframe {
  height: 100vh;
}

iframe {
  position: relative;
  display: block;
  width: 100%;
  border: none;
  overflow-y: auto;
  overflow-x: hidden;
}


================================================
FILE: web/src/style/components/NavigationTitle.module.css
================================================
.nav-title {
  display: flex;
  flex-direction: column;
  width: 100%;
}

@media only screen and (min-width: 1400px) {
  .nav-title {
    width: 50%;
  }
}

.page-header {
  display: flex;
  flex-direction: row;
  align-items: center;
}

.back-link {
  height: 24px;
  width: 24px;
}

.page-title {
  padding-left: 15px;
  overflow-x: hidden;
  text-overflow: ellipsis;
}

.page-description {
  padding: 16px 0 16px 0;
  font-size: 0.9em;
}

.page-description a {
  text-decoration: underline;
}


================================================
FILE: web/src/style/components/PageLayout.module.css
================================================
.main {
  display: flex;
  flex-direction: column;
}

@media only screen and (min-width: 650px) {
  .main {
    padding: 1% 0;
    margin: 3% 16% 1% 16%;
  }
}

@media only screen and (max-width: 650px) {
  .main {
    padding: 1% 0;
    margin: 3%;
  }
}


================================================
FILE: web/src/style/components/Project.module.css
================================================
.project-card {
  max-width: 800px;
  margin-left: 24px;
  margin-bottom: 8px;
  margin-top: 8px;
}

.secondary-typography {
  color: var(--primary-foreground);
  opacity: 0.6;
}

.project-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 6px;
}

.project-footer {
  display: flex;
  justify-content: space-between;
  margin-bottom: 6px;
}

.subhead {
  color: var(--primary-foreground);
  opacity: 0.54;
  font-size: 16px;
}

.project-card-title {
  font-weight: 400;
  font-size: 1.1em;
}

.project-logo {
  float: left;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  margin-right: 16px;
}


================================================
FILE: web/src/style/components/ProjectList.module.css
================================================
.project-list {
  display: grid;
}

.project-list {
  min-width: 250px;
  margin-right: 32px;
}


================================================
FILE: web/src/style/components/SearchBar.module.css
================================================
.search-bar {
  border: 1px solid #e8e8e8;
  float: right;
  margin: 8px 16px 0 0;
  border: none;
}

@media only screen and (max-width: 500px) {
  .search-bar {
    display: none;
  }
}


================================================
FILE: web/src/style/components/StyledForm.module.css
================================================
.form {
  display: flex;
  flex-direction: column;
}

.form > div {
  margin: 16px 0 0 0;
  width: 100%;
  min-height: 5em;
}

@media only screen and (min-width: 1400px) {
  .form {
    width: 50%;
  }
}

@media only screen and (max-width: 1400px) {
  .form {
    width: 100%;
  }
}

button[type='submit'] {
  margin-top: 16px;
  padding: 8px;
  width: 100%;
  max-width: 175px;
  font-size: 1.05em;
  border-radius: 8px;
  border: none;
  background-color: var(--button-primary);
  color: white;
  cursor: pointer;
}

button:disabled {
  background-color: gray;
  cursor: not-allowed;
}


================================================
FILE: web/src/style/pages/Help.module.css
================================================
/* from here: https://github.com/sindresorhus/github-markdown-css */

@media (prefers-color-scheme: light) {
  .markdown-container {
    color-scheme: light;
    --color-prettylights-syntax-comment: #6e7781;
    --
Download .txt
gitextract_7bq8lh13/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── docat.yml
├── .prettierrc
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── doc/
│   └── getting-started.md
├── docat/
│   ├── .gitignore
│   ├── Makefile
│   ├── README.md
│   ├── docat/
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   ├── app.py
│   │   ├── models.py
│   │   ├── nginx/
│   │   │   └── default
│   │   └── utils.py
│   ├── pyproject.toml
│   └── tests/
│       ├── __init__.py
│       ├── conftest.py
│       ├── test_claim.py
│       ├── test_delete.py
│       ├── test_hide_show.py
│       ├── test_project.py
│       ├── test_rename.py
│       ├── test_stats.py
│       ├── test_upload.py
│       ├── test_upload_icon.py
│       └── test_utils.py
└── web/
    ├── .gitignore
    ├── .prettierignore
    ├── .prettierrc.json
    ├── README.md
    ├── eslint.config.js
    ├── index.html
    ├── package.json
    ├── src/
    │   ├── App.tsx
    │   ├── components/
    │   │   ├── DataSelect.tsx
    │   │   ├── DocumentControlButtons.tsx
    │   │   ├── FavoriteStar.tsx
    │   │   ├── FileInput.tsx
    │   │   ├── Footer.tsx
    │   │   ├── Header.tsx
    │   │   ├── IFrame.tsx
    │   │   ├── InfoBanner.tsx
    │   │   ├── NavigationTitle.tsx
    │   │   ├── PageLayout.tsx
    │   │   ├── Project.tsx
    │   │   ├── ProjectList.tsx
    │   │   ├── SearchBar.tsx
    │   │   └── StyledForm.tsx
    │   ├── data-providers/
    │   │   ├── ConfigDataProvider.tsx
    │   │   ├── MessageBannerProvider.tsx
    │   │   ├── ProjectDataProvider.tsx
    │   │   ├── RandomId.tsx
    │   │   ├── SearchProvider.tsx
    │   │   └── StatsDataProvider.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   ├── models/
    │   │   ├── ProjectDetails.ts
    │   │   └── ProjectsResponse.ts
    │   ├── pages/
    │   │   ├── Claim.tsx
    │   │   ├── Delete.tsx
    │   │   ├── Docs.tsx
    │   │   ├── Help.tsx
    │   │   ├── Home.tsx
    │   │   ├── LoadingPage.tsx
    │   │   ├── NotFound.tsx
    │   │   └── Upload.tsx
    │   ├── react-app-env.d.ts
    │   ├── repositories/
    │   │   └── ProjectRepository.ts
    │   ├── style/
    │   │   ├── components/
    │   │   │   ├── ControlButtons.module.css
    │   │   │   ├── DocumentControlButtons.module.css
    │   │   │   ├── FileInput.module.css
    │   │   │   ├── Footer.module.css
    │   │   │   ├── Header.module.css
    │   │   │   ├── IFrame.module.css
    │   │   │   ├── NavigationTitle.module.css
    │   │   │   ├── PageLayout.module.css
    │   │   │   ├── Project.module.css
    │   │   │   ├── ProjectList.module.css
    │   │   │   ├── SearchBar.module.css
    │   │   │   └── StyledForm.module.css
    │   │   └── pages/
    │   │       ├── Help.module.css
    │   │       ├── Home.module.css
    │   │       ├── NotFound.module.css
    │   │       └── Upload.module.css
    │   └── tests/
    │       └── repositories/
    │           └── ProjectRepository.test.ts
    ├── tsconfig.json
    ├── vite-env.d.ts
    └── vite.config.ts
Download .txt
SYMBOL INDEX (165 symbols across 45 files)

FILE: docat/docat/app.py
  function lifespan (line 47) | async def lifespan(_: FastAPI):
  function get_db (line 53) | def get_db() -> TinyDB:
  function get_stats (line 71) | def get_stats():
  function get_projects (line 78) | def get_projects(include_hidden: bool = False):
  function get_project (line 90) | def get_project(project, include_hidden: bool = False):
  function upload_icon (line 100) | def upload_icon(
  function hide_version (line 144) | def hide_version(
  function show_version (line 179) | def show_version(
  function upload (line 213) | def upload(
  function tag (line 272) | def tag(project: str, version: str, new_tag: str, response: Response):
  function claim (line 293) | def claim(project: str, db: TinyDB = Depends(get_db)):
  function rename (line 309) | def rename(
  function delete (line 348) | def delete(
  function check_token_for_project (line 372) | def check_token_for_project(db, token, project) -> TokenStatus:

FILE: docat/docat/models.py
  class TokenStatus (line 8) | class TokenStatus:
  class ApiResponse (line 13) | class ApiResponse(BaseModel):
  class ClaimResponse (line 17) | class ClaimResponse(ApiResponse):
  class ProjectVersion (line 21) | class ProjectVersion(BaseModel):
  class Project (line 28) | class Project(BaseModel):
  class Projects (line 35) | class Projects(BaseModel):
  class Stats (line 39) | class Stats(BaseModel):
  class ProjectDetail (line 45) | class ProjectDetail(BaseModel):

FILE: docat/docat/utils.py
  function is_dir (line 20) | def is_dir(self):
  function create_symlink (line 39) | def create_symlink(source, destination):
  function extract_archive (line 57) | def extract_archive(target_file, destination):
  function remove_docs (line 75) | def remove_docs(project: str, version: str, upload_folder_path: Path):
  function calculate_token (line 110) | def calculate_token(password, salt):
  function is_forbidden_project_name (line 122) | def is_forbidden_project_name(name: str) -> bool:
  function readable_size (line 142) | def readable_size(bytes: int) -> str:
  function get_dir_size (line 166) | def get_dir_size(path: Path) -> int:
  function get_system_stats (line 186) | def get_system_stats(upload_folder_path: Path) -> Stats:
  function get_all_projects (line 214) | def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> ...
  function get_version_timestamp (line 246) | def get_version_timestamp(version_folder: Path) -> datetime:
  function get_project_details (line 253) | def get_project_details(upload_folder_path: Path, project_name: str, inc...

FILE: docat/tests/conftest.py
  function setup_docat_paths (line 13) | def setup_docat_paths():
  function client (line 29) | def client():
  function client_with_claimed_project (line 38) | def client_with_claimed_project(client):
  function temp_project_version (line 46) | def temp_project_version():

FILE: docat/tests/test_claim.py
  function test_successfully_claim_token (line 1) | def test_successfully_claim_token(client):
  function test_already_claimed (line 9) | def test_already_claimed(client):

FILE: docat/tests/test_delete.py
  function test_successfully_delete (line 4) | def test_successfully_delete(client_with_claimed_project):
  function test_no_valid_token_delete (line 10) | def test_no_valid_token_delete(client_with_claimed_project):
  function test_no_token_delete (line 19) | def test_no_token_delete(client_with_claimed_project):

FILE: docat/tests/test_hide_show.py
  function test_hide (line 9) | def test_hide(_, client_with_claimed_project):
  function test_hide_only_version_not_listed_in_projects (line 44) | def test_hide_only_version_not_listed_in_projects(_, client_with_claimed...
  function test_hide_creates_hidden_file (line 86) | def test_hide_creates_hidden_file(client_with_claimed_project):
  function test_hide_fails_project_does_not_exist (line 108) | def test_hide_fails_project_does_not_exist(client_with_claimed_project):
  function test_hide_fails_version_does_not_exist (line 120) | def test_hide_fails_version_does_not_exist(client_with_claimed_project):
  function test_hide_fails_already_hidden (line 139) | def test_hide_fails_already_hidden(client_with_claimed_project):
  function test_hide_fails_no_token (line 163) | def test_hide_fails_no_token(client_with_claimed_project):
  function test_hide_fails_invalid_token (line 182) | def test_hide_fails_invalid_token(client_with_claimed_project):
  function test_show (line 202) | def test_show(_, client_with_claimed_project):
  function test_show_deletes_hidden_file (line 241) | def test_show_deletes_hidden_file(client_with_claimed_project):
  function test_show_fails_project_does_not_exist (line 268) | def test_show_fails_project_does_not_exist(client_with_claimed_project):
  function test_show_fails_version_does_not_exist (line 280) | def test_show_fails_version_does_not_exist(client_with_claimed_project):
  function test_show_fails_already_shown (line 304) | def test_show_fails_already_shown(client_with_claimed_project):
  function test_show_fails_no_token (line 323) | def test_show_fails_no_token(client_with_claimed_project):
  function test_show_fails_invalid_token (line 347) | def test_show_fails_invalid_token(client_with_claimed_project):
  function test_hide_and_show_with_tag (line 372) | def test_hide_and_show_with_tag(_, client_with_claimed_project):

FILE: docat/tests/test_project.py
  function test_project_api (line 16) | def test_project_api(_, temp_project_version):
  function test_project_api_without_any_projects (line 46) | def test_project_api_without_any_projects():
  function test_project_details_api (line 54) | def test_project_details_api(_, temp_project_version):
  function test_project_details_api_with_a_project_that_does_not_exist (line 71) | def test_project_details_api_with_a_project_that_does_not_exist():
  function test_get_project_details_with_hidden_versions (line 79) | def test_get_project_details_with_hidden_versions(_, client_with_claimed...
  function test_project_details_without_hidden_versions (line 112) | def test_project_details_without_hidden_versions(_, client_with_claimed_...
  function test_include_hidden_parameter_for_get_projects (line 141) | def test_include_hidden_parameter_for_get_projects(_, client_with_claime...
  function test_include_hidden_parameter_for_get_project_details (line 205) | def test_include_hidden_parameter_for_get_project_details(_, client_with...

FILE: docat/tests/test_rename.py
  function test_rename_fail_project_does_not_exist (line 10) | def test_rename_fail_project_does_not_exist(client_with_claimed_project):
  function test_rename_fail_new_project_name_already_used (line 19) | def test_rename_fail_new_project_name_already_used(client_with_claimed_p...
  function test_rename_not_authenticated (line 38) | def test_rename_not_authenticated(client_with_claimed_project):
  function test_rename_success (line 53) | def test_rename_success(client_with_claimed_project):
  function test_rename_rejects_forbidden_project_name (line 77) | def test_rename_rejects_forbidden_project_name(client_with_claimed_proje...

FILE: docat/tests/test_stats.py
  function test_get_stats (line 18) | def test_get_stats(_, project_config, n_projects, n_versions, storage, c...

FILE: docat/tests/test_upload.py
  function test_successfully_upload (line 8) | def test_successfully_upload(client):
  function test_successfully_override (line 18) | def test_successfully_override(client_with_claimed_project):
  function test_tags_are_not_overwritten_without_api_key (line 37) | def test_tags_are_not_overwritten_without_api_key(client_with_claimed_pr...
  function test_successful_tag_creation (line 57) | def test_successful_tag_creation(client_with_claimed_project):
  function test_create_tag_fails_when_version_does_not_exist (line 73) | def test_create_tag_fails_when_version_does_not_exist(client_with_claime...
  function test_create_tag_fails_on_overwrite_of_version (line 89) | def test_create_tag_fails_on_overwrite_of_version(client_with_claimed_pr...
  function test_create_fails_on_overwrite_of_tag (line 112) | def test_create_fails_on_overwrite_of_tag(client_with_claimed_project):
  function test_fails_with_invalid_token (line 136) | def test_fails_with_invalid_token(client_with_claimed_project):
  function test_upload_rejects_forbidden_project_name (line 156) | def test_upload_rejects_forbidden_project_name(client_with_claimed_proje...
  function test_upload_issues_warning_missing_index_file (line 173) | def test_upload_issues_warning_missing_index_file(client_with_claimed_pr...

FILE: docat/tests/test_upload_icon.py
  function test_successful_icon_upload (line 13) | def test_successful_icon_upload(client_with_claimed_project):
  function test_icon_upload_fails_with_no_project (line 31) | def test_icon_upload_fails_with_no_project(client_with_claimed_project):
  function test_icon_upload_fails_no_token_and_existing_icon (line 44) | def test_icon_upload_fails_no_token_and_existing_icon(client):
  function test_icon_upload_successful_replacement_with_token (line 76) | def test_icon_upload_successful_replacement_with_token(client_with_claim...
  function test_icon_upload_successful_no_token_no_existing_icon (line 111) | def test_icon_upload_successful_no_token_no_existing_icon(client):
  function test_icon_upload_fails_no_image (line 129) | def test_icon_upload_fails_no_image(client_with_claimed_project):
  function test_get_project_recongizes_icon (line 148) | def test_get_project_recongizes_icon(_, client_with_claimed_project):

FILE: docat/tests/test_utils.py
  function test_symlink_creation (line 8) | def test_symlink_creation():
  function test_symlink_creation_overwrite_destination (line 22) | def test_symlink_creation_overwrite_destination():
  function test_symlink_creation_do_not_overwrite_destination (line 40) | def test_symlink_creation_do_not_overwrite_destination():
  function test_archive_artifact (line 58) | def test_archive_artifact():
  function test_remove_version (line 72) | def test_remove_version(temp_project_version):
  function test_remove_symlink_version (line 80) | def test_remove_symlink_version(temp_project_version):
  function test_broken_symlinks_in_projects (line 91) | def test_broken_symlinks_in_projects(temp_project_version):

FILE: web/src/App.tsx
  function App (line 16) | function App(): React.JSX.Element {

FILE: web/src/components/DataSelect.tsx
  type Props (line 4) | interface Props {
  function DataSelect (line 13) | function DataSelect(props: Props): React.JSX.Element {

FILE: web/src/components/DocumentControlButtons.tsx
  type Props (line 18) | interface Props {
  function DocumentControlButtons (line 25) | function DocumentControlButtons(props: Props): React.JSX.Element {

FILE: web/src/components/FavoriteStar.tsx
  type Props (line 5) | interface Props {
  function FavoriteStar (line 10) | function FavoriteStar(props: Props): React.JSX.Element {

FILE: web/src/components/FileInput.tsx
  type Props (line 6) | interface Props {
  function FileInput (line 14) | function FileInput(props: Props): React.JSX.Element {

FILE: web/src/components/Footer.tsx
  function Footer (line 7) | function Footer(): JSX.Element {

FILE: web/src/components/Header.tsx
  function Header (line 10) | function Header(): JSX.Element {

FILE: web/src/components/IFrame.tsx
  type Props (line 5) | interface Props {
  function IFrame (line 14) | function IFrame(props: Props): React.JSX.Element {

FILE: web/src/components/InfoBanner.tsx
  type Props (line 6) | interface Props {
  function Banner (line 10) | function Banner(props: Props): React.JSX.Element {

FILE: web/src/components/NavigationTitle.tsx
  type Props (line 7) | interface Props {
  function NavigationTitle (line 13) | function NavigationTitle(props: Props): React.JSX.Element {

FILE: web/src/components/PageLayout.tsx
  type Props (line 7) | interface Props {
  function PageLayout (line 14) | function PageLayout(props: Props): JSX.Element {

FILE: web/src/components/Project.tsx
  type Props (line 10) | interface Props {
  function timeSince (line 15) | function timeSince(date: Date) {
  function Project (line 41) | function Project(props: Props): React.JSX.Element {

FILE: web/src/components/ProjectList.tsx
  type Props (line 7) | interface Props {
  function ProjectList (line 12) | function ProjectList(props: Props): React.JSX.Element {

FILE: web/src/components/SearchBar.tsx
  type Props (line 10) | interface Props {
  function SearchBar (line 15) | function SearchBar(props: Props): React.JSX.Element {

FILE: web/src/components/StyledForm.tsx
  type Props (line 4) | interface Props {
  function StyledForm (line 8) | function StyledForm(props: Props): React.JSX.Element {

FILE: web/src/data-providers/ConfigDataProvider.tsx
  type Config (line 8) | interface Config {

FILE: web/src/data-providers/MessageBannerProvider.tsx
  type Message (line 9) | interface Message {
  type MessageBannerState (line 15) | interface MessageBannerState {
  function MessageBannerProvider (line 29) | function MessageBannerProvider({ children }: any): JSX.Element {

FILE: web/src/data-providers/ProjectDataProvider.tsx
  type ProjectState (line 11) | interface ProjectState {
  function ProjectDataProvider (line 32) | function ProjectDataProvider({ children }: any): JSX.Element {

FILE: web/src/data-providers/SearchProvider.tsx
  type SearchState (line 11) | interface SearchState {
  function SearchProvider (line 25) | function SearchProvider({ children }: any): JSX.Element {

FILE: web/src/data-providers/StatsDataProvider.tsx
  type Stats (line 10) | type Stats = {
  type StatsState (line 16) | interface StatsState {
  function StatsDataProvider (line 34) | function StatsDataProvider({ children }: any): JSX.Element {

FILE: web/src/models/ProjectDetails.ts
  class ProjectDetails (line 1) | class ProjectDetails {
    method constructor (line 7) | constructor(name: string, tags: string[], hidden: boolean, timestamp: ...

FILE: web/src/models/ProjectsResponse.ts
  type Project (line 3) | interface Project {
  type ProjectsResponse (line 10) | interface ProjectsResponse {

FILE: web/src/pages/Claim.tsx
  function Claim (line 10) | function Claim(): JSX.Element {

FILE: web/src/pages/Delete.tsx
  type Validation (line 11) | interface Validation {
  function Delete (line 17) | function Delete(): JSX.Element {

FILE: web/src/pages/Docs.tsx
  function Docs (line 11) | function Docs(): JSX.Element {

FILE: web/src/pages/Help.tsx
  function Help (line 13) | function Help(): JSX.Element {

FILE: web/src/pages/Home.tsx
  function Home (line 22) | function Home(): JSX.Element {

FILE: web/src/pages/LoadingPage.tsx
  function LoadingPage (line 5) | function LoadingPage(): React.JSX.Element {

FILE: web/src/pages/NotFound.tsx
  function NotFound (line 7) | function NotFound(): React.JSX.Element {

FILE: web/src/pages/Upload.tsx
  type Validation (line 14) | interface Validation {
  function Upload (line 28) | function Upload(): JSX.Element {

FILE: web/src/repositories/ProjectRepository.ts
  constant RESOURCE (line 5) | const RESOURCE = 'doc'
  function dateTimeReviver (line 7) | function dateTimeReviver(key: string, value: unknown) {
  function filterHiddenVersions (line 14) | function filterHiddenVersions(allProjects: Project[]): Project[] {
  function getVersions (line 29) | async function getVersions(projectName: string): Promise<ProjectDetails[...
  function getLatestVersion (line 49) | function getLatestVersion(versions: ProjectDetails[]): ProjectDetails {
  function getProjectLogoURL (line 69) | function getProjectLogoURL(projectName: string): string {
  function getProjectDocsURL (line 80) | function getProjectDocsURL(
  function upload (line 96) | async function upload(
  function claim (line 142) | async function claim(projectName: string): Promise<{ token: string }> {
  function deleteDoc (line 166) | async function deleteDoc(
  function compareVersions (line 203) | function compareVersions(
  function isFavorite (line 230) | function isFavorite(projectName: string): boolean {
  function setFavorite (line 239) | function setFavorite(projectName: string, shouldBeFavorite: boolean): vo...

FILE: web/vite-env.d.ts
  type ImportMetaEnv (line 3) | interface ImportMetaEnv {
  type ImportMeta (line 7) | interface ImportMeta {
Condensed preview — 91 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (243K chars).
[
  {
    "path": ".dockerignore",
    "chars": 112,
    "preview": ".git\n*.pyc\ndocat/env\ndocat/__pycache__\ndocat/upload\ndocat/.tox\ndocat/tests\nweb/node_modules\nweb/build\nweb/.env*\n"
  },
  {
    "path": ".github/workflows/docat.yml",
    "chars": 3478,
    "preview": "name: docat ci\n\non: [push, pull_request]\n\njobs:\n  python-test:\n    runs-on: ubuntu-latest\n    strategy:\n      max-parall"
  },
  {
    "path": ".prettierrc",
    "chars": 75,
    "preview": "{\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"trailingComma\": \"none\"\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 913,
    "preview": "# Contributing to docat\n\nThanks for contributing to docat!\nIn order to keep the quality of the source-code high,\nplease "
  },
  {
    "path": "Dockerfile",
    "chars": 1337,
    "preview": "# building frontend\nFROM node:24-slim AS frontend\nWORKDIR /app/frontend\n\nCOPY web/package.json web/yarn.lock ./\nRUN yarn"
  },
  {
    "path": "LICENSE",
    "chars": 1091,
    "preview": "MIT License\n\nCopyright (c) 2019 https://github.com/docat-org/docat\n\nPermission is hereby granted, free of charge, to any"
  },
  {
    "path": "README.md",
    "chars": 5496,
    "preview": "![docat](doc/assets/docat-teaser.png)\n\n**Host your docs. Simple. Versioned. Fancy.**\n\n[![build](https://github.com/docat"
  },
  {
    "path": "doc/getting-started.md",
    "chars": 5032,
    "preview": "## Getting Started with DOCAT\n\n\n\n\n### Using `docatl`, the docat CLI 🙀\n\nThe most convenient way to interact with docat is"
  },
  {
    "path": "docat/.gitignore",
    "chars": 84,
    "preview": "*.pyc\nenv\n__pycache__\nupload\n.tox\n.coverage\ndb.json\n.python-version\ndocat.egg-info/\n"
  },
  {
    "path": "docat/Makefile",
    "chars": 168,
    "preview": ".PHONY: all\nall: format lint typing pytest\n\nformat:\n\tuv run ruff check --fix\n\tuv run ruff format\nlint:\n\tuv run ruff chec"
  },
  {
    "path": "docat/README.md",
    "chars": 825,
    "preview": "# docat backend\n\nThe backend hosts the documentation and an api to push documentation and\ntag versions of the documentat"
  },
  {
    "path": "docat/docat/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "docat/docat/__main__.py",
    "chars": 233,
    "preview": "import os\n\nimport uvicorn\n\nfrom docat.app import app\n\nif __name__ == \"__main__\":\n    try:\n        port = int(os.environ."
  },
  {
    "path": "docat/docat/app.py",
    "chars": 13641,
    "preview": "\"\"\"\ndocat\n~~~~~\n\nHost your docs. Simple. Versioned. Fancy.\n\n:copyright: (c) 2019 by docat, https://github.com/docat-org/"
  },
  {
    "path": "docat/docat/models.py",
    "chars": 732,
    "preview": "from dataclasses import dataclass\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\n@dataclass(frozen=True"
  },
  {
    "path": "docat/docat/nginx/default",
    "chars": 568,
    "preview": "upstream python_backend {\n    server 127.0.0.1:5000;\n}\n\nserver {\n    listen 80 default_server;\n    listen [::]:80 defaul"
  },
  {
    "path": "docat/docat/utils.py",
    "chars": 8380,
    "preview": "\"\"\"\ndocat utilities\n\"\"\"\n\nimport hashlib\nimport os\nimport shutil\nfrom datetime import datetime\nfrom functools import cach"
  },
  {
    "path": "docat/pyproject.toml",
    "chars": 1210,
    "preview": "[project]\nname = \"docat\"\nversion = \"0.0.0\"\ndescription = \"Host your docs. Simple. Versioned. Fancy.\"\nauthors = [\n    { n"
  },
  {
    "path": "docat/tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "docat/tests/conftest.py",
    "chars": 1403,
    "preview": "import tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom tinydb import Tin"
  },
  {
    "path": "docat/tests/test_claim.py",
    "chars": 578,
    "preview": "def test_successfully_claim_token(client):\n    response = client.get(\"/api/some-project/claim\")\n    response_data = resp"
  },
  {
    "path": "docat/tests/test_delete.py",
    "chars": 1165,
    "preview": "from unittest.mock import patch\n\n\ndef test_successfully_delete(client_with_claimed_project):\n    with patch(\"docat.app.r"
  },
  {
    "path": "docat/tests/test_hide_show.py",
    "chars": 17787,
    "preview": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport docat.app as docat\n\n\n@patch(\"docat.utils"
  },
  {
    "path": "docat/tests/test_project.py",
    "chars": 10330,
    "preview": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport httpx\nfrom fastapi.testclient import Tes"
  },
  {
    "path": "docat/tests/test_rename.py",
    "chars": 4385,
    "preview": "import io\nfrom pathlib import Path\nfrom unittest.mock import call, patch\n\nfrom tinydb import Query\n\nimport docat.app as "
  },
  {
    "path": "docat/tests/test_stats.py",
    "chars": 1337,
    "preview": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport pytest\n\n\n@patch(\"docat.utils.get_version"
  },
  {
    "path": "docat/tests/test_upload.py",
    "chars": 8474,
    "preview": "import io\nfrom pathlib import Path\nfrom unittest.mock import call, patch\n\nimport docat.app as docat\n\n\ndef test_successfu"
  },
  {
    "path": "docat/tests/test_upload_icon.py",
    "chars": 8093,
    "preview": "import base64\nimport io\nfrom datetime import datetime\nfrom unittest.mock import call, patch\n\nimport docat.app as docat\n\n"
  },
  {
    "path": "docat/tests/test_utils.py",
    "chars": 2937,
    "preview": "from pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport docat.app as docat\nfrom docat.utils import c"
  },
  {
    "path": "web/.gitignore",
    "chars": 376,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": "web/.prettierignore",
    "chars": 48,
    "preview": "node_modules\n# Ignore artifacts:\nbuild\ncoverage\n"
  },
  {
    "path": "web/.prettierrc.json",
    "chars": 87,
    "preview": "{\n  \"trailingComma\": \"none\",\n  \"tabWidth\": 2,\n  \"semi\": false,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "web/README.md",
    "chars": 1382,
    "preview": "# docat web\n\n## Project setup\n\n```sh\nyarn install [--pure-lockfile]\n```\n\n### Compiles and hot-reloads for development\n\nT"
  },
  {
    "path": "web/eslint.config.js",
    "chars": 1440,
    "preview": "import eslintReact from \"@eslint-react/eslint-plugin\";\nimport eslintJs from \"@eslint/js\";\nimport { defineConfig } from \""
  },
  {
    "path": "web/index.html",
    "chars": 518,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE="
  },
  {
    "path": "web/package.json",
    "chars": 1412,
    "preview": "{\n  \"name\": \"docat-web\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"@emotion/"
  },
  {
    "path": "web/src/App.tsx",
    "chars": 2154,
    "preview": "import React from 'react';\nimport { createBrowserRouter, RouterProvider } from 'react-router-dom'\nimport { ConfigDataPro"
  },
  {
    "path": "web/src/components/DataSelect.tsx",
    "chars": 1312,
    "preview": "import { FormGroup, MenuItem, TextField } from '@mui/material'\nimport React, { useState } from 'react'\n\ninterface Props "
  },
  {
    "path": "web/src/components/DocumentControlButtons.tsx",
    "chars": 4694,
    "preview": "import { Home, Share } from '@mui/icons-material'\nimport {\n  Checkbox,\n  FormControl,\n  FormControlLabel,\n  FormGroup,\n "
  },
  {
    "path": "web/src/components/FavoriteStar.tsx",
    "chars": 815,
    "preview": "import { Star, StarOutlined } from '@mui/icons-material'\nimport React, { useState } from 'react'\nimport ProjectRepositor"
  },
  {
    "path": "web/src/components/FileInput.tsx",
    "chars": 3628,
    "preview": "import { InputLabel } from '@mui/material'\nimport React, { useRef, useState } from 'react'\n\nimport styles from './../sty"
  },
  {
    "path": "web/src/components/Footer.tsx",
    "chars": 1131,
    "preview": "import { Box } from '@mui/material'\nimport { useState, JSX } from 'react'\nimport { Link } from 'react-router-dom'\nimport"
  },
  {
    "path": "web/src/components/Header.tsx",
    "chars": 798,
    "preview": "import { useState, JSX } from 'react'\nimport { Link } from 'react-router-dom'\n\nimport { useConfig } from '../data-provid"
  },
  {
    "path": "web/src/components/IFrame.tsx",
    "chars": 4459,
    "preview": "import React, { useRef } from 'react'\nimport { generateKey } from '../data-providers/RandomId'\n\nimport styles from '../s"
  },
  {
    "path": "web/src/components/InfoBanner.tsx",
    "chars": 967,
    "preview": "import { Alert, Snackbar } from '@mui/material'\nimport React, { useState } from 'react'\nimport { type Message } from '.."
  },
  {
    "path": "web/src/components/NavigationTitle.tsx",
    "chars": 805,
    "preview": "import React from 'react';\nimport { ArrowBackIos } from '@mui/icons-material'\nimport { Link } from 'react-router-dom'\n\ni"
  },
  {
    "path": "web/src/components/PageLayout.tsx",
    "chars": 626,
    "preview": "import { JSX } from 'react'\nimport styles from './../style/components/PageLayout.module.css'\nimport Footer from './Foote"
  },
  {
    "path": "web/src/components/Project.tsx",
    "chars": 3047,
    "preview": "import React from 'react';\nimport { Link } from 'react-router-dom'\nimport { type Project as ProjectType } from '../model"
  },
  {
    "path": "web/src/components/ProjectList.tsx",
    "chars": 706,
    "preview": "import React from 'react';\nimport Project from './Project'\n\nimport { type Project as ProjectType } from '../models/Proje"
  },
  {
    "path": "web/src/components/SearchBar.tsx",
    "chars": 2547,
    "preview": "import SearchIcon from '@mui/icons-material/Search';\nimport StarIcon from '@mui/icons-material/Star';\nimport StarBorderI"
  },
  {
    "path": "web/src/components/StyledForm.tsx",
    "chars": 338,
    "preview": "import React from 'react';\nimport styles from './../style/components/StyledForm.module.css'\n\ninterface Props {\n  childre"
  },
  {
    "path": "web/src/data-providers/ConfigDataProvider.tsx",
    "chars": 959,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children"
  },
  {
    "path": "web/src/data-providers/MessageBannerProvider.tsx",
    "chars": 2035,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children"
  },
  {
    "path": "web/src/data-providers/ProjectDataProvider.tsx",
    "chars": 2103,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children"
  },
  {
    "path": "web/src/data-providers/RandomId.tsx",
    "chars": 296,
    "preview": "// Generate a mostly random key\nexport const generateKey = () => {\n  // Use the native function not available in http mo"
  },
  {
    "path": "web/src/data-providers/SearchProvider.tsx",
    "chars": 1672,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children"
  },
  {
    "path": "web/src/data-providers/StatsDataProvider.tsx",
    "chars": 1838,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children"
  },
  {
    "path": "web/src/index.css",
    "chars": 1188,
    "preview": "* {\n  margin: 0;\n  padding: 0;\n  font-family: \"Roboto\", Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialia"
  },
  {
    "path": "web/src/index.tsx",
    "chars": 263,
    "preview": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport './index.css'\nimport App from './App'\n\nconst ro"
  },
  {
    "path": "web/src/models/ProjectDetails.ts",
    "chars": 291,
    "preview": "export default class ProjectDetails {\n  name: string\n  hidden: boolean\n  timestamp: Date\n  tags: string[]\n\n  constructor"
  },
  {
    "path": "web/src/models/ProjectsResponse.ts",
    "chars": 228,
    "preview": "import type ProjectDetails from './ProjectDetails'\n\nexport interface Project {\n  name: string\n  logo: boolean\n  storage:"
  },
  {
    "path": "web/src/pages/Claim.tsx",
    "chars": 3021,
    "preview": "import { TextField } from '@mui/material'\nimport React, { useEffect, useState, JSX } from 'react'\nimport DataSelect from"
  },
  {
    "path": "web/src/pages/Delete.tsx",
    "chars": 4417,
    "preview": "import { TextField } from '@mui/material'\nimport React, { useEffect, useState, JSX } from 'react'\nimport DataSelect from"
  },
  {
    "path": "web/src/pages/Docs.tsx",
    "chars": 6448,
    "preview": "import { useEffect, useMemo, useState, useRef, JSX } from 'react'\nimport ProjectRepository from '../repositories/Project"
  },
  {
    "path": "web/src/pages/Help.tsx",
    "chars": 1974,
    "preview": "import { useEffect, useState, JSX } from 'react'\nimport ReactMarkdown from 'react-markdown'\n\n// @ts-expect-error ts can'"
  },
  {
    "path": "web/src/pages/Home.tsx",
    "chars": 7443,
    "preview": "import { useEffect, useState, JSX } from 'react';\nimport { useNavigate } from 'react-router';\n\nimport { Delete, ErrorOut"
  },
  {
    "path": "web/src/pages/LoadingPage.tsx",
    "chars": 282,
    "preview": "import React from 'react'\nimport Footer from '../components/Footer'\nimport Header from '../components/Header'\n\nexport de"
  },
  {
    "path": "web/src/pages/NotFound.tsx",
    "chars": 721,
    "preview": "import React from 'react'\nimport { Link } from 'react-router-dom'\nimport Footer from '../components/Footer'\nimport Heade"
  },
  {
    "path": "web/src/pages/Upload.tsx",
    "chars": 5346,
    "preview": "import { TextField } from '@mui/material'\nimport React, { useState, useEffect, JSX } from 'react'\n\nimport FileInput from"
  },
  {
    "path": "web/src/react-app-env.d.ts",
    "chars": 23,
    "preview": "import 'react-scripts'\n"
  },
  {
    "path": "web/src/repositories/ProjectRepository.ts",
    "chars": 6942,
    "preview": "import semver from 'semver'\nimport type ProjectDetails from '../models/ProjectDetails'\nimport { type Project } from '../"
  },
  {
    "path": "web/src/style/components/ControlButtons.module.css",
    "chars": 553,
    "preview": ".upload-button,\n.claim-button,\n.delete-button,\n.single-control-button {\n  width: 50px;\n  height: 50px;\n  border-radius: "
  },
  {
    "path": "web/src/style/components/DocumentControlButtons.module.css",
    "chars": 2471,
    "preview": "\n.controls {\n  position: fixed;\n  bottom: 32px;\n  right: 32px;\n  height: 50px;\n  display: flex;\n}\n\n.home-button,\n.share-"
  },
  {
    "path": "web/src/style/components/FileInput.module.css",
    "chars": 985,
    "preview": ".file-upload-container {\n  display: flex;\n  flex-direction: column;\n  max-height: 250px;\n}\n\n.file-drop-zone {\n  display:"
  },
  {
    "path": "web/src/style/components/Footer.module.css",
    "chars": 616,
    "preview": ".footer {\n    margin-top: 32px;\n    padding: 15px 0 15px 0;\n}\n\n.help-link {\n    font-weight: 200;\n    text-decoration: n"
  },
  {
    "path": "web/src/style/components/Header.module.css",
    "chars": 612,
    "preview": ".header {\n  --header-height: 74px;\n\n  min-width: 230px;\n  height: var(--header-height);\n  border-bottom: 1px solid var(-"
  },
  {
    "path": "web/src/style/components/IFrame.module.css",
    "chars": 268,
    "preview": "html {\n  overflow: auto;\n}\n\nhtml,\nbody,\niframe {\n  margin: 0px;\n  padding: 0px;\n  height: 100%;\n  border: none;\n}\n\nifram"
  },
  {
    "path": "web/src/style/components/NavigationTitle.module.css",
    "chars": 496,
    "preview": ".nav-title {\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n}\n\n@media only screen and (min-width: 1400px) {\n "
  },
  {
    "path": "web/src/style/components/PageLayout.module.css",
    "chars": 256,
    "preview": ".main {\n  display: flex;\n  flex-direction: column;\n}\n\n@media only screen and (min-width: 650px) {\n  .main {\n    padding:"
  },
  {
    "path": "web/src/style/components/Project.module.css",
    "chars": 627,
    "preview": ".project-card {\n  max-width: 800px;\n  margin-left: 24px;\n  margin-bottom: 8px;\n  margin-top: 8px;\n}\n\n.secondary-typograp"
  },
  {
    "path": "web/src/style/components/ProjectList.module.css",
    "chars": 96,
    "preview": ".project-list {\n  display: grid;\n}\n\n.project-list {\n  min-width: 250px;\n  margin-right: 32px;\n}\n"
  },
  {
    "path": "web/src/style/components/SearchBar.module.css",
    "chars": 187,
    "preview": ".search-bar {\n  border: 1px solid #e8e8e8;\n  float: right;\n  margin: 8px 16px 0 0;\n  border: none;\n}\n\n@media only screen"
  },
  {
    "path": "web/src/style/components/StyledForm.module.css",
    "chars": 588,
    "preview": ".form {\n  display: flex;\n  flex-direction: column;\n}\n\n.form > div {\n  margin: 16px 0 0 0;\n  width: 100%;\n  min-height: 5"
  },
  {
    "path": "web/src/style/pages/Help.module.css",
    "chars": 22906,
    "preview": "/* from here: https://github.com/sindresorhus/github-markdown-css */\n\n@media (prefers-color-scheme: light) {\n  .markdown"
  },
  {
    "path": "web/src/style/pages/Home.module.css",
    "chars": 779,
    "preview": ".loading-error {\n  color: red;\n  text-align: center;\n  padding: 30px 0;\n  font-size: large;\n}\n\n.no-results {\n  text-alig"
  },
  {
    "path": "web/src/style/pages/NotFound.module.css",
    "chars": 539,
    "preview": ".not-found {\n  height: fit-content;\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n  justify-content: s"
  },
  {
    "path": "web/src/style/pages/Upload.module.css",
    "chars": 128,
    "preview": ".validation-message {\n  padding-top: 5px;\n  margin-left: 14px;\n  font-size: 0.8em;\n  min-height: 1em;\n}\n\n.red {\n  color:"
  },
  {
    "path": "web/src/tests/repositories/ProjectRepository.test.ts",
    "chars": 12749,
    "preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n// -> we need any for our mocks\n\nimport ProjectDetails from '../"
  },
  {
    "path": "web/tsconfig.json",
    "chars": 590,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n "
  },
  {
    "path": "web/vite-env.d.ts",
    "chars": 161,
    "preview": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_DOCAT_VERSION: string\n}\n\ninterface Impo"
  },
  {
    "path": "web/vite.config.ts",
    "chars": 907,
    "preview": "import { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// https://vitejs.dev/config/\nexp"
  }
]

About this extraction

This page contains the full source code of the randombenj/docat GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 91 files (221.8 KB), approximately 59.3k tokens, and a symbol index with 165 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!