Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a371646238 | ||
|
|
55c0054ad8 | ||
|
|
8e05c4eb53 | ||
|
|
1bcc2edeb3 | ||
|
|
ec987dfc82 | ||
|
|
dd8061d838 | ||
|
|
61e9f4de5a | ||
|
|
1aa9836a26 | ||
|
|
eea374d328 | ||
|
|
0798eb7438 | ||
|
|
4ff9f8b7a0 |
No files matched your search
@@ -5,3 +5,8 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
# Do NOT ignore migrations/ — they are read at runtime, on every boot.
|
# Do NOT ignore migrations/ — they are read at runtime, on every boot.
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
#
|
#
|
||||||
# Copy to .env and fill in. Never commit the real file.
|
# Copy to .env and fill in. Never commit the real file.
|
||||||
# Everything below the first block is optional: Klarbild starts, and the print
|
# Everything below the first block is optional: Klarbild starts, and the print
|
||||||
# studio works, with nothing but DATABASE_URL, SESSION_SECRET and ENCRYPTION_KEY.
|
# studio works, with nothing but a database, SESSION_SECRET and ENCRYPTION_KEY.
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 1. The minimum — this is all a print-only instance needs
|
# 1. The minimum — this is all a print-only instance needs
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# The compose files build DATABASE_URL from DB_PASSWORD, so with Docker you set
|
||||||
|
# only the password here. Running from source, set DATABASE_URL yourself instead.
|
||||||
|
DB_PASSWORD=CHANGEME
|
||||||
DATABASE_URL=postgres://klarbild:CHANGEME@db:5432/klarbild
|
DATABASE_URL=postgres://klarbild:CHANGEME@db:5432/klarbild
|
||||||
|
|
||||||
# Signs the session cookie. Any long random string.
|
# Signs the session cookie. Any long random string.
|
||||||
@@ -43,6 +46,12 @@ KLARBILD_TZ=Europe/Berlin
|
|||||||
|
|
||||||
PORT=4321
|
PORT=4321
|
||||||
|
|
||||||
|
# How many reverse proxies sit in front of this instance. The rate limiter reads
|
||||||
|
# the client address that many hops from the right of X-Forwarded-For, so a wrong
|
||||||
|
# number here lets a client either spoof its address or share someone else's
|
||||||
|
# limit. One proxy (Caddy, nginx, Traefik, Coolify) is the default.
|
||||||
|
TRUSTED_PROXIES=1
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 2. First start
|
# 2. First start
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -64,9 +73,20 @@ PUBLIC_BASE_URL=http://localhost:4321
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 4. Module: ai — image generation
|
# 4. Module: ai — image generation
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Klarbild calls image models through OpenRouter. This costs money per image.
|
# Klarbild calls image models over an OpenAI-shaped images endpoint. This costs
|
||||||
# The key can also be entered in the admin area; the database value wins.
|
# money per image, whoever you call. The key can also be entered in the admin
|
||||||
|
# area, and that value wins over anything set here.
|
||||||
|
|
||||||
|
IMAGE_API_KEY=
|
||||||
|
|
||||||
|
# Where those requests go. The default is OpenRouter, which is one key for many
|
||||||
|
# models. Point this at anything that answers POST {base}/images in the OpenAI
|
||||||
|
# image shape — a LiteLLM gateway, a company proxy, a server of your own — and
|
||||||
|
# Klarbild talks to that instead.
|
||||||
|
IMAGE_API_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
|
# The old name for IMAGE_API_KEY. Still read, so instances set up before the
|
||||||
|
# endpoint became configurable keep working.
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -111,9 +131,6 @@ S3_ACCESS_KEY=
|
|||||||
S3_SECRET_KEY=
|
S3_SECRET_KEY=
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# A webhook that fires when a job finishes — for n8n and the like — is set in the
|
||||||
# 9. Optional
|
# admin area rather than here, because it is a per-instance setting rather than a
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# secret.
|
||||||
# A webhook that gets a POST when a job finishes — for n8n and the like.
|
|
||||||
|
|
||||||
N8N_WEBHOOK_URL=
|
|
||||||
@@ -10,11 +10,11 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
# `npm ci` needs the lockfile to match package.json exactly, which is the
|
# `npm ci` needs the lockfile to match package.json exactly, which is the
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Container image
|
||||||
|
|
||||||
|
# Builds the image that docker-compose.yaml pulls, for amd64 and arm64
|
||||||
|
# (a Raspberry Pi 4/5 or an ARM VPS runs the same image).
|
||||||
|
#
|
||||||
|
# push to main -> ghcr.io/tillheidrich/klarbild:edge
|
||||||
|
# tag v1.2.3 -> :1.2.3, :1.2, :1 and :latest
|
||||||
|
#
|
||||||
|
# Nothing to configure: the workflow pushes with its own GITHUB_TOKEN.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*.*.*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: image-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository_owner }}/klarbild
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=main
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Klarbild
|
||||||
|
org.opencontainers.image.description=Self-hosted print studio: pictures on paper at exactly the size you asked for.
|
||||||
|
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -7,3 +7,6 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
uploads/
|
uploads/
|
||||||
data/
|
data/
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -1,5 +1,63 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.1.0 — 2026-10-10
|
||||||
|
|
||||||
|
**Install without cloning or building**
|
||||||
|
|
||||||
|
- **Prebuilt image** for linux/amd64 and linux/arm64 at
|
||||||
|
`ghcr.io/tillheidrich/klarbild`. The compose files pull `:1`, which follows every
|
||||||
|
1.x release; an update is `docker compose pull && docker compose up -d`. The quick
|
||||||
|
start needs two downloaded files instead of a clone and a build.
|
||||||
|
- The image is two-stage: no compiler, no dev dependencies, no source tree at runtime.
|
||||||
|
It **runs as uid 1000**, not root.
|
||||||
|
|
||||||
|
**First visit**
|
||||||
|
|
||||||
|
- On a print-only instance the print studio no longer offers "Pick from the library" —
|
||||||
|
the library belongs to the AI module, and on an instance without it the button
|
||||||
|
opened an empty picker. In its place: **Try it with a sample photo**, an original
|
||||||
|
illustration that goes through the normal upload.
|
||||||
|
- A favicon and an iPhone home-screen icon.
|
||||||
|
|
||||||
|
**Trash (AI module)**
|
||||||
|
|
||||||
|
- Deleting moves an image into the **trash** instead of removing it on the spot. It is
|
||||||
|
hidden from the library, share links, ZIPs, deliveries, the mirror, print sources and
|
||||||
|
the print history, and comes back unchanged on restore. "Undo" in the toast, a trash
|
||||||
|
view with days left, restore, delete for good, empty.
|
||||||
|
- Maintenance purges after `trash_days` (default 30, Admin → Storage & cleanup). 0
|
||||||
|
switches the trash off.
|
||||||
|
- An image trashed while queued is skipped instead of being generated and billed.
|
||||||
|
- Purging removes only files no other image still uses: alternatives share their source
|
||||||
|
with the original.
|
||||||
|
- API: `DELETE /api/items/:id` now trashes (`?purge=1` for good), `GET|POST|DELETE
|
||||||
|
/api/trash`. See `/llms.txt`.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- Only the file route checked who may see an image. Rename, delete, retry, alternative,
|
||||||
|
reuse and the selection ZIP accepted any id from any signed-in account. One rule now
|
||||||
|
(`src/lib/itemaccess.ts`): read your own, or a shared library but never a private
|
||||||
|
session; change only your own; admins everything. A refused id answers 404.
|
||||||
|
- README said Astro 5; it is Astro 7.
|
||||||
|
|
||||||
|
**Dependencies**
|
||||||
|
|
||||||
|
- nodemailer 10, basic-ftp 6, argon2 0.45, archiver 8, ssh2-sftp-client 12,
|
||||||
|
@astrojs/react 7, Astro 7.3.8, sharp 0.35.5 — `npm audit` 0 (was 7, six high).
|
||||||
|
- Node ≥ 22.12.
|
||||||
|
- Not taken yet: pg-boss 12 (it cannot migrate the v10 queue schema in place) and
|
||||||
|
TypeScript 7.
|
||||||
|
|
||||||
|
**Upgrading from 1.0**
|
||||||
|
|
||||||
|
- If you built 1.0 yourself with the old compose file, your files volume belongs to
|
||||||
|
root. The new image runs as uid 1000, so hand the volume over once:
|
||||||
|
`docker compose run --rm --user root app chown -R 1000:1000 /data`
|
||||||
|
- basic-ftp 6 no longer silently redirects the data connection when an FTP server
|
||||||
|
behind NAT announces its private address. If a delivery target that worked fails
|
||||||
|
now, "Test connection" names the cause.
|
||||||
|
|
||||||
## 1.0.0 — first public release
|
## 1.0.0 — first public release
|
||||||
|
|
||||||
The first open-source release of Klarbild. Everything before this lived in a
|
The first open-source release of Klarbild. Everything before this lived in a
|
||||||
@@ -11,7 +69,8 @@ private repository as a personal tool.
|
|||||||
default is the print studio alone — no image model, no object store, no bot
|
default is the print studio alone — no image model, no object store, no bot
|
||||||
token, no account anywhere. See `src/lib/modules.ts`.
|
token, no account anywhere. See `src/lib/modules.ts`.
|
||||||
- **English interface**, with German as a full second language. The English
|
- **English interface**, with German as a full second language. The English
|
||||||
sentence is the translation key; 1,038 strings.
|
sentence is the translation key; 1,067 entries, covering every translatable
|
||||||
|
string in the source.
|
||||||
- **First-run setup.** No built-in accounts. The first boot creates one
|
- **First-run setup.** No built-in accounts. The first boot creates one
|
||||||
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
|
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
|
||||||
and prints it once to the log.
|
and prints it once to the log.
|
||||||
@@ -23,6 +82,7 @@ private repository as a personal tool.
|
|||||||
- English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`,
|
- English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`,
|
||||||
`/tour`, `/password`.
|
`/tour`, `/password`.
|
||||||
- AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`.
|
- AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`.
|
||||||
|
- Astro 7 and current `sharp`: `npm audit` reports no known vulnerability.
|
||||||
|
|
||||||
**Carried over from the private tool**
|
**Carried over from the private tool**
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ You need a PostgreSQL to point `DATABASE_URL` at; `docker compose up db` gives y
|
|||||||
one. Migrations run themselves on the first request.
|
one. Migrations run themselves on the first request.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # 124 tests, no database needed
|
npm test # 130 tests, no database needed
|
||||||
npx tsc --noEmit # must be clean
|
npx tsc --noEmit # must be clean
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
FROM node:20-bookworm-slim
|
# Klarbild — two stages. The first builds, the second only runs: no compiler,
|
||||||
|
# no dev dependencies, no source tree in the image that ends up on your server.
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0
|
# The lockfile comes first and `npm ci` honours it, so the image is built from
|
||||||
|
# the exact versions CI tested — not from whatever `^` resolved to today.
|
||||||
# Build tools for the native modules (argon2). sharp ships a prebuilt libvips.
|
# Native modules (argon2, sharp) ship prebuilt binaries for amd64 and arm64,
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# so no compiler is needed in either stage.
|
||||||
python3 make g++ ca-certificates \
|
COPY package.json package-lock.json ./
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install --no-audit --no-fund
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build && npm prune --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0 STORAGE_DIR=/data
|
||||||
|
|
||||||
|
COPY --from=build --chown=node:node /app/package.json ./
|
||||||
|
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /app/dist ./dist
|
||||||
|
# Read at runtime, on every boot.
|
||||||
|
COPY --from=build --chown=node:node /app/migrations ./migrations
|
||||||
|
|
||||||
|
# Not root. A fresh named volume mounted at /data takes over this ownership;
|
||||||
|
# for a bind mount, give the directory to uid 1000 (`chown 1000:1000 ./data`).
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
USER node
|
||||||
|
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4321)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4321)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ page runs this repository's own layout engine client-side and hands you the PDF.
|
|||||||
nothing uploaded.
|
nothing uploaded.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/tillheidrich/klarbild.git && cd klarbild
|
mkdir klarbild && cd klarbild
|
||||||
cp .env.example .env # fill in three secrets, the file says which
|
curl -fsSLO https://raw.githubusercontent.com/tillheidrich/klarbild/main/docker-compose.yaml
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/tillheidrich/klarbild/main/.env.example -o .env
|
||||||
|
nano .env # four values at the top; the file says which
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose logs app # your admin password is printed here, once
|
docker compose logs app # your admin password is printed here, once
|
||||||
```
|
```
|
||||||
|
|
||||||
|
No clone, no build: the compose file pulls a prebuilt image for amd64 and arm64
|
||||||
|
(a Raspberry Pi 4 or 5 runs it). Open `http://localhost:4321`, sign in, and press
|
||||||
|
**Try it with a sample photo** if you have no picture at hand.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,7 +48,7 @@ output is a PDF laid out in millimetres, not a scaled bitmap, so 30 × 40 cm is
|
|||||||
**Sheet layout.** Put several pictures on one sheet at 100 % with crop marks:
|
**Sheet layout.** Put several pictures on one sheet at 100 % with crop marks:
|
||||||
corner marks (0.25 pt, outside the trim) or continuous lines. Equal sizes snap to
|
corner marks (0.25 pt, outside the trim) or continuous lines. Equal sizes snap to
|
||||||
an exact grid; mixed sizes go through a MaxRects packer. Bleed 0–10 mm. 19 paper
|
an exact grid; mixed sizes go through a MaxRects packer. Bleed 0–10 mm. 19 paper
|
||||||
formats from A6 to A2 — including A3+ and photo paper 9 × 13 to 20 × 30, Letter
|
formats from A6 to A1 — including A3+ and photo paper 9 × 13 to 20 × 30, Letter
|
||||||
and Legal — or a custom sheet.
|
and Legal — or a custom sheet.
|
||||||
|
|
||||||
**Pictures bigger than the sheet, handled three ways.** A 20 × 30 cm print on A4
|
**Pictures bigger than the sheet, handled three ways.** A 20 × 30 cm print on A4
|
||||||
@@ -75,7 +81,7 @@ of the PDF above measures 210.00 × 297.00 mm, because that is what A4 is.
|
|||||||
| Module | What it adds | What it needs |
|
| Module | What it adds | What it needs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `print` | everything above | nothing — always on |
|
| `print` | everything above | nothing — always on |
|
||||||
| `ai` | clean up screenshots, convert, combine, generate | an OpenRouter key (paid per image) |
|
| `ai` | clean up screenshots, convert, combine, generate — plus the library, with folders, flags and a 30-day trash | a key for an images API (paid per image) |
|
||||||
| `delivery` | push finished files to an SFTP/FTPS server you already have | that server |
|
| `delivery` | push finished files to an SFTP/FTPS server you already have | that server |
|
||||||
| `mirror` | a second copy of every result, sorted by month | a second target |
|
| `mirror` | a second copy of every result, sorted by month | a second target |
|
||||||
| `share` | public links: expiring, revocable, optional passphrase, ZIP download | a public URL |
|
| `share` | public links: expiring, revocable, optional passphrase, ZIP download | a public URL |
|
||||||
@@ -92,6 +98,17 @@ A module that is off is off: no navigation entry, no settings card, and its API
|
|||||||
routes answer 404 rather than 403 — a disabled instance does not advertise what it
|
routes answer 404 rather than 403 — a disabled instance does not advertise what it
|
||||||
could have done.
|
could have done.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Which image model
|
||||||
|
|
||||||
|
The `ai` module posts to `{IMAGE_API_BASE_URL}/images` in the OpenAI image shape.
|
||||||
|
The default base is OpenRouter — one key, many models, and the model list in the
|
||||||
|
admin area fills itself in. Point `IMAGE_API_BASE_URL` at a LiteLLM gateway, a
|
||||||
|
company proxy or a server of your own and Klarbild talks to that instead; you then
|
||||||
|
type the model id rather than picking it from a list. Either way the pictures go to
|
||||||
|
exactly one endpoint and you chose which.
|
||||||
|
|
||||||
## Why self-hosted matters here
|
## Why self-hosted matters here
|
||||||
|
|
||||||
The pictures are family photos, passport photos, ID pictures and client work.
|
The pictures are family photos, passport photos, ID pictures and client work.
|
||||||
@@ -108,14 +125,15 @@ visitor per hour, deleted after 90 days.
|
|||||||
|
|
||||||
## Stack and shape
|
## Stack and shape
|
||||||
|
|
||||||
Astro 5 (SSR, Node standalone) with React islands · PostgreSQL · `sharp` ·
|
Astro 7 (SSR, Node standalone) with React islands · PostgreSQL · `sharp` ·
|
||||||
`pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by
|
`pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by
|
||||||
default; S3/MinIO is available but not required.
|
default; S3/MinIO is available but not required.
|
||||||
|
|
||||||
About 13,600 lines across 116 files, 124 tests, 16 forward-only idempotent
|
About 13,800 lines across 115 files, 130 tests, 17 forward-only idempotent
|
||||||
migrations that run themselves on boot. English and German interface, 1,038
|
migrations that run themselves on boot. English and German interface — 1,095
|
||||||
translated strings; the English sentence is the translation key, so there is no key
|
entries, covering every translatable string in the source. The English sentence is
|
||||||
file to keep in sync.
|
the translation key, so there is no key file to keep in sync and a missing entry
|
||||||
|
degrades to readable English rather than to `print.sheet.title`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -124,6 +142,17 @@ and Postgres, files on a volume. `docker-compose.full.yaml` adds MinIO and turns
|
|||||||
every module on. Both read their secrets from `.env` and refuse to start rather than
|
every module on. Both read their secrets from `.env` and refuse to start rather than
|
||||||
boot with a default password.
|
boot with a default password.
|
||||||
|
|
||||||
|
Both pull `ghcr.io/tillheidrich/klarbild:1`, which follows every 1.x release.
|
||||||
|
Updating is
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
and the migrations run themselves on the next boot. The container runs as uid
|
||||||
|
1000, not root. Prefer to build the image yourself? Swap the `image:` line in the
|
||||||
|
compose file for `build: .` and run it from a clone.
|
||||||
|
|
||||||
**From source.**
|
**From source.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -149,7 +178,7 @@ the two tools worth knowing.
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
[klarbild-demo.heidrich-digital.de](https://klarbild-demo.heidrich-digital.de) is the product
|
[klarbild-demo.heidrich-digital.de](https://klarbild-demo.heidrich-digital.de) is the product
|
||||||
page; its source is [klarbild-demo](https://git.heidrich-digital.de/till/klarbild-demo).
|
page.
|
||||||
|
|
||||||
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||||
how the code is laid out and what the house style is. Security reports go to the
|
how the code is laid out and what the house style is. Security reports go to the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
Please do **not** open a public issue. Mail **security@heidrich-digital.de** with
|
Please do **not** open a public issue. Mail **mail@tillheidrich.de** with
|
||||||
what you found and how to reproduce it. You will get an acknowledgement within a
|
what you found and how to reproduce it. You will get an acknowledgement within a
|
||||||
few days. There is no bounty programme — this is a one-person project — but
|
few days. There is no bounty programme — this is a one-person project — but
|
||||||
credit in the release notes is yours if you want it.
|
credit in the release notes is yours if you want it.
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ export default defineConfig({
|
|||||||
// CSRF: handled by our own SameSite=Lax session cookie. Astro's checkOrigin
|
// CSRF: handled by our own SameSite=Lax session cookie. Astro's checkOrigin
|
||||||
// breaks multipart uploads behind a reverse proxy, because the proxy speaks
|
// breaks multipart uploads behind a reverse proxy, because the proxy speaks
|
||||||
// http to the app while the browser spoke https and the origins no longer match.
|
// http to the app while the browser spoke https and the origins no longer match.
|
||||||
security: { checkOrigin: false },
|
//
|
||||||
// Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes
|
// Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes
|
||||||
// the hash of every inline script and style it emits, at build time. The remaining
|
// the hash of every inline script and style it emits, at build time. The remaining
|
||||||
// directives are set by the middleware (src/middleware.ts).
|
// directives are set by the middleware (src/middleware.ts).
|
||||||
experimental: {
|
// Astro 7 graduated this out of `experimental` and it lives under `security` now.
|
||||||
|
// Careful: a top-level `csp` key is swallowed without a warning and the site then
|
||||||
|
// runs with no policy at all.
|
||||||
|
security: {
|
||||||
|
checkOrigin: false,
|
||||||
csp: {
|
csp: {
|
||||||
directives: [
|
directives: [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
# The prebuilt image (amd64 and arm64). `:1` follows every 1.x release, so
|
||||||
context: .
|
# `docker compose pull && docker compose up -d` is the whole update.
|
||||||
dockerfile: Dockerfile
|
image: ghcr.io/tillheidrich/klarbild:${KLARBILD_VERSION:-1}
|
||||||
|
# Rather build it yourself from this checkout? Swap the line above for:
|
||||||
|
# build: .
|
||||||
environment:
|
environment:
|
||||||
PORT: "4321"
|
PORT: "4321"
|
||||||
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
||||||
@@ -35,10 +37,11 @@ services:
|
|||||||
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
ADMIN_USERNAME: "${ADMIN_USERNAME:-admin}"
|
||||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
|
||||||
|
|
||||||
|
IMAGE_API_KEY: "${IMAGE_API_KEY:-}"
|
||||||
|
IMAGE_API_BASE_URL: "${IMAGE_API_BASE_URL:-https://openrouter.ai/api/v1}"
|
||||||
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
|
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
|
||||||
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
|
||||||
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
|
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
|
||||||
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-}"
|
|
||||||
|
|
||||||
# The database wins over these; they only apply until something is entered
|
# The database wins over these; they only apply until something is entered
|
||||||
# under Admin → Email, so a fresh install can send mail straight away.
|
# under Admin → Email, so a fresh install can send mail straight away.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Klarbild — the default stack: the print studio and a database. That is all.
|
# Klarbild — the default stack: the print studio and a database. That is all.
|
||||||
#
|
#
|
||||||
# cp .env.example .env # fill in the three secrets at the top
|
# cp .env.example .env # fill in the values at the top; the file says which
|
||||||
# docker compose up -d
|
# docker compose up -d
|
||||||
# docker compose logs app # the first-start password is printed once, here
|
# docker compose logs app # the first-start password is printed once, here
|
||||||
#
|
#
|
||||||
@@ -9,9 +9,11 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
# The prebuilt image (amd64 and arm64). `:1` follows every 1.x release, so
|
||||||
context: .
|
# `docker compose pull && docker compose up -d` is the whole update.
|
||||||
dockerfile: Dockerfile
|
image: ghcr.io/tillheidrich/klarbild:${KLARBILD_VERSION:-1}
|
||||||
|
# Rather build it yourself from this checkout? Swap the line above for:
|
||||||
|
# build: .
|
||||||
environment:
|
environment:
|
||||||
PORT: "4321"
|
PORT: "4321"
|
||||||
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
DATABASE_URL: "postgres://klarbild:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/klarbild"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -32,7 +32,7 @@ own tools) and then calls the Klarbild tools.
|
|||||||
- **process_images** — upload and process images (local paths or `data:` URLs).
|
- **process_images** — upload and process images (local paths or `data:` URLs).
|
||||||
Give either `recipeId` (a preset) or `mode` (`each`/`compose`/`generate`) + options:
|
Give either `recipeId` (a preset) or `mode` (`each`/`compose`/`generate`) + options:
|
||||||
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
|
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
|
||||||
(fixed, like `30x40`/`theframe`, **or custom**, `25x35` / `sticker5`), `output_ext`
|
(fixed, like `30x40`/`tv169`, **or custom**, `25x35` / `sticker5`), `output_ext`
|
||||||
(`png`/`jpg`, omit = global default), `orientation`, `crop_mode`, `contour_mm`,
|
(`png`/`jpg`, omit = global default), `orientation`, `crop_mode`, `contour_mm`,
|
||||||
`delivery_folder`, `delivery`. Returns the job ID.
|
`delivery_folder`, `delivery`. Returns the job ID.
|
||||||
- **job_status** — query the status of a job (including finish time and error messages).
|
- **job_status** — query the status of a job (including finish time and error messages).
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const TOOLS = [
|
|||||||
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
|
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
|
||||||
description: 'Only for mode=each. Default: ["format"] if output_format is set.' },
|
description: 'Only for mode=each. Default: ["format"] if output_format is set.' },
|
||||||
prompt_text: { type: 'string', description: 'Description for compose/generate' },
|
prompt_text: { type: 'string', description: 'Description for compose/generate' },
|
||||||
output_format: { type: 'string', description: 'Fixed: 30x40, A4, theframe, portrait916, keep … OR custom: "25x35" (=25×35 cm) or "sticker5" (=5×5 cm).' },
|
output_format: { type: 'string', description: 'Fixed: 30x40, A4, tv169, portrait916, keep … OR custom: "25x35" (=25×35 cm) or "sticker5" (=5×5 cm).' },
|
||||||
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'File format. Omit = global default setting. JPG only without transparency.' },
|
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'File format. Omit = global default setting. JPG only without transparency.' },
|
||||||
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
|
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
|
||||||
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
|
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Trash instead of deleting for good ----------------------------------------
|
||||||
|
--
|
||||||
|
-- Deleting an image used to remove the row and the result file on the spot. One
|
||||||
|
-- slip in the library and the result was gone — including what it had cost to
|
||||||
|
-- generate. Now a delete only sets `deleted_at`. The image disappears from every
|
||||||
|
-- view, every share link and every delivery, and comes back unchanged when it is
|
||||||
|
-- restored. Maintenance removes it for good after `settings.trash_days`.
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
CREATE INDEX IF NOT EXISTS items_deleted_at_idx ON items (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- 0 = no trash: a delete is final, as before.
|
||||||
|
ALTER TABLE settings ADD COLUMN IF NOT EXISTS trash_days int NOT NULL DEFAULT 30;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "klarbild",
|
"name": "klarbild",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Self-hosted print studio: put pictures on paper at exactly the size you asked for. Sheet layout with crop marks, tiling, 1:1 PDF. Optional AI, delivery, sharing and Telegram modules.",
|
"description": "Self-hosted print studio: put pictures on paper at exactly the size you asked for. Sheet layout with crop marks, tiling, 1:1 PDF. Optional AI, delivery, sharing and Telegram modules.",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": "Till Heidrich",
|
"author": "Till Heidrich",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=22.12"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
@@ -38,33 +38,32 @@
|
|||||||
"scan": "node scripts/secret-scan.mjs"
|
"scan": "node scripts/secret-scan.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/node": "^11.1.7",
|
||||||
"@astrojs/react": "^4.2.1",
|
"@astrojs/react": "^7.0.1",
|
||||||
"@aws-sdk/client-s3": "^3.700.0",
|
"@aws-sdk/client-s3": "^3.1149.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
||||||
"@fontsource/space-grotesk": "^5.1.0",
|
"@fontsource/space-grotesk": "^5.1.0",
|
||||||
"@fontsource/space-mono": "^5.1.0",
|
"@fontsource/space-mono": "^5.1.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^8.0.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.45.1",
|
||||||
"astro": "^5.5.0",
|
"astro": "^7.3.8",
|
||||||
"basic-ftp": "^5.0.5",
|
"basic-ftp": "^6.2.3",
|
||||||
"grammy": "^1.30.0",
|
"grammy": "^1.46.0",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"nodemailer": "^9.1.0",
|
"nodemailer": "^10.0.16",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.23.1",
|
||||||
"pg-boss": "^10.1.5",
|
"pg-boss": "^10.1.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.3.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.35.5",
|
||||||
"ssh2-sftp-client": "^11.0.0"
|
"ssh2-sftp-client": "^12.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.5.1",
|
"@types/archiver": "^8.0.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/node": "^26.6.5",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/ssh2-sftp-client": "^9.0.6",
|
"@types/ssh2-sftp-client": "^9.0.6",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#F6F6F1"/><circle cx="16" cy="16" r="8.5" fill="none" stroke="#1B3BE0" stroke-width="2.2"/><path d="M16 3.5v25M3.5 16h25" stroke="#1B3BE0" stroke-width="2.2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1,9 @@
|
|||||||
|
# Sample image
|
||||||
|
|
||||||
|
`lake-at-dusk.jpg` is an original illustration drawn for Klarbild as vector
|
||||||
|
shapes and rendered to JPEG. It contains no third-party material and is
|
||||||
|
covered by the repository's licence like everything else here.
|
||||||
|
|
||||||
|
It exists so that the first visit to a fresh instance can end in a printed
|
||||||
|
sheet: the "Try it with a sample photo" button in the print studio uploads
|
||||||
|
it through the normal upload path.
|
||||||
|
After Width: | Height: | Size: 43 KiB |
@@ -38,9 +38,11 @@ const RULES = [
|
|||||||
|
|
||||||
// Lines that are allowed to match: documentation of the shape of a secret,
|
// Lines that are allowed to match: documentation of the shape of a secret,
|
||||||
// and the example file, which is meant to show the names without the values.
|
// and the example file, which is meant to show the names without the values.
|
||||||
|
// Lines that are allowed to match: documentation of the *shape* of a secret.
|
||||||
|
// Being a comment is deliberately not a free pass — a pasted `.env` fragment is
|
||||||
|
// usually commented out, and that is exactly the mistake worth catching.
|
||||||
const ALLOW = [
|
const ALLOW = [
|
||||||
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand/i,
|
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand|\.\.\.|xxxx/i,
|
||||||
/^\s*(?:\/\/|#|--|\*)/,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let findings = 0;
|
let findings = 0;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
output_ext: s.output_ext || 'png',
|
output_ext: s.output_ext || 'png',
|
||||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||||
|
trash_days: s.trash_days ?? 30,
|
||||||
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar,
|
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar,
|
||||||
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||||
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||||
@@ -274,7 +275,6 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
<div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
|
<div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
|
||||||
<div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
|
<div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>{t('Cricut sheet size (cm)')}</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
|
|
||||||
<div className="field"><label>{t('Default file format')}</label>
|
<div className="field"><label>{t('Default file format')}</label>
|
||||||
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
|
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
|
||||||
<option value="png">{t('PNG (lossless, large)')}</option>
|
<option value="png">{t('PNG (lossless, large)')}</option>
|
||||||
@@ -338,6 +338,9 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
<div className="field"><label>{t('Retention (days)')}</label>
|
<div className="field"><label>{t('Retention (days)')}</label>
|
||||||
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||||
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
||||||
|
<div className="field"><label>{t('Trash (days)')}</label>
|
||||||
|
<input className="input" type="number" min={0} max={3650} value={s.trash_days ?? 30} onChange={(e) => field('trash_days', e.target.value)} />
|
||||||
|
<div className="hint">{t('Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.')}</div></div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
||||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
||||||
@@ -489,7 +492,7 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
{targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>}
|
{targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="two">
|
<div className="two">
|
||||||
<div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. The Frame FTP')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
<div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. Studio archive')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
||||||
<div className="field"><label>{t('Protocol')}</label>
|
<div className="field"><label>{t('Protocol')}</label>
|
||||||
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
||||||
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
||||||
|
|||||||
@@ -30,8 +30,16 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||||
const [share, setShare] = useState<ShareTarget | null>(null);
|
const [share, setShare] = useState<ShareTarget | null>(null);
|
||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<{ msg: string; undo?: () => void } | null>(null);
|
||||||
const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
|
const toastTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// With an undo button the toast stays longer — long enough to notice the slip.
|
||||||
|
const notify = (msg: string, undo?: () => void) => {
|
||||||
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
|
setToast({ msg, undo });
|
||||||
|
toastTimer.current = setTimeout(() => setToast(null), undo ? 7000 : 2400);
|
||||||
|
};
|
||||||
|
const [trashView, setTrashView] = useState(false);
|
||||||
|
const [trash, setTrash] = useState<{ days: number; items: (Item & { deleted_at: string; days_left: number })[] }>({ days: 30, items: [] });
|
||||||
|
|
||||||
// Model name: the admin label, otherwise the readable tail of the model ID.
|
// Model name: the admin label, otherwise the readable tail of the model ID.
|
||||||
const modelName = (id: string | null) => {
|
const modelName = (id: string | null) => {
|
||||||
@@ -42,12 +50,14 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
};
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [i, f, m] = await Promise.all([
|
const [i, f, m, tr] = await Promise.all([
|
||||||
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||||
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
||||||
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
||||||
|
fetch('/api/trash').then((r) => r.json()).catch(() => ({ days: 30, items: [] })),
|
||||||
]);
|
]);
|
||||||
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
||||||
|
setTrash({ days: tr.days ?? 30, items: tr.items || [] });
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@@ -62,8 +72,30 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
const trashAction = (action: 'trash' | 'restore' | 'purge', ids: string[]) =>
|
||||||
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify(t('Deleted.')); };
|
fetch('/api/trash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ids }) })
|
||||||
|
.then((r) => r.json()).catch(() => ({ ids: [] }));
|
||||||
|
// Deleting moves into the trash; the toast offers to take it back right there.
|
||||||
|
const del = async (ids: string[]) => {
|
||||||
|
if (!ids.length) return;
|
||||||
|
setItems((x) => x.filter((y) => !ids.includes(y.id)));
|
||||||
|
const r = await trashAction('trash', ids);
|
||||||
|
setPick(new Set()); load();
|
||||||
|
const n = (r.ids || []).length;
|
||||||
|
if (trash.days === 0) { notify(n === 1 ? t('Deleted.') : t('{n} deleted.', { n })); return; }
|
||||||
|
notify(n === 1 ? t('Moved to the trash.') : t('{n} moved to the trash.', { n }),
|
||||||
|
async () => { await trashAction('restore', r.ids || []); setToast(null); load(); });
|
||||||
|
};
|
||||||
|
const delSelected = () => del([...pick]);
|
||||||
|
const restore = async (ids: string[]) => { await trashAction('restore', ids); load(); notify(t('Restored.')); };
|
||||||
|
const purge = async (ids: string[]) => {
|
||||||
|
if (!confirm(ids.length === 1 ? t('Delete this image for good? This cannot be undone.') : t('Delete {n} images for good? This cannot be undone.', { n: ids.length }))) return;
|
||||||
|
await trashAction('purge', ids); load(); notify(t('Deleted for good.'));
|
||||||
|
};
|
||||||
|
const emptyTrash = async () => {
|
||||||
|
if (!confirm(t('Empty the trash? {n} images will be deleted for good.', { n: trash.items.length }))) return;
|
||||||
|
await fetch('/api/trash', { method: 'DELETE' }); load(); notify(t('Trash emptied.'));
|
||||||
|
};
|
||||||
const deliverSelected = async () => {
|
const deliverSelected = async () => {
|
||||||
notify(t('Sending to the delivery target …'));
|
notify(t('Sending to the delivery target …'));
|
||||||
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
||||||
@@ -131,9 +163,10 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="head-row">
|
<div className="head-row">
|
||||||
<span className="mono-label">{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
<span className="mono-label">{trashView ? t('Trash · {n}', { n: trash.items.length })
|
||||||
|
: <>{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</>}</span>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{pick.size > 0 && <>
|
{pick.size > 0 && !trashView && <>
|
||||||
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
||||||
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
||||||
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
||||||
@@ -145,10 +178,43 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</select>
|
</select>
|
||||||
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
||||||
</>}
|
</>}
|
||||||
<button className="mini" onClick={newFolder}>{t('New folder')}</button>
|
{!trashView && <button className="mini" onClick={newFolder}>{t('New folder')}</button>}
|
||||||
|
{(trash.items.length > 0 || trashView) && (
|
||||||
|
<button className={`mini ${trashView ? 'strong' : ''}`} onClick={() => setTrashView(!trashView)}>
|
||||||
|
{trashView ? t('Back to the library') : t('Trash · {n}', { n: trash.items.length })}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{trashView ? (
|
||||||
|
<div className="trash">
|
||||||
|
<div className="hint notice">{trash.days > 0
|
||||||
|
? t('Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.', { days: trash.days })
|
||||||
|
: t('The trash is switched off — deleting is final.')}</div>
|
||||||
|
{trash.items.length === 0 ? (
|
||||||
|
<div className="empty"><span className="regmark" /><h3>{t('The trash is empty')}</h3></div>
|
||||||
|
) : (<>
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<button className="mini" onClick={() => restore(trash.items.map((x) => x.id))}>{t('Restore all')}</button>
|
||||||
|
<button className="mini danger" onClick={emptyTrash}>{t('Empty the trash')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="gallery">
|
||||||
|
{trash.items.map((v) => (
|
||||||
|
<article key={v.id} className="tile trashed">
|
||||||
|
<div className="tile-image"><img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" /></div>
|
||||||
|
<div className="name static">{v.filename || '—'}</div>
|
||||||
|
<div className="meta">{v.days_left === 0 ? t('Removed at the next cleanup') : v.days_left === 1 ? t('1 day left') : t('{n} days left', { n: v.days_left })}</div>
|
||||||
|
<div className="row tight">
|
||||||
|
<button className="mini strong2" onClick={() => restore([v.id])}>{t('Restore')}</button>
|
||||||
|
<button className="mini" onClick={() => purge([v.id])}>{t('Delete for good')}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
|
||||||
{/* Folder filter */}
|
{/* Folder filter */}
|
||||||
<div className="folderbar">
|
<div className="folderbar">
|
||||||
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
||||||
@@ -238,7 +304,7 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
<option value="">{t('No folder')}</option>
|
<option value="">{t('No folder')}</option>
|
||||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
<button className="mini" onClick={() => del([v.id])} title={t('Move to the trash')}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -246,6 +312,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
{share && (
|
{share && (
|
||||||
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +354,8 @@ export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: D
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast" role="status">{toast.msg}
|
||||||
|
{toast.undo && <button className="toast-undo" onClick={toast.undo}>{t('Undo')}</button>}</div>}
|
||||||
<LibStyles />
|
<LibStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -359,6 +428,10 @@ function LibStyles() {
|
|||||||
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||||
.pb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
.pb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||||
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;display:flex;align-items:center;gap:14px;}
|
||||||
|
.toast-undo{background:none;border:1px solid rgba(251,251,247,.5);color:#FBFBF7;border-radius:3px;padding:4px 10px;font-family:inherit;font-size:12.5px;cursor:pointer;}
|
||||||
|
.mini.danger{border-color:var(--err);color:var(--err);}
|
||||||
|
.tile.trashed .tile-image img{opacity:.6;filter:grayscale(.6);}
|
||||||
|
.name.static{font-family:var(--font-mono);font-size:12px;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
`}</style>;
|
`}</style>;
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,8 @@ function shrink(dataUrl: string, maxEdge = 700): Promise<{ url: string; w: numbe
|
|||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
|
|
||||||
export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
export default function PrintApp({ locale, dict, canDeliver = false, hasLibrary = true }:
|
||||||
|
{ locale?: Locale; dict?: Dict | null; canDeliver?: boolean; hasLibrary?: boolean }) {
|
||||||
const t = useT(dict);
|
const t = useT(dict);
|
||||||
const [cells, setCells] = useState<Cell[]>([]);
|
const [cells, setCells] = useState<Cell[]>([]);
|
||||||
const [paperId, setPaperId] = useState('A4');
|
const [paperId, setPaperId] = useState('A4');
|
||||||
@@ -298,8 +299,13 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
// Only ask when the delivery module is on. Asking anyway would work — the
|
||||||
|
// route answers 404 by design — but it puts a red line in the console of
|
||||||
|
// every print-only instance, which reads like a fault and is not one.
|
||||||
|
if (canDeliver) {
|
||||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||||
}, []);
|
}
|
||||||
|
}, [canDeliver]);
|
||||||
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
|
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
|
||||||
.then((j) => setPresets(j.presets || [])).catch(() => {});
|
.then((j) => setPresets(j.presets || [])).catch(() => {});
|
||||||
|
|
||||||
@@ -329,6 +335,17 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
} catch { notify(t('These settings could not be applied.'), true); }
|
} catch { notify(t('These settings could not be applied.'), true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A photo to try it with, for the first visit — before anyone has picked a
|
||||||
|
// picture of their own. It goes through the normal upload, so what follows is
|
||||||
|
// exactly what happens with a real image. (An original illustration made for
|
||||||
|
// Klarbild; public/samples/README.md says so.)
|
||||||
|
const addSample = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await fetch('/samples/lake-at-dusk.jpg').then((r) => { if (!r.ok) throw new Error(); return r.blob(); });
|
||||||
|
await addFiles([new File([blob], 'lake-at-dusk.jpg', { type: 'image/jpeg' })]);
|
||||||
|
} catch { notify(t('The sample photo could not be loaded.')); }
|
||||||
|
};
|
||||||
|
|
||||||
const addFromLibrary = (it: any) => {
|
const addFromLibrary = (it: any) => {
|
||||||
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
|
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
|
||||||
const id = uid();
|
const id = uid();
|
||||||
@@ -413,7 +430,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
|
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
|
||||||
});
|
});
|
||||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || t('Failed.'), true); return; }
|
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || t('Failed.'), true); return; }
|
||||||
download(await res.blob(), filenameFromHeader(res.headers.get('Content-Disposition'), `bild.${ext}`));
|
download(await res.blob(), filenameFromHeader(res.headers.get('Content-Disposition'), `image.${ext}`));
|
||||||
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
||||||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||||||
? t('Downloaded — careful: the source only carries about {dpi} dpi.', { dpi: String(real) })
|
? t('Downloaded — careful: the source only carries about {dpi} dpi.', { dpi: String(real) })
|
||||||
@@ -494,7 +511,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
<div className="col work-left">
|
<div className="col work-left">
|
||||||
<Card nr="1" title={t('Images')} actions={
|
<Card nr="1" title={t('Images')} actions={
|
||||||
<>
|
<>
|
||||||
<button className="link" onClick={() => setPicker(true)}>{t('From the library')}</button>
|
{hasLibrary && <button className="link" onClick={() => setPicker(true)}>{t('From the library')}</button>}
|
||||||
{cells.length > 0 && (
|
{cells.length > 0 && (
|
||||||
<button className="link danger"
|
<button className="link danger"
|
||||||
onClick={() => { if (confirm(t('Remove {images}?',
|
onClick={() => { if (confirm(t('Remove {images}?',
|
||||||
@@ -522,10 +539,20 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
already in Klarbild and need not be exported and uploaded
|
already in Klarbild and need not be exported and uploaded
|
||||||
again first. */}
|
again first. */}
|
||||||
<div className="or"><span>{t('or')}</span></div>
|
<div className="or"><span>{t('or')}</span></div>
|
||||||
|
{hasLibrary ? (
|
||||||
<button type="button" className="drop drop-lib" onClick={() => setPicker(true)}>
|
<button type="button" className="drop drop-lib" onClick={() => setPicker(true)}>
|
||||||
<span className="drop-title">{t('Pick from the library')}</span>
|
<span className="drop-title">{t('Pick from the library')}</span>
|
||||||
<span className="drop-hint">{t('Everything Klarbild has already made — with search, by folder, and several at once')}</span>
|
<span className="drop-hint">{t('Everything Klarbild has already made — with search, by folder, and several at once')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
// Print-only instance: there is no library to pick from. The
|
||||||
|
// second way in is a sample, so the first visit ends in a
|
||||||
|
// sheet and not in an empty box.
|
||||||
|
<button type="button" className="drop drop-lib" onClick={addSample}>
|
||||||
|
<span className="drop-title">{t('Try it with a sample photo')}</span>
|
||||||
|
<span className="drop-hint">{t('No picture at hand? Take ours, pick a preset below and make the PDF.')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{presets.length > 0 && (
|
{presets.length > 0 && (
|
||||||
<div className="quickstart">
|
<div className="quickstart">
|
||||||
<span className="mono-label">{t('Or start from a preset')}</span>
|
<span className="mono-label">{t('Or start from a preset')}</span>
|
||||||
@@ -553,7 +580,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
))}
|
))}
|
||||||
<div className="cell-add-group">
|
<div className="cell-add-group">
|
||||||
<button className="cell-add" onClick={() => fileRef.current?.click()}>{t('+ Upload')}</button>
|
<button className="cell-add" onClick={() => fileRef.current?.click()}>{t('+ Upload')}</button>
|
||||||
<button className="cell-add" onClick={() => setPicker(true)}>{t('+ From the library')}</button>
|
{hasLibrary && <button className="cell-add" onClick={() => setPicker(true)}>{t('+ From the library')}</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -706,6 +733,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{canDeliver && (
|
||||||
<Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}>
|
<Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}>
|
||||||
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}
|
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}
|
||||||
aria-label={t('Additional target')}>
|
aria-label={t('Additional target')}>
|
||||||
@@ -718,7 +746,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
|
|||||||
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
|
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||||
placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} />
|
placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} />
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [num
|
|||||||
{ id: 'A2', label: 'DIN A2', cm: [42, 59.4] },
|
{ id: 'A2', label: 'DIN A2', cm: [42, 59.4] },
|
||||||
{ id: '20x20', label: '20 × 20 cm', cm: [20, 20] },
|
{ id: '20x20', label: '20 × 20 cm', cm: [20, 20] },
|
||||||
{ id: '30x30', label: '30 × 30 cm', cm: [30, 30] },
|
{ id: '30x30', label: '30 × 30 cm', cm: [30, 30] },
|
||||||
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
|
{ id: 'tv169', label: 'TV 16:9', screen: [3840, 2160] },
|
||||||
{ id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] },
|
{ id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] },
|
||||||
{ id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] },
|
{ id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] },
|
||||||
{ id: 'custom', label: 'Custom size (cm)' },
|
{ id: 'custom', label: 'Custom size (cm)' },
|
||||||
@@ -85,7 +85,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
|
|||||||
const fmt = FORMATS.find((f) => f.id === format);
|
const fmt = FORMATS.find((f) => f.id === format);
|
||||||
const hasCutout = tasks.includes('cutout');
|
const hasCutout = tasks.includes('cutout');
|
||||||
const hasFormat = tasks.includes('format');
|
const hasFormat = tasks.includes('format');
|
||||||
const theframeConflict = hasCutout && (format === 'theframe' || format === 'portrait916');
|
const tvFormatConflict = hasCutout && (format === 'tv169' || format === 'portrait916');
|
||||||
// In compose/generate the size is always available (without the "Format" task).
|
// In compose/generate the size is always available (without the "Format" task).
|
||||||
const showFormat = mode === 'each' ? hasFormat : true;
|
const showFormat = mode === 'each' ? hasFormat : true;
|
||||||
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
||||||
@@ -220,7 +220,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
|
|||||||
// "Custom size" needs a valid entry, otherwise the run cannot start.
|
// "Custom size" needs a valid entry, otherwise the run cannot start.
|
||||||
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
|
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
|
||||||
const canRun = !busy && !freeFmtInvalid && (
|
const canRun = !busy && !freeFmtInvalid && (
|
||||||
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !tvFormatConflict && !(tasks.includes('contour') && !hasCutout))
|
||||||
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
||||||
: desc.trim().length > 0
|
: desc.trim().length > 0
|
||||||
);
|
);
|
||||||
@@ -373,7 +373,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
|
|||||||
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
|
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{theframeConflict && <div className="hint warn">{t('Cut out + The Frame makes no sense — pick one.')}</div>}
|
{tvFormatConflict && <div className="hint warn">{t('Cut out + TV 16:9 makes no sense — pick one.')}</div>}
|
||||||
{effCm && (
|
{effCm && (
|
||||||
<div className="switch">
|
<div className="switch">
|
||||||
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
|
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
|
||||||
@@ -388,7 +388,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{targetPx && mode !== 'generate' && crop === 'extend' && (
|
{targetPx && mode !== 'generate' && crop === 'extend' && (
|
||||||
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.')}</div>
|
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for wide TV frames.')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const ICONS: Record<string, string> = {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#EAEAE3" />
|
<meta name="theme-color" content="#EAEAE3" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export async function backupAll(): Promise<{ items: number; mirrored: number; fa
|
|||||||
const dests = await backupDestinations();
|
const dests = await backupDestinations();
|
||||||
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||||
const rows = await query<{ id: string }>(
|
const rows = await query<{ id: string }>(
|
||||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL AND deleted_at IS NULL ORDER BY created_at`);
|
||||||
let mirrored = 0, failed = 0;
|
let mirrored = 0, failed = 0;
|
||||||
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||||
return { items: rows.length, mirrored, failed };
|
return { items: rows.length, mirrored, failed };
|
||||||
|
|||||||
@@ -80,6 +80,6 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
|
|||||||
/** Work through all deliveries of a job that are still open (pending). */
|
/** Work through all deliveries of a job that are still open (pending). */
|
||||||
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
||||||
const items = await query<{ id: string }>(
|
const items = await query<{ id: string }>(
|
||||||
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]);
|
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done' AND deleted_at IS NULL`, [jobId]);
|
||||||
for (const it of items) await deliverItem(it.id);
|
for (const it of items) await deliverItem(it.id);
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ const CM: Record<string, [number, number]> = {
|
|||||||
|
|
||||||
// Pure screen sizes (fixed pixels, no cm/dpi)
|
// Pure screen sizes (fixed pixels, no cm/dpi)
|
||||||
const SCREEN: Record<string, [number, number]> = {
|
const SCREEN: Record<string, [number, number]> = {
|
||||||
theframe: [3840, 2160], // 16:9 landscape
|
tv169: [3840, 2160], // 16:9 landscape
|
||||||
portrait916: [2160, 3840], // 9:16 portrait
|
portrait916: [2160, 3840], // 9:16 portrait
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null {
|
|||||||
|
|
||||||
if (format in SCREEN) {
|
if (format in SCREEN) {
|
||||||
const [w, h] = SCREEN[format];
|
const [w, h] = SCREEN[format];
|
||||||
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Portrait' };
|
return { w, h, label: format === 'tv169' ? 'TV 16:9' : 'Portrait' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let cm: [number, number] | undefined;
|
let cm: [number, number] | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Who may do what with an image.
|
||||||
|
//
|
||||||
|
// One rule, in one place. Before this file, only the file route checked access;
|
||||||
|
// renaming, deleting, retrying and zipping took any id from any signed-in user.
|
||||||
|
// On a single-user install that changes nothing. On a family install it meant
|
||||||
|
// one account could delete another account's images by id.
|
||||||
|
import { one } from './db.ts';
|
||||||
|
|
||||||
|
export interface Viewer { uid: string | null; role: string }
|
||||||
|
|
||||||
|
export interface ItemAccessRow {
|
||||||
|
id: string;
|
||||||
|
created_by: string | null;
|
||||||
|
private: boolean | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reading: your own images, everything as admin, others' only in a shared library and never from a private session. */
|
||||||
|
export function canRead(v: Viewer, it: Pick<ItemAccessRow, 'created_by' | 'private'>, libraryShared: boolean): boolean {
|
||||||
|
if (v.role === 'admin') return true;
|
||||||
|
if (v.uid && it.created_by === v.uid) return true;
|
||||||
|
return libraryShared && !it.private;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changing or deleting: only your own images, or as admin. A shared library is for looking, not for tidying up other people's work. */
|
||||||
|
export function canWrite(v: Viewer, it: Pick<ItemAccessRow, 'created_by'>): boolean {
|
||||||
|
if (v.role === 'admin') return true;
|
||||||
|
return !!v.uid && it.created_by === v.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an image together with what the access decision needs.
|
||||||
|
* Returns null both for "does not exist" and for "not yours" — a caller must not
|
||||||
|
* be able to tell the two apart by trying ids.
|
||||||
|
*/
|
||||||
|
export async function loadItemFor(
|
||||||
|
v: Viewer, id: string, mode: 'read' | 'write', opts: { includeTrashed?: boolean } = {},
|
||||||
|
): Promise<ItemAccessRow | null> {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(id)) return null;
|
||||||
|
const it = await one<ItemAccessRow & { library_visibility: string | null }>(
|
||||||
|
`SELECT i.id, j.created_by, j.private, i.deleted_at,
|
||||||
|
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
||||||
|
FROM items i LEFT JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.id = $1`, [id]);
|
||||||
|
if (!it) return null;
|
||||||
|
if (it.deleted_at && !opts.includeTrashed) return null;
|
||||||
|
const ok = mode === 'write' ? canWrite(v, it) : canRead(v, it, it.library_visibility === 'shared');
|
||||||
|
return ok ? it : null;
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ export function startMaintenance(): void {
|
|||||||
const r = await runRetention(s.retention_days);
|
const r = await runRetention(s.retention_days);
|
||||||
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} old items removed.`);
|
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} old items removed.`);
|
||||||
}
|
}
|
||||||
|
const { purgeExpiredTrash } = await import('./trash');
|
||||||
|
const tr = await purgeExpiredTrash();
|
||||||
|
if (tr) console.log(`[maintenance] Trash: ${tr} images removed for good.`);
|
||||||
const p = await purgePrivate(60);
|
const p = await purgePrivate(60);
|
||||||
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
||||||
const t = await purgeTracking(90);
|
const t = await purgeTracking(90);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// renaming, tests).
|
// renaming, tests).
|
||||||
//
|
//
|
||||||
// Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
// Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||||
// 2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg
|
// 2026-08-20_143207_portrait-badge_tv169_a3f9.jpg
|
||||||
//
|
//
|
||||||
// Why: the timestamp comes first, so every file listing — delivery target,
|
// Why: the timestamp comes first, so every file listing — delivery target,
|
||||||
// Finder, backup mirror — sorts chronologically by itself. The name becomes
|
// Finder, backup mirror — sorts chronologically by itself. The name becomes
|
||||||
@@ -89,7 +89,7 @@ export interface NameParts {
|
|||||||
/**
|
/**
|
||||||
* Unique, chronologically sortable file name:
|
* Unique, chronologically sortable file name:
|
||||||
* `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
* `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||||
* e.g. `2026-08-20_143207_portrait_the-frame_a3f9.png`
|
* e.g. `2026-08-20_143207_portrait_tv169_a3f9.png`
|
||||||
*
|
*
|
||||||
* Why like this: the name begins with the timestamp, so every file listing
|
* Why like this: the name begins with the timestamp, so every file listing
|
||||||
* (delivery target, Finder, backup mirror) sorts chronologically by itself. It
|
* (delivery target, Finder, backup mirror) sorts chronologically by itself. It
|
||||||
@@ -142,7 +142,7 @@ export function newNameFor(it: { id: string; filename: string | null; created_at
|
|||||||
/** Format key for the file name (from the output_format). */
|
/** Format key for the file name (from the output_format). */
|
||||||
export function formatToken(outputFormat: string | undefined): string {
|
export function formatToken(outputFormat: string | undefined): string {
|
||||||
if (!outputFormat || outputFormat === 'keep') return 'original';
|
if (!outputFormat || outputFormat === 'keep') return 'original';
|
||||||
if (outputFormat === 'theframe') return 'the-frame';
|
if (outputFormat === 'tv169') return 'tv169';
|
||||||
if (outputFormat === 'portrait916') return 'portrait916';
|
if (outputFormat === 'portrait916') return 'portrait916';
|
||||||
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { one } from './db';
|
import { one } from './db';
|
||||||
import { decrypt } from './crypto';
|
import { decrypt } from './crypto';
|
||||||
|
|
||||||
const BASE = 'https://openrouter.ai/api/v1';
|
/**
|
||||||
|
* Where the image requests go.
|
||||||
|
*
|
||||||
|
* OpenRouter by default, because it is one key for many models. Point
|
||||||
|
* `IMAGE_API_BASE_URL` somewhere else and Klarbild talks to that instead — any
|
||||||
|
* endpoint that answers `POST {base}/images` in the OpenAI image shape will do,
|
||||||
|
* which covers a gateway like LiteLLM, a company proxy, or a local server. The
|
||||||
|
* request body is written to that shape deliberately; the only OpenRouter
|
||||||
|
* extras are the `X-Title` header and the `usage.cost` field, and both are
|
||||||
|
* optional on the way back.
|
||||||
|
*
|
||||||
|
* Whatever you point it at, the images go to exactly one place and you chose it.
|
||||||
|
*/
|
||||||
|
const BASE = (process.env.IMAGE_API_BASE_URL || 'https://openrouter.ai/api/v1').replace(/\/+$/, '');
|
||||||
|
|
||||||
/** Key: the DB value (set in the admin area) takes precedence over the environment variable. */
|
/** True when this instance talks to OpenRouter rather than to something else. */
|
||||||
|
export const usingOpenRouter = BASE.includes('openrouter.ai');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key: the value from the admin area wins, then `IMAGE_API_KEY`, then
|
||||||
|
* `OPENROUTER_API_KEY` — the last one so that instances set up before the base
|
||||||
|
* URL became configurable keep working untouched.
|
||||||
|
*/
|
||||||
export async function resolveKey(): Promise<string> {
|
export async function resolveKey(): Promise<string> {
|
||||||
const row = await one<{ openrouter_key_enc: string | null }>(
|
const row = await one<{ openrouter_key_enc: string | null }>(
|
||||||
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
||||||
if (row?.openrouter_key_enc) {
|
if (row?.openrouter_key_enc) {
|
||||||
try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ }
|
try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ }
|
||||||
}
|
}
|
||||||
return process.env.OPENROUTER_API_KEY || '';
|
return process.env.IMAGE_API_KEY || process.env.OPENROUTER_API_KEY || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenRouterError extends Error {
|
export class OpenRouterError extends Error {
|
||||||
@@ -49,7 +69,7 @@ export interface GenerateResult {
|
|||||||
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
||||||
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
||||||
const key = await resolveKey();
|
const key = await resolveKey();
|
||||||
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No OpenRouter key stored');
|
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No API key stored for the image service');
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
@@ -69,6 +89,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
// OpenRouter shows this on the activity page. Other endpoints ignore it.
|
||||||
'X-Title': 'Klarbild',
|
'X-Title': 'Klarbild',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -76,7 +97,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
||||||
`OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
|
`Image API ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: any = await res.json();
|
const json: any = await res.json();
|
||||||
@@ -90,12 +111,20 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The available image models (for the model list in the admin area). */
|
/**
|
||||||
|
* The available image models, for the picker in the admin area.
|
||||||
|
*
|
||||||
|
* `GET {base}/images/models` is an OpenRouter route. An endpoint that does not
|
||||||
|
* have it answers 404, and that is not an error worth showing: it only means
|
||||||
|
* the list cannot be filled in automatically, and the model id has to be typed.
|
||||||
|
* Anything else — a wrong key, a dead host — is still reported.
|
||||||
|
*/
|
||||||
export async function listImageModels(): Promise<any[]> {
|
export async function listImageModels(): Promise<any[]> {
|
||||||
const key = await resolveKey();
|
const key = await resolveKey();
|
||||||
const res = await fetch(`${BASE}/images/models`, {
|
const res = await fetch(`${BASE}/images/models`, {
|
||||||
headers: { Authorization: `Bearer ${key}` },
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
});
|
});
|
||||||
|
if (res.status === 404 && !usingOpenRouter) return [];
|
||||||
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
||||||
return (await res.json())?.data ?? [];
|
return (await res.json())?.data ?? [];
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ export const PHOTO_SIZES: PhotoSize[] = [
|
|||||||
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
||||||
|
|
||||||
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
||||||
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
{ id: 'W16x9', label: '16:9 TV 16:9 (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||||
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
||||||
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
||||||
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
|
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
|
||||||
// Deliberately without AI — plain geometry and scaling, so that the result is
|
// Deliberately without AI — plain geometry and scaling, so that the result is
|
||||||
// predictable and repeatable.
|
// predictable and repeatable.
|
||||||
import sharp from 'sharp';
|
import sharp, { type Sharp } from 'sharp';
|
||||||
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||||
import { mmToPt, mmToPx } from './paper.ts';
|
import { mmToPt, mmToPx } from './paper.ts';
|
||||||
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
||||||
@@ -112,7 +112,7 @@ export async function renderCell(
|
|||||||
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||||
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
||||||
|
|
||||||
const encode = (pipe: sharp.Sharp) => (ext === 'png'
|
const encode = (pipe: Sharp) => (ext === 'png'
|
||||||
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||||
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function recordRun(e: RunInput): Promise<string | null> {
|
|||||||
await query(
|
await query(
|
||||||
`INSERT INTO print_run_items (run_id, item_id)
|
`INSERT INTO print_run_items (run_id, item_id)
|
||||||
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
||||||
WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
|
WHERE EXISTS (SELECT 1 FROM items WHERE id = x AND deleted_at IS NULL)
|
||||||
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
||||||
console.error('[printruns] image references not stored:', err?.message || err));
|
console.error('[printruns] image references not stored:', err?.message || err));
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ export async function listRuns(userId: string | null, isAdmin: boolean, limit =
|
|||||||
const images = await query<{ run_id: string; id: string; filename: string | null }>(
|
const images = await query<{ run_id: string; id: string; filename: string | null }>(
|
||||||
`SELECT ri.run_id, i.id, i.filename
|
`SELECT ri.run_id, i.id, i.filename
|
||||||
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
||||||
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
|
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL`,
|
||||||
[runs.map((r) => r.id)]);
|
[runs.map((r) => r.id)]);
|
||||||
|
|
||||||
const byRun = new Map<string, { id: string; filename: string | null }[]>();
|
const byRun = new Map<string, { id: string; filename: string | null }[]>();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function loadSourceNamed(src: PrintSource, user: SessionUser): Prom
|
|||||||
if (src.kind === 'item') {
|
if (src.kind === 'item') {
|
||||||
const item = await one<any>(
|
const item = await one<any>(
|
||||||
`SELECT i.result_path, i.filename, j.created_by, j.private
|
`SELECT i.result_path, i.filename, j.created_by, j.private
|
||||||
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
|
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1 AND i.deleted_at IS NULL`, [src.id]);
|
||||||
if (!item?.result_path) throw new Error('Image not found.');
|
if (!item?.result_path) throw new Error('Image not found.');
|
||||||
const isAdmin = user.role === 'admin';
|
const isAdmin = user.role === 'admin';
|
||||||
const own = item.created_by === user.uid;
|
const own = item.created_by === user.uid;
|
||||||
|
|||||||
@@ -61,9 +61,15 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
|||||||
|
|
||||||
/** Processes one item completely and updates its record. */
|
/** Processes one item completely and updates its record. */
|
||||||
export async function processItem(itemId: string): Promise<ProcessResult> {
|
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||||
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
|
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null; deleted_at: string | null }>(
|
||||||
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
'SELECT id, job_id, source_path, source_paths, filename, deleted_at FROM items WHERE id=$1', [itemId]);
|
||||||
if (!item) return { ok: false, cost: 0, error: 'Item not found' };
|
if (!item) return { ok: false, cost: 0, error: 'Item not found' };
|
||||||
|
// Moved to the trash while it was still waiting: do not pay a model for an
|
||||||
|
// image nobody wants any more.
|
||||||
|
if (item.deleted_at) {
|
||||||
|
await query(`UPDATE items SET status='skipped' WHERE id=$1 AND status IN ('queued','running')`, [itemId]);
|
||||||
|
return { ok: true, cost: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||||
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ async function seedAdmin(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
// Printed once, on the first boot only. There is no way to recover it later;
|
// Printed once, on the first boot only. There is no way to recover it later;
|
||||||
// the reset flow (module "mail") or a fresh volume is the way back.
|
// the reset flow (module "mail") or a fresh volume is the way back.
|
||||||
|
const lines = [
|
||||||
|
'Klarbild is set up. Sign in with:',
|
||||||
|
` user ${username}`,
|
||||||
|
` password ${password}`,
|
||||||
|
'Shown once. Change it under Account after you sign in.',
|
||||||
|
];
|
||||||
|
const w = Math.max(...lines.map((l) => l.length)) + 2;
|
||||||
console.log(
|
console.log(
|
||||||
'\n' +
|
'\n ┌' + '─'.repeat(w) + '┐\n' +
|
||||||
' ┌────────────────────────────────────────────────────────────┐\n' +
|
lines.map((l) => ` │ ${l.padEnd(w - 1)}│`).join('\n') +
|
||||||
' │ Klarbild is set up. Sign in with: │\n' +
|
'\n └' + '─'.repeat(w) + '┘\n',
|
||||||
` │ user ${username.padEnd(46)}│\n` +
|
|
||||||
` │ password ${password.padEnd(46)}│\n` +
|
|
||||||
' │ This is shown once. Change it under Account after login. │\n' +
|
|
||||||
' └────────────────────────────────────────────────────────────┘\n',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,15 +81,19 @@ async function seedRecipes(): Promise<void> {
|
|||||||
const by = admin?.id ?? null;
|
const by = admin?.id ?? null;
|
||||||
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||||||
query(
|
query(
|
||||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm, output_ext)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||||
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
||||||
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
|
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null,
|
||||||
|
(extra.output_ext as string) ?? null]);
|
||||||
|
|
||||||
|
// Examples, meant to be read and then replaced. Each one shows a different
|
||||||
|
// combination of the four tasks rather than a different size.
|
||||||
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||||||
await R('TV frame (16:9)', ['clean', 'format'], 'theframe', 'landscape');
|
await R('Cut out, transparent PNG', ['clean', 'cutout'], 'keep', 'landscape', { output_ext: 'png' });
|
||||||
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
|
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
|
||||||
|
await R('Square 30 × 30 for social', ['clean', 'format'], '30x30', 'landscape');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------- print presets */
|
/* --------------------------------------------------------- print presets */
|
||||||
|
|||||||
@@ -153,14 +153,16 @@ export async function shareItems(shareId: string): Promise<ShareBild[]> {
|
|||||||
`SELECT i.id, i.filename, i.output_px, i.created_at
|
`SELECT i.id, i.filename, i.output_px, i.created_at
|
||||||
FROM share_items si
|
FROM share_items si
|
||||||
JOIN items i ON i.id = si.item_id
|
JOIN items i ON i.id = si.item_id
|
||||||
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
|
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL
|
||||||
ORDER BY si.position, i.created_at`, [shareId]);
|
ORDER BY si.position, i.created_at`, [shareId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this image belong to this link? Cross-check before every delivery. */
|
/** Does this image belong to this link? Cross-check before every delivery. */
|
||||||
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
||||||
const r = await one<{ item_id: string }>(
|
const r = await one<{ item_id: string }>(
|
||||||
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
|
// An image in the trash is not part of any link until it is restored.
|
||||||
|
`SELECT si.item_id FROM share_items si JOIN items i ON i.id = si.item_id
|
||||||
|
WHERE si.share_id=$1 AND si.item_id=$2 AND i.deleted_at IS NULL`, [shareId, itemId]);
|
||||||
return !!r;
|
return !!r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +235,7 @@ export async function listShares(userId: string | null, isAdmin: boolean): Promi
|
|||||||
// share_views: with many accesses this page turned sluggish otherwise.
|
// share_views: with many accesses this page turned sluggish otherwise.
|
||||||
`SELECT s.*,
|
`SELECT s.*,
|
||||||
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
||||||
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS count,
|
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL AND i.deleted_at IS NULL) AS count,
|
||||||
s.visitor
|
s.visitor
|
||||||
FROM shares s
|
FROM shares s
|
||||||
WHERE $2::boolean OR s.created_by = $1
|
WHERE $2::boolean OR s.created_by = $1
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// The trash: delete softly, restore, empty, and let maintenance finish the job.
|
||||||
|
import { one, query } from './db.ts';
|
||||||
|
import { deleteObject } from './storage.ts';
|
||||||
|
import type { Viewer } from './itemaccess.ts';
|
||||||
|
|
||||||
|
export const DEFAULT_TRASH_DAYS = 30;
|
||||||
|
|
||||||
|
/** Every stored object an image row points to. */
|
||||||
|
export function objectKeysOf(it: {
|
||||||
|
source_path?: string | null; source_paths?: string[] | null; result_path?: string | null; thumb_path?: string | null;
|
||||||
|
}): string[] {
|
||||||
|
const keys = [it.source_path, it.result_path, it.thumb_path, ...(it.source_paths || [])]
|
||||||
|
.filter((k): k is string => typeof k === 'string' && k.length > 0);
|
||||||
|
return [...new Set(keys)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days until an image in the trash is removed for good. Rounded up, never below
|
||||||
|
* zero: "0 days" means the next maintenance run takes it.
|
||||||
|
*/
|
||||||
|
export function daysLeft(deletedAt: Date | string, trashDays: number, now: Date = new Date()): number {
|
||||||
|
const gone = new Date(deletedAt).getTime() + trashDays * 86_400_000;
|
||||||
|
return Math.max(0, Math.ceil((gone - now.getTime()) / 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashDays(): Promise<number> {
|
||||||
|
const s = await one<{ trash_days: number | null }>('SELECT trash_days FROM settings WHERE id=1');
|
||||||
|
const d = s?.trash_days;
|
||||||
|
return typeof d === 'number' && d >= 0 ? d : DEFAULT_TRASH_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL condition: rows this viewer may change. `$1` = uid, `$2` = is admin. */
|
||||||
|
const OWN = `($2::boolean OR i.job_id IN (SELECT id FROM jobs WHERE created_by = $1))`;
|
||||||
|
|
||||||
|
/** Moves images into the trash. Returns the ids that actually moved. */
|
||||||
|
export async function trashItems(v: Viewer, ids: string[]): Promise<string[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
if ((await trashDays()) === 0) return purgeItems(v, ids, { onlyTrashed: false });
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`UPDATE items i SET deleted_at = now()
|
||||||
|
WHERE i.id = ANY($3::uuid[]) AND i.deleted_at IS NULL AND ${OWN}
|
||||||
|
RETURNING i.id`, [v.uid, v.role === 'admin', ids]);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreItems(v: Viewer, ids: string[]): Promise<string[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`UPDATE items i SET deleted_at = NULL
|
||||||
|
WHERE i.id = ANY($3::uuid[]) AND i.deleted_at IS NOT NULL AND ${OWN}
|
||||||
|
RETURNING i.id`, [v.uid, v.role === 'admin', ids]);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes images for good: the row and every file that no other image still
|
||||||
|
* uses. That last part matters — an alternative version shares its source file
|
||||||
|
* with the original, and deleting the original must not take the source of the
|
||||||
|
* alternative with it.
|
||||||
|
*/
|
||||||
|
export async function purgeItems(v: Viewer | null, ids: string[], opts: { onlyTrashed?: boolean } = {}): Promise<string[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const onlyTrashed = opts.onlyTrashed !== false;
|
||||||
|
const admin = !v || v.role === 'admin';
|
||||||
|
const rows = await query<{ id: string; source_path: string | null; source_paths: string[] | null; result_path: string | null; thumb_path: string | null }>(
|
||||||
|
`DELETE FROM items i
|
||||||
|
WHERE i.id = ANY($3::uuid[]) ${onlyTrashed ? 'AND i.deleted_at IS NOT NULL' : ''} AND ${OWN}
|
||||||
|
RETURNING i.id, i.source_path, i.source_paths, i.result_path, i.thumb_path`,
|
||||||
|
[v?.uid ?? null, admin, ids]);
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const k of objectKeysOf(r)) {
|
||||||
|
// `source_paths` is jsonb, not a Postgres array. A failure here must not stop
|
||||||
|
// the loop: the rows are already gone, and a skipped file is only wasted
|
||||||
|
// space, while an exception would skip every file after it.
|
||||||
|
try {
|
||||||
|
const still = await one<{ n: number }>(
|
||||||
|
`SELECT count(*)::int AS n FROM items
|
||||||
|
WHERE source_path = $1 OR result_path = $1 OR thumb_path = $1
|
||||||
|
OR source_paths @> jsonb_build_array($1::text)`, [k]);
|
||||||
|
if (!still?.n) await deleteObject(k);
|
||||||
|
} catch (e) { console.error('[trash] could not remove', k, (e as Error)?.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empties the trash: the viewer's own images, or all of them for an admin. */
|
||||||
|
export async function emptyTrash(v: Viewer): Promise<number> {
|
||||||
|
const ids = await query<{ id: string }>(
|
||||||
|
`SELECT i.id FROM items i WHERE i.deleted_at IS NOT NULL AND ${OWN}`, [v.uid, v.role === 'admin']);
|
||||||
|
return (await purgeItems(v, ids.map((r) => r.id))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maintenance: everything that has been in the trash longer than the setting allows. */
|
||||||
|
export async function purgeExpiredTrash(): Promise<number> {
|
||||||
|
const days = await trashDays();
|
||||||
|
const ids = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM items WHERE deleted_at IS NOT NULL AND deleted_at < now() - ($1 || ' days')::interval`, [String(days)]);
|
||||||
|
return (await purgeItems(null, ids.map((r) => r.id))).length;
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
{
|
{
|
||||||
"(enforced)": "(erzwungen)",
|
"(enforced)": "(erzwungen)",
|
||||||
|
"+ Alt.": "+ Alt.",
|
||||||
".md off": ".md aus",
|
".md off": ".md aus",
|
||||||
".md on": ".md an",
|
".md on": ".md an",
|
||||||
"0 = unlimited.": "0 = unbegrenzt.",
|
"0 = unlimited.": "0 = unbegrenzt.",
|
||||||
"1 Sep 2026": "01.09.2026",
|
"1 Sep 2026": "01.09.2026",
|
||||||
|
"1 day left": "Noch 1 Tag",
|
||||||
"1 image": "1 Bild",
|
"1 image": "1 Bild",
|
||||||
"1 year": "1 Jahr",
|
"1 year": "1 Jahr",
|
||||||
"1 year from now": "1 Jahr ab jetzt",
|
"1 year from now": "1 Jahr ab jetzt",
|
||||||
"1 · Edit an image": "1 · Bild bearbeiten",
|
"1 · Edit an image": "1 · Bild bearbeiten",
|
||||||
|
"1.1 — a trash, a sample photo, and an image you do not have to build": "1.1 — ein Papierkorb, ein Beispielfoto und ein Image, das du nicht selbst bauen musst",
|
||||||
|
"10 Oct 2026": "10.10.2026",
|
||||||
"13 × 18 cm → measured 130.1 × 180.1 mm": "13 × 18 cm → gemessen 130,1 × 180,1 mm",
|
"13 × 18 cm → measured 130.1 × 180.1 mm": "13 × 18 cm → gemessen 130,1 × 180,1 mm",
|
||||||
"150 — draft": "150 — Entwurf",
|
"150 — draft": "150 — Entwurf",
|
||||||
"16:9 \"The Frame\" (30 cm wide)": "16:9 „The Frame\" (30 cm breit)",
|
"16:9 TV (30 cm wide)": "16:9 TV 16:9 (30 cm breit)",
|
||||||
"18 Aug 2026": "18.08.2026",
|
"18 Aug 2026": "18.08.2026",
|
||||||
"19 Aug 2026": "19.08.2026",
|
"19 Aug 2026": "19.08.2026",
|
||||||
"1:1 square (30 cm)": "1:1 quadratisch (30 cm)",
|
"1:1 square (30 cm)": "1:1 quadratisch (30 cm)",
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
"3:4 (30 cm tall)": "3:4 (30 cm hoch)",
|
"3:4 (30 cm tall)": "3:4 (30 cm hoch)",
|
||||||
"4 · Library, folders, alternatives & keep editing": "4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten",
|
"4 · Library, folders, alternatives & keep editing": "4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten",
|
||||||
"4.5 × 6 cm": "4,5 × 6 cm",
|
"4.5 × 6 cm": "4,5 × 6 cm",
|
||||||
|
"465 = SSL · 587 = STARTTLS": "465 = SSL · 587 = STARTTLS",
|
||||||
"4:3 (30 cm wide)": "4:3 (30 cm breit)",
|
"4:3 (30 cm wide)": "4:3 (30 cm breit)",
|
||||||
"4:5 Instagram (30 cm tall)": "4:5 Instagram (30 cm hoch)",
|
"4:5 Instagram (30 cm tall)": "4:5 Instagram (30 cm hoch)",
|
||||||
"5 · Print — exact sizes and crop marks (without AI)": "5 · Drucken — exakte Maße und Schnittmarken (ohne KI)",
|
"5 · Print — exact sizes and crop marks (without AI)": "5 · Drucken — exakte Maße und Schnittmarken (ohne KI)",
|
||||||
@@ -83,6 +88,8 @@
|
|||||||
"<b>Cutting guides</b>: <b>corner marks</b> (fine marks outside the final format, as in Photoshop and InDesign) or <b>continuous lines</b> across the whole sheet for the cutting ruler. The continuous lines never run through another subject.": "<b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal. Die durchgehenden Linien laufen nie durch ein anderes Motiv.",
|
"<b>Cutting guides</b>: <b>corner marks</b> (fine marks outside the final format, as in Photoshop and InDesign) or <b>continuous lines</b> across the whole sheet for the cutting ruler. The continuous lines never run through another subject.": "<b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal. Die durchgehenden Linien laufen nie durch ein anderes Motiv.",
|
||||||
"<b>Decide the caption yourself.</b> The small line under the sheet can be switched off, given your own text and placed in one of six spots (top or bottom, left, centre or right). If there is no room free at the chosen edge it is dropped automatically — nothing is ever printed over an image.": "<b>Beschriftung selbst bestimmen.</b> Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.",
|
"<b>Decide the caption yourself.</b> The small line under the sheet can be switched off, given your own text and placed in one of six spots (top or bottom, left, centre or right). If there is no room free at the chosen edge it is dropped automatically — nothing is ever printed over an image.": "<b>Beschriftung selbst bestimmen.</b> Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.",
|
||||||
"<b>Delete a job</b> straight from the queue (for old or failed jobs). Missing sources now report a clear notice instead of a cryptic technical error.": "<b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.",
|
"<b>Delete a job</b> straight from the queue (for old or failed jobs). Missing sources now report a clear notice instead of a cryptic technical error.": "<b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.",
|
||||||
|
"<b>Deleting is no longer final on the spot.</b> ✕ moves an image into the trash, and for a few seconds the bottom of the screen offers <b>Undo</b>. Noticed later? The library shows a <b>Trash</b> button; everything in there can be restored for 30 days, then Klarbild clears it away by itself.": "<b>Löschen ist nicht mehr sofort endgültig.</b> ✕ legt ein Bild in den Papierkorb, und unten steht für ein paar Sekunden <b>Rückgängig</b>. Später gemerkt? In der Bibliothek steht dann <b>Papierkorb</b>; alles darin lässt sich 30 Tage lang wiederherstellen, danach räumt Klarbild es von selbst weg.",
|
||||||
|
"<b>Deleting</b> (✕) moves an image into the <b>trash</b>, with <b>Undo</b> right after. The trash (a button top right, once something is in it) keeps it for 30 days — and while it is there it appears in no share link and no delivery.": "<b>Löschen</b> (✕) legt ein Bild in den <b>Papierkorb</b>, direkt danach mit <b>Rückgängig</b>. Der Papierkorb (Knopf oben rechts, sobald etwas drinliegt) hebt es 30 Tage auf — und solange es dort liegt, taucht es in keinem geteilten Link und bei keiner Auslieferung auf.",
|
||||||
"<b>Delivery-target tools in Admin:</b> “Show folders” (what sits where over SFTP) and “Clean up leftovers” (remove .tmp files left behind).": "<b>Werkzeuge fürs Auslieferungsziel im Admin:</b> „Ordner anzeigen\" (was liegt per SFTP wo) und „Reste aufräumen\" (liegengebliebene.tmp-Dateien entfernen).",
|
"<b>Delivery-target tools in Admin:</b> “Show folders” (what sits where over SFTP) and “Clean up leftovers” (remove .tmp files left behind).": "<b>Werkzeuge fürs Auslieferungsziel im Admin:</b> „Ordner anzeigen\" (was liegt per SFTP wo) und „Reste aufräumen\" (liegengebliebene.tmp-Dateien entfernen).",
|
||||||
"<b>Download singly</b>: one image at exactly its size as PNG/JPG with dpi metadata — without a sheet.": "<b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.",
|
"<b>Download singly</b>: one image at exactly its size as PNG/JPG with dpi metadata — without a sheet.": "<b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.",
|
||||||
"<b>Download</b> saves the file at full resolution. On a phone it also works like this: press and hold an image, then save it.": "<b>Herunterladen</b> speichert die Datei in voller Auflösung. Am Handy geht es auch so: lange auf ein Bild drücken und speichern.",
|
"<b>Download</b> saves the file at full resolution. On a phone it also works like this: press and hold an image, then save it.": "<b>Herunterladen</b> speichert die Datei in voller Auflösung. Am Handy geht es auch so: lange auf ein Bild drücken und speichern.",
|
||||||
@@ -92,10 +99,10 @@
|
|||||||
"<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.": "<b>Exakte Maße statt „ungefähr\".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %\" wählen — nicht „an Seitengröße anpassen\".",
|
"<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.": "<b>Exakte Maße statt „ungefähr\".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %\" wählen — nicht „an Seitengröße anpassen\".",
|
||||||
"<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).": "<b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm\" lief ins Leere („Unbekanntes Format: sticker5\").",
|
"<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).": "<b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm\" lief ins Leere („Unbekanntes Format: sticker5\").",
|
||||||
"<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.": "<b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.",
|
"<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.": "<b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.",
|
||||||
"<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)": "<b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt <code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B. <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Weil Datum und Uhrzeit vorn stehen, sortiert jede Dateiliste — Auslieferungsziel, Spiegel, Dateimanager — von allein chronologisch. Das Kürzel am Ende stammt aus der Kennung des Bildes: <b>zwei Bilder können sich beim Ausliefern nicht mehr überschreiben</b>, auch wenn du am selben Tag zweimal dasselbe Motiv im selben Format erzeugst. (Vor dem 21.08.2026 war das anders — dort ging in so einem Fall das erste Bild verloren. Unter <b>Admin → Dateinamen</b> lässt sich die vorhandene Bibliothek nachträglich umbenennen.)",
|
"<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)": "<b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt <code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B. <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code>. Weil Datum und Uhrzeit vorn stehen, sortiert jede Dateiliste — Auslieferungsziel, Spiegel, Dateimanager — von allein chronologisch. Das Kürzel am Ende stammt aus der Kennung des Bildes: <b>zwei Bilder können sich beim Ausliefern nicht mehr überschreiben</b>, auch wenn du am selben Tag zweimal dasselbe Motiv im selben Format erzeugst. (Vor dem 21.08.2026 war das anders — dort ging in so einem Fall das erste Bild verloren. Unter <b>Admin → Dateinamen</b> lässt sich die vorhandene Bibliothek nachträglich umbenennen.)",
|
||||||
"<b>Finish time in the job:</b> the queue and the job show when it was done.": "<b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.",
|
"<b>Finish time in the job:</b> the queue and the job show when it was done.": "<b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.",
|
||||||
"<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_the-frame.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.": "<b>Behoben: Bilder haben sich am Auslieferungsziel gegenseitig überschrieben.</b> Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugt hat — etwa zwei Badges —, bekam zweimal denselben Dateinamen (<code>2026-07-23_portrait_the-frame.png</code>). Beim Ausliefern ersetzte das zweite Bild dann stillschweigend das erste. Das kann jetzt nicht mehr passieren.",
|
"<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_tv169.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.": "<b>Behoben: Bilder haben sich am Auslieferungsziel gegenseitig überschrieben.</b> Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugt hat — etwa zwei Badges —, bekam zweimal denselben Dateinamen (<code>2026-07-23_portrait_tv169.png</code>). Beim Ausliefern ersetzte das zweite Bild dann stillschweigend das erste. Das kann jetzt nicht mehr passieren.",
|
||||||
"<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_druckbogen.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.": "<b>Behoben: Druckdateien hießen alle gleich.</b> Zwei Bogen am selben Tag waren beide <code>2026-09-01_druckbogen.pdf</code> — im selben Ordner überschrieb der zweite den ersten. Jetzt <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier und ein Kürzel. Das Kürzel gehört zum Verlaufseintrag — du siehst also, welcher Druck welche Datei war. Einzelbilder heißen jetzt nach ihrem Motiv.",
|
"<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_print-sheet.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_print-9-pieces_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.": "<b>Behoben: Druckdateien hießen alle gleich.</b> Zwei Bogen am selben Tag waren beide <code>2026-09-01_druckbogen.pdf</code> — im selben Ordner überschrieb der zweite den ersten. Jetzt <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier und ein Kürzel. Das Kürzel gehört zum Verlaufseintrag — du siehst also, welcher Druck welche Datei war. Einzelbilder heißen jetzt nach ihrem Motiv.",
|
||||||
"<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.": "<b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.",
|
"<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.": "<b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.",
|
||||||
"<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.": "<b>Behoben: „Ich kann das Bild nicht verschieben.\"</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.",
|
"<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.": "<b>Behoben: „Ich kann das Bild nicht verschieben.\"</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.",
|
||||||
"<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.": "<b>Behoben:</b> Ein neues Passwort beendet jetzt auch alle noch offenen Anmeldungen. Vorher blieb eine bestehende Sitzung bis zu 30 Tage gültig — wer sein Passwort zurücksetzte, weil jemand anderes hineingekommen war, hatte davon also nichts.",
|
"<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.": "<b>Behoben:</b> Ein neues Passwort beendet jetzt auch alle noch offenen Anmeldungen. Vorher blieb eine bestehende Sitzung bis zu 30 Tage gültig — wer sein Passwort zurücksetzte, weil jemand anderes hineingekommen war, hatte davon also nichts.",
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
"<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>": "<b>Wenn das Bild nicht zum Format passt</b>, entscheidest du je Bild: <b>Zuschneiden</b> (das Format wird ausgefüllt, außen fehlt etwas — Standard) oder <b>Rand lassen</b> (das ganze Bild bleibt sichtbar, außen steht die <b>Randfarbe</b> — weiß, schwarz, papierfarben oder frei gewählt). <b>Verzerrt wird nie.</b>",
|
"<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>": "<b>Wenn das Bild nicht zum Format passt</b>, entscheidest du je Bild: <b>Zuschneiden</b> (das Format wird ausgefüllt, außen fehlt etwas — Standard) oder <b>Rand lassen</b> (das ganze Bild bleibt sichtbar, außen steht die <b>Randfarbe</b> — weiß, schwarz, papierfarben oder frei gewählt). <b>Verzerrt wird nie.</b>",
|
||||||
"<b>Images from the library into the print area.</b> Until now you had to upload a file even when Klarbild had it already. The print area now offers <b>“Choose from the library”</b> — with search, by folder, and several at once.": "<b>Bilder aus der Bibliothek in den Druckbereich.</b> Bisher musstest du eine Datei hochladen, auch wenn Klarbild sie längst hatte. Jetzt steht im Druckbereich <b>„Aus der Bibliothek wählen\"</b> — mit Suche, nach Ordner, und mehrere auf einmal.",
|
"<b>Images from the library into the print area.</b> Until now you had to upload a file even when Klarbild had it already. The print area now offers <b>“Choose from the library”</b> — with search, by folder, and several at once.": "<b>Bilder aus der Bibliothek in den Druckbereich.</b> Bisher musstest du eine Datei hochladen, auch wenn Klarbild sie längst hatte. Jetzt steht im Druckbereich <b>„Aus der Bibliothek wählen\"</b> — mit Suche, nach Ordner, und mehrere auf einmal.",
|
||||||
"<b>Important when printing:</b> in the print dialog choose “Actual size” or “100%” — <b>not</b> “fit to page”. Otherwise the size no longer holds. If you can print borderless, set the margin to 0; otherwise 3–5 mm, or the printer cuts the marks away.": "<b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe\" bzw. „100 %\" wählen — <b>nicht</b> „an Seitengröße anpassen\". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann, setzt den Rand auf 0; sonst 3–5 mm, sonst schneidet der Drucker die Marken weg.",
|
"<b>Important when printing:</b> in the print dialog choose “Actual size” or “100%” — <b>not</b> “fit to page”. Otherwise the size no longer holds. If you can print borderless, set the margin to 0; otherwise 3–5 mm, or the printer cuts the marks away.": "<b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe\" bzw. „100 %\" wählen — <b>nicht</b> „an Seitengröße anpassen\". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann, setzt den Rand auf 0; sonst 3–5 mm, sonst schneidet der Drucker die Marken weg.",
|
||||||
|
"<b>Its own icon</b> in the browser tab and on the iPhone home screen.": "<b>Ein eigenes Symbol</b> im Browser-Tab und auf dem iPhone-Homebildschirm.",
|
||||||
"<b>Keep editing:</b> simply send a finished image from the bot back to it.": "<b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.",
|
"<b>Keep editing:</b> simply send a finished image from the bot back to it.": "<b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.",
|
||||||
"<b>Keep editing:</b> take a finished image as a new source and edit it again.": "<b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.",
|
"<b>Keep editing:</b> take a finished image as a new source and edit it again.": "<b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.",
|
||||||
"<b>Keep editing</b>: takes a finished image into the studio as a new source — edit it again there, bring it to the next format, or mix it with further images in Combine mode.": "<b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.",
|
"<b>Keep editing</b>: takes a finished image into the studio as a new source — edit it again there, bring it to the next format, or mix it with further images in Combine mode.": "<b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.",
|
||||||
@@ -148,17 +156,18 @@
|
|||||||
"<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.": "<b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann; von dort lässt sich das Bild in voller Auflösung herunterladen.",
|
"<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.": "<b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann; von dort lässt sich das Bild in voller Auflösung herunterladen.",
|
||||||
"<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.": "<b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit <b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann getrennt vom Link — sonst nützt es nichts.",
|
"<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.": "<b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit <b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann getrennt vom Link — sonst nützt es nichts.",
|
||||||
"<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.": "<b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.",
|
"<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.": "<b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.",
|
||||||
|
"<b>TV 16:9 “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.": "<b>TV 16:9 „Erweitern\":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.",
|
||||||
|
"<b>TV 16:9</b> now delivers automatically into the folder <b>“TV-Backgrounds”</b> on the target.": "<b>TV 16:9</b> liefert jetzt automatisch in die Galerie <b>„TV-Backgrounds\"</b>.",
|
||||||
"<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.": "<b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.",
|
"<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.": "<b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.",
|
||||||
"<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.": "<b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild\", Weiterbearbeiten, ganz ohne Befehle.",
|
"<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.": "<b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild\", Weiterbearbeiten, ganz ohne Befehle.",
|
||||||
"<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.": "<b>The Frame „Erweitern\":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.",
|
|
||||||
"<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.": "<b>The Frame</b> liefert jetzt automatisch in die Galerie <b>„TheFrame-Backgrounds\"</b>.",
|
|
||||||
"<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.": "<b>Der Bereich heißt jetzt „Drucken\"</b> (vorher „Passbilder\") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.",
|
"<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.": "<b>Der Bereich heißt jetzt „Drucken\"</b> (vorher „Passbilder\") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.",
|
||||||
"<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)": "<b>Das Auslieferungsziel bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen\" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Weboberfläche des Ziels.)",
|
"<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)": "<b>Das Auslieferungsziel bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen\" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Weboberfläche des Ziels.)",
|
||||||
"<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.": "<b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.",
|
"<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.": "<b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.",
|
||||||
"<b>The new name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.": "<b>Der neue Name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — also Datum, Uhrzeit auf die Sekunde, Motiv, Format und ein kurzes Kürzel. Weil der Zeitstempel vorn steht, sortiert <b>jede Dateiliste von allein chronologisch</b> — am Auslieferungsziel, im Dateimanager, auf dem Spiegel. Das Kürzel stammt aus der Bild-Kennung und ist damit garantiert eindeutig.",
|
"<b>The new name:</b> <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.": "<b>Der neue Name:</b> <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code> — also Datum, Uhrzeit auf die Sekunde, Motiv, Format und ein kurzes Kürzel. Weil der Zeitstempel vorn steht, sortiert <b>jede Dateiliste von allein chronologisch</b> — am Auslieferungsziel, im Dateimanager, auf dem Spiegel. Das Kürzel stammt aus der Bild-Kennung und ist damit garantiert eindeutig.",
|
||||||
"<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.": "<b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes Hochladen mehr.",
|
"<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.": "<b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes Hochladen mehr.",
|
||||||
"<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.": "<b>Uhrzeit in deutscher Zeit</b>, nicht in UTC. Ein Bild von 0:30 Uhr trägt jetzt das richtige Datum und nicht mehr das des Vortags.",
|
"<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.": "<b>Uhrzeit in deutscher Zeit</b>, nicht in UTC. Ein Bild von 0:30 Uhr trägt jetzt das richtige Datum und nicht mehr das des Vortags.",
|
||||||
"<b>Thumbnails</b> for the library (fast & frugal).": "<b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).",
|
"<b>Thumbnails</b> for the library (fast & frugal).": "<b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).",
|
||||||
|
"<b>Try it with a sample photo.</b> An instance with only the print studio used to offer “Pick from the library” — without having a library. Now that place holds a sample photo, so a first visit ends in a printed sheet.": "<b>Mit einem Beispielfoto ausprobieren.</b> Eine Installation nur mit Druckstudio bot bisher „Aus der Bibliothek wählen“ an — ohne eine Bibliothek zu haben. An der Stelle steht jetzt ein Beispielfoto, damit der erste Besuch mit einem fertigen Bogen endet.",
|
||||||
"<b>Where to?</b>: the finished sheet can also be put on the delivery target, on the backup mirror or on an extra target — like the images from the studio.": "<b>Wohin?</b>: der fertige Bogen kann zusätzlich an das Auslieferungsziel, auf den Spiegel oder an ein Zusatzziel gelegt werden — wie die Bilder aus dem Studio.",
|
"<b>Where to?</b>: the finished sheet can also be put on the delivery target, on the backup mirror or on an extra target — like the images from the studio.": "<b>Wohin?</b>: der fertige Bogen kann zusätzlich an das Auslieferungsziel, auf den Spiegel oder an ein Zusatzziel gelegt werden — wie die Bilder aus dem Studio.",
|
||||||
"<b>Without filling up the disk:</b> what is kept is not the PDFs — an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print area computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; otherwise the entry says what is in the way. The last 100 prints are kept.": "<b>Ohne die Platte vollzumachen:</b> Aufgehoben werden nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB — sondern nur die Einstellung, ein paar Kilobyte. Weil der Druckbereich ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. „Nochmal drucken\" gibt es deshalb nur, wenn alle Bilder aus der Bibliothek kamen und noch da sind; sonst steht am Eintrag, woran es liegt. Aufgehoben werden die letzten 100 Drucke.",
|
"<b>Without filling up the disk:</b> what is kept is not the PDFs — an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print area computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; otherwise the entry says what is in the way. The last 100 prints are kept.": "<b>Ohne die Platte vollzumachen:</b> Aufgehoben werden nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB — sondern nur die Einstellung, ein paar Kilobyte. Weil der Druckbereich ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt. „Nochmal drucken\" gibt es deshalb nur, wenn alle Bilder aus der Bibliothek kamen und noch da sind; sonst steht am Eintrag, woran es liegt. Aufgehoben werden die letzten 100 Drucke.",
|
||||||
"<b>“Deliver again”</b> uploads every already delivered image once more under its new name. The old files stay behind on the target — you clear those away there yourself, so that nothing disappears from the client's view by accident.": "<b>„Erneut ausliefern\"</b> lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch. Die alten Dateien bleiben am Ziel liegen — die räumst du am Ziel selbst weg, damit nichts versehentlich beim Kunden verschwindet.",
|
"<b>“Deliver again”</b> uploads every already delivered image once more under its new name. The old files stay behind on the target — you clear those away there yourself, so that nothing disappears from the client's view by accident.": "<b>„Erneut ausliefern\"</b> lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch. Die alten Dateien bleiben am Ziel liegen — die räumst du am Ziel selbst weg, damit nichts versehentlich beim Kunden verschwindet.",
|
||||||
@@ -175,6 +184,7 @@
|
|||||||
"API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).": "API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie\").",
|
"API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).": "API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie\").",
|
||||||
"API token generated.": "API-Token erzeugt.",
|
"API token generated.": "API-Token erzeugt.",
|
||||||
"Access & limits": "Zugang & Grenzen",
|
"Access & limits": "Zugang & Grenzen",
|
||||||
|
"Account": "Konto",
|
||||||
"Add a note": "Dazu schreiben",
|
"Add a note": "Dazu schreiben",
|
||||||
"Add a white sticker border (only together with Cut out).": "Weißen Stickerrand anlegen (nur mit Freistellen).",
|
"Add a white sticker border (only together with Cut out).": "Weißen Stickerrand anlegen (nur mit Freistellen).",
|
||||||
"Add target": "Ziel hinzufügen",
|
"Add target": "Ziel hinzufügen",
|
||||||
@@ -182,6 +192,8 @@
|
|||||||
"Additional target": "Zusätzliches Ziel",
|
"Additional target": "Zusätzliches Ziel",
|
||||||
"Address removed.": "Adresse entfernt.",
|
"Address removed.": "Adresse entfernt.",
|
||||||
"Address saved.": "Adresse gespeichert.",
|
"Address saved.": "Adresse gespeichert.",
|
||||||
|
"Admin": "Admin",
|
||||||
|
"Admin · Klarbild": "Admin · Klarbild",
|
||||||
"Admin → Email": "Admin → E-Mail",
|
"Admin → Email": "Admin → E-Mail",
|
||||||
"Admins always see everything. “Own only”: everyone sees only the images they generated themselves.": "Admins sehen immer alles. „Nur eigene\": jede/r sieht ausschließlich die selbst erzeugten Bilder.",
|
"Admins always see everything. “Own only”: everyone sees only the images they generated themselves.": "Admins sehen immer alles. „Nur eigene\": jede/r sieht ausschließlich die selbst erzeugten Bilder.",
|
||||||
"Again": "Erneut",
|
"Again": "Erneut",
|
||||||
@@ -213,12 +225,14 @@
|
|||||||
"Assign images to this folder from the folder menu.": "Weise Bildern über das Ordner-Menü diesen Ordner zu.",
|
"Assign images to this folder from the folder menu.": "Weise Bildern über das Ordner-Menü diesen Ordner zu.",
|
||||||
"Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.": "Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.",
|
"Automated tests for the geometry plus a raster sample of the finished PDFs at 254 dpi (= 10 px/mm) with edge measurement.": "Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.",
|
||||||
"Automation / MCP access": "Automatisierung / MCP-Zugriff",
|
"Automation / MCP access": "Automatisierung / MCP-Zugriff",
|
||||||
|
"Back to the library": "Zurück zur Bibliothek",
|
||||||
"Back up all existing images to the mirror": "Alle vorhandenen Bilder auf den Sicherungsspiegel sichern",
|
"Back up all existing images to the mirror": "Alle vorhandenen Bilder auf den Sicherungsspiegel sichern",
|
||||||
"Back up all finished images to every backup target (mirror + marked targets)?": "Alle fertigen Bilder auf alle Backup-Ziele (Sicherungsspiegel + markierte Ziele) sichern?",
|
"Back up all finished images to every backup target (mirror + marked targets)?": "Alle fertigen Bilder auf alle Backup-Ziele (Sicherungsspiegel + markierte Ziele) sichern?",
|
||||||
"Back up all finished images to the mirror?": "Alle fertigen Bilder auf den Sicherungsspiegel sichern?",
|
"Back up all finished images to the mirror?": "Alle fertigen Bilder auf den Sicherungsspiegel sichern?",
|
||||||
"Back up all images": "Alle Bilder sichern",
|
"Back up all images": "Alle Bilder sichern",
|
||||||
"Backing up to the mirror … (may take a while)": "Sichere auf den Sicherungsspiegel … (kann dauern)",
|
"Backing up to the mirror … (may take a while)": "Sichere auf den Sicherungsspiegel … (kann dauern)",
|
||||||
"Backing up … (may take a while)": "Sichere … (kann dauern)",
|
"Backing up … (may take a while)": "Sichere … (kann dauern)",
|
||||||
|
"Backup": "Sicherung",
|
||||||
"Backup mirror": "Zweite Sicherung",
|
"Backup mirror": "Zweite Sicherung",
|
||||||
"Backup mirror (second copy)": "Sicherungsspiegel (zweite Sicherung)",
|
"Backup mirror (second copy)": "Sicherungsspiegel (zweite Sicherung)",
|
||||||
"Backup off": "Backup aus",
|
"Backup off": "Backup aus",
|
||||||
@@ -251,6 +265,9 @@
|
|||||||
"Change passphrase": "Wort ändern",
|
"Change passphrase": "Wort ändern",
|
||||||
"Change validity": "Gültigkeit ändern",
|
"Change validity": "Gültigkeit ändern",
|
||||||
"Change validity …": "Gültigkeit ändern …",
|
"Change validity …": "Gültigkeit ändern …",
|
||||||
|
"Changelog": "Änderungen",
|
||||||
|
"Changelog · Klarbild": "Änderungen · Klarbild",
|
||||||
|
"Chat {id}": "Chat {id}",
|
||||||
"Check library": "Bibliothek prüfen",
|
"Check library": "Bibliothek prüfen",
|
||||||
"Checking connection …": "Prüfe Verbindung …",
|
"Checking connection …": "Prüfe Verbindung …",
|
||||||
"Checking file names …": "Prüfe Dateinamen …",
|
"Checking file names …": "Prüfe Dateinamen …",
|
||||||
@@ -262,10 +279,10 @@
|
|||||||
"Choose a way, otherwise the image is left out.": "Wähle einen Weg, sonst bleibt das Bild außen vor.",
|
"Choose a way, otherwise the image is left out.": "Wähle einen Weg, sonst bleibt das Bild außen vor.",
|
||||||
"Choose images": "Bilder wählen",
|
"Choose images": "Bilder wählen",
|
||||||
"Choose sheet": "Bogen wählen",
|
"Choose sheet": "Bogen wählen",
|
||||||
"Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).": "Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da — biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30, Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 und die Seitenverhältnisse (16:9 „The Frame\", 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).",
|
"Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 TV, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).": "Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da — biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30, Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 und die Seitenverhältnisse (16:9 TV 16:9, 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).",
|
||||||
"Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.": "<b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte 9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.",
|
"Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.": "<b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte 9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.",
|
||||||
"Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.": "<b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Auslieferungsziel</b> oder beides) wählen, dann <b>Loslegen</b>.",
|
"Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.": "<b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Auslieferungsziel</b> oder beides) wählen, dann <b>Loslegen</b>.",
|
||||||
"Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).": "Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg), <b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder The Frame), <b>Kontur</b> (weißer Stickerrand).",
|
"Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or a 16:9 TV frame, for instance), <b>Contour</b> (white sticker edge).": "Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg), <b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder TV 16:9), <b>Kontur</b> (weißer Stickerrand).",
|
||||||
"Clean up": "Bereinigen",
|
"Clean up": "Bereinigen",
|
||||||
"Clean up leftovers": "Reste aufräumen",
|
"Clean up leftovers": "Reste aufräumen",
|
||||||
"Clean up screenshots, cut them out, bring them to size.": "Screenshots bereinigen, freistellen, aufs Format bringen.",
|
"Clean up screenshots, cut them out, bring them to size.": "Screenshots bereinigen, freistellen, aufs Format bringen.",
|
||||||
@@ -303,7 +320,6 @@
|
|||||||
"Create link": "Link erzeugen",
|
"Create link": "Link erzeugen",
|
||||||
"Create the PDF and choose “Actual size / 100%” in the print dialog. Done.": "PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.",
|
"Create the PDF and choose “Actual size / 100%” in the print dialog. Done.": "PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.",
|
||||||
"Credit card 85.6 × 54 mm": "Scheckkarte 85,6 × 54 mm",
|
"Credit card 85.6 × 54 mm": "Scheckkarte 85,6 × 54 mm",
|
||||||
"Cricut sheet size (cm)": "Cricut-Bogenmaß (cm)",
|
|
||||||
"Crop": "Zuschneiden",
|
"Crop": "Zuschneiden",
|
||||||
"Crop marks as in prepress": "Schnittmarken wie in der Druckvorstufe",
|
"Crop marks as in prepress": "Schnittmarken wie in der Druckvorstufe",
|
||||||
"Crop · {size}": "Zuschnitt · {size}",
|
"Crop · {size}": "Zuschnitt · {size}",
|
||||||
@@ -314,7 +330,7 @@
|
|||||||
"Custom size (cm)": "Eigenes Maß (cm)",
|
"Custom size (cm)": "Eigenes Maß (cm)",
|
||||||
"Custom size …": "Eigenes Maß …",
|
"Custom size …": "Eigenes Maß …",
|
||||||
"Cut out": "Freistellen",
|
"Cut out": "Freistellen",
|
||||||
"Cut out + The Frame makes no sense — pick one.": "Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.",
|
"Cut out + TV 16:9 makes no sense — pick one.": "Freistellen + TV 16:9 ergibt keinen Sinn — bitte eins wählen.",
|
||||||
"Cutting": "Schneiden",
|
"Cutting": "Schneiden",
|
||||||
"Cutting aids": "Schnitthilfen",
|
"Cutting aids": "Schnitthilfen",
|
||||||
"DIN A0 (84.1 × 118.9 cm)": "DIN A0 (84,1 × 118,9 cm)",
|
"DIN A0 (84.1 × 118.9 cm)": "DIN A0 (84,1 × 118,9 cm)",
|
||||||
@@ -329,6 +345,8 @@
|
|||||||
"DIN A7–A0, photo paper, US sizes, custom size": "DIN A7–A0, Fotopapier, US-Maße, freies Maß",
|
"DIN A7–A0, photo paper, US sizes, custom size": "DIN A7–A0, Fotopapier, US-Maße, freies Maß",
|
||||||
"DL 9.9 × 21 cm": "DIN lang 9,9 × 21 cm",
|
"DL 9.9 × 21 cm": "DIN lang 9,9 × 21 cm",
|
||||||
"DL landscape 21 × 9.9 cm": "DIN lang quer 21 × 9,9 cm",
|
"DL landscape 21 × 9.9 cm": "DIN lang quer 21 × 9,9 cm",
|
||||||
|
"DNS ✓ ({address})": "DNS ✓ ({address})",
|
||||||
|
"DNS ✕ ({error})": "DNS ✕ ({error})",
|
||||||
"Default": "Standard",
|
"Default": "Standard",
|
||||||
"Default delivery target": "Standard-Auslieferungsziel",
|
"Default delivery target": "Standard-Auslieferungsziel",
|
||||||
"Default dpi": "Standard-dpi",
|
"Default dpi": "Standard-dpi",
|
||||||
@@ -339,6 +357,7 @@
|
|||||||
"Delete": "Löschen",
|
"Delete": "Löschen",
|
||||||
"Delete folder": "Ordner löschen",
|
"Delete folder": "Ordner löschen",
|
||||||
"Delete folder? The images are kept.": "Ordner löschen? Die Bilder bleiben erhalten.",
|
"Delete folder? The images are kept.": "Ordner löschen? Die Bilder bleiben erhalten.",
|
||||||
|
"Delete for good": "Endgültig löschen",
|
||||||
"Delete items older than {days} days now?": "Positionen älter als {days} Tage jetzt löschen?",
|
"Delete items older than {days} days now?": "Positionen älter als {days} Tage jetzt löschen?",
|
||||||
"Delete preset": "Vorlage löschen",
|
"Delete preset": "Vorlage löschen",
|
||||||
"Delete preset?": "Vorlage löschen?",
|
"Delete preset?": "Vorlage löschen?",
|
||||||
@@ -347,7 +366,12 @@
|
|||||||
"Delete target?": "Ziel löschen?",
|
"Delete target?": "Ziel löschen?",
|
||||||
"Delete the link \"{name}\" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.": "Den Link „{name}\" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. Nur abschalten reicht meistens.",
|
"Delete the link \"{name}\" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.": "Den Link „{name}\" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. Nur abschalten reicht meistens.",
|
||||||
"Delete the source images of all finished items? Results are kept.": "Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.",
|
"Delete the source images of all finished items? Results are kept.": "Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.",
|
||||||
|
"Delete this image for good? This cannot be undone.": "Dieses Bild endgültig löschen? Das lässt sich nicht rückgängig machen.",
|
||||||
"Delete this job?": "Diesen Auftrag löschen?",
|
"Delete this job?": "Diesen Auftrag löschen?",
|
||||||
|
"Delete {n} images for good? This cannot be undone.": "{n} Bilder endgültig löschen? Das lässt sich nicht rückgängig machen.",
|
||||||
|
"Deleted for good.": "Endgültig gelöscht.",
|
||||||
|
"Deleted images stay here for {days} days and can be restored until then. They are hidden from the library, from every share link and from every delivery.": "Gelöschte Bilder bleiben {days} Tage hier und lassen sich bis dahin wiederherstellen. In der Bibliothek, in Freigabelinks und bei der Auslieferung tauchen sie nicht mehr auf.",
|
||||||
|
"Deleted images wait this long in the trash and can be restored until then. 0 = no trash, a delete is final.": "So lange bleiben gelöschte Bilder im Papierkorb und lassen sich bis dahin wiederherstellen. 0 = kein Papierkorb, Löschen ist endgültig.",
|
||||||
"Deleted.": "Gelöscht.",
|
"Deleted.": "Gelöscht.",
|
||||||
"Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.": "Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder auf den Sicherungsspiegel sichern\".",
|
"Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.": "Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder auf den Sicherungsspiegel sichern\".",
|
||||||
"Deliver": "Ausliefern",
|
"Deliver": "Ausliefern",
|
||||||
@@ -378,6 +402,8 @@
|
|||||||
"Email address and notifications.": "E-Mail-Adresse und Benachrichtigungen.",
|
"Email address and notifications.": "E-Mail-Adresse und Benachrichtigungen.",
|
||||||
"Empty = everyone with the link gets in": "Leer = jeder mit dem Link kommt rein",
|
"Empty = everyone with the link gets in": "Leer = jeder mit dem Link kommt rein",
|
||||||
"Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.": "Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.",
|
"Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.": "Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.",
|
||||||
|
"Empty the trash": "Papierkorb leeren",
|
||||||
|
"Empty the trash? {n} images will be deleted for good.": "Papierkorb leeren? {n} Bilder werden endgültig gelöscht.",
|
||||||
"Enable the backup mirror and save first.": "Sicherungsspiegel erst aktivieren & speichern.",
|
"Enable the backup mirror and save first.": "Sicherungsspiegel erst aktivieren & speichern.",
|
||||||
"Enter a host and save first.": "Erst Host eintragen und speichern.",
|
"Enter a host and save first.": "Erst Host eintragen und speichern.",
|
||||||
"Enter a new key to replace it": "Neuen Key eingeben zum Wechseln",
|
"Enter a new key to replace it": "Neuen Key eingeben zum Wechseln",
|
||||||
@@ -416,10 +442,11 @@
|
|||||||
"For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.": "Für Profis gibt es zusätzlich das „/\"-Befehlsmenü (/new, /presets, /status) — nötig ist es nicht.",
|
"For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.": "Für Profis gibt es zusätzlich das „/\"-Befehlsmenü (/new, /presets, /status) — nötig ist es nicht.",
|
||||||
"For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.": "Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.",
|
"For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.": "Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.",
|
||||||
"For that you first need an email address (above).": "Dafür brauchst du zuerst eine E-Mail-Adresse (oben).",
|
"For that you first need an email address (above).": "Dafür brauchst du zuerst eine E-Mail-Adresse (oben).",
|
||||||
"For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.": "Beim Format „<b>Zuschneiden</b>\" oder „<b>Erweitern</b>\" wählen — Erweitern erzeugt mehr Szene rund ums Motiv (Outpainting), ideal für The Frame.",
|
"For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for a wide TV frame.": "Beim Format „<b>Zuschneiden</b>\" oder „<b>Erweitern</b>\" wählen — Erweitern erzeugt mehr Szene rund ums Motiv (Outpainting), ideal für TV 16:9.",
|
||||||
"Force private": "Privat erzwingen",
|
"Force private": "Privat erzwingen",
|
||||||
"Forgot your password": "Passwort vergessen",
|
"Forgot your password": "Passwort vergessen",
|
||||||
"Forgot your password?": "Passwort vergessen?",
|
"Forgot your password?": "Passwort vergessen?",
|
||||||
|
"Format (optional)": "Format (optional)",
|
||||||
"Format of {name}": "Format von {name}",
|
"Format of {name}": "Format von {name}",
|
||||||
"From the library": "Aus Bibliothek",
|
"From the library": "Aus Bibliothek",
|
||||||
"Gap automatic ({gap} mm — to suit the marks)": "Abstand automatisch ({gap} mm — passend zu den Marken)",
|
"Gap automatic ({gap} mm — to suit the marks)": "Abstand automatisch ({gap} mm — passend zu den Marken)",
|
||||||
@@ -434,14 +461,16 @@
|
|||||||
"Generate preview images": "Vorschaubilder erzeugen",
|
"Generate preview images": "Vorschaubilder erzeugen",
|
||||||
"Generate token": "Token erzeugen",
|
"Generate token": "Token erzeugen",
|
||||||
"Generating an alternative …": "Erzeuge Alternative …",
|
"Generating an alternative …": "Erzeuge Alternative …",
|
||||||
"Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. Copies already placed on the delivery target keep their old name; that is what “Deliver again” is for afterwards.": "Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. Bereits am Auslieferungsziel abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern\".",
|
"Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. ": "Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. Bereits am Auslieferungsziel abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern\".",
|
||||||
"Gives exactly {w} × {h} pixels at 300 dpi — ready to print.": "Ergibt exakt {w} × {h} Pixel bei 300 dpi — druckfertig.",
|
"Gives exactly {w} × {h} pixels at 300 dpi — ready to print.": "Ergibt exakt {w} × {h} Pixel bei 300 dpi — druckfertig.",
|
||||||
"Glue flap (mm)": "Klebefalz (mm)",
|
"Glue flap (mm)": "Klebefalz (mm)",
|
||||||
"Grab the image and drag · double-tap zooms · arrow keys for fine work.": "Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit.",
|
"Grab the image and drag · double-tap zooms · arrow keys for fine work.": "Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit.",
|
||||||
"Green": "Grün",
|
"Green": "Grün",
|
||||||
"Heading": "Überschrift",
|
"Heading": "Überschrift",
|
||||||
|
"Help": "Hilfe",
|
||||||
"Help · Klarbild": "Anleitung · Klarbild",
|
"Help · Klarbild": "Anleitung · Klarbild",
|
||||||
"Hide visits": "Zugriffe zu",
|
"Hide visits": "Zugriffe zu",
|
||||||
|
"Host": "Host",
|
||||||
"How Klarbild works": "So funktioniert Klarbild",
|
"How Klarbild works": "So funktioniert Klarbild",
|
||||||
"If the aspect ratio does not fit, you decide: crop or letterbox. In the crop window you move and zoom the original inside the target frame — with the mouse, a finger, a double-tap or the arrow keys. The crop is stored relatively and survives any resolution.": "Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.",
|
"If the aspect ratio does not fit, you decide: crop or letterbox. In the crop window you move and zoom the original inside the target frame — with the mouse, a finger, a double-tap or the arrow keys. The crop is stored relatively and survives any resolution.": "Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.",
|
||||||
"If there is an account for this address, the message is on its way. Have a look in the spam folder too.": "Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.",
|
"If there is an account for this address, the message is on its way. Have a look in the spam folder too.": "Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.",
|
||||||
@@ -491,6 +520,7 @@
|
|||||||
"Links that let other people look at your images — without an account. Here you can see how often they were opened, and switch them off whenever you want.": "Links, mit denen andere deine Bilder ansehen können — ohne Konto. Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.",
|
"Links that let other people look at your images — without an account. Here you can see how often they were opened, and switch them off whenever you want.": "Links, mit denen andere deine Bilder ansehen können — ohne Konto. Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.",
|
||||||
"Loading …": "Wird geladen …",
|
"Loading …": "Wird geladen …",
|
||||||
"Mailbox password": "Postfach-Passwort",
|
"Mailbox password": "Postfach-Passwort",
|
||||||
|
"Main navigation": "Hauptnavigation",
|
||||||
"Make the print PDF": "Druck-PDF erzeugen",
|
"Make the print PDF": "Druck-PDF erzeugen",
|
||||||
"Manage all links": "Alle Links verwalten",
|
"Manage all links": "Alle Links verwalten",
|
||||||
"Margin to the paper edge (mm)": "Rand zum Papier (mm)",
|
"Margin to the paper edge (mm)": "Rand zum Papier (mm)",
|
||||||
@@ -509,11 +539,14 @@
|
|||||||
"Monthly budget ($)": "Monatliches Budget ($)",
|
"Monthly budget ($)": "Monatliches Budget ($)",
|
||||||
"More output targets (FTP/SFTP)": "Weitere Ausgabeziele (FTP/SFTP)",
|
"More output targets (FTP/SFTP)": "Weitere Ausgabeziele (FTP/SFTP)",
|
||||||
"More settings": "Mehr einstellen",
|
"More settings": "Mehr einstellen",
|
||||||
|
"Move to the trash": "In den Papierkorb",
|
||||||
"Moved into the folder.": "In Ordner verschoben.",
|
"Moved into the folder.": "In Ordner verschoben.",
|
||||||
|
"Moved to the trash.": "In den Papierkorb gelegt.",
|
||||||
"My account": "Mein Konto",
|
"My account": "Mein Konto",
|
||||||
"My account · Klarbild": "Mein Konto · Klarbild",
|
"My account · Klarbild": "Mein Konto · Klarbild",
|
||||||
"My account, sharing & email": "Mein Konto, Teilen & E-Mail",
|
"My account, sharing & email": "Mein Konto, Teilen & E-Mail",
|
||||||
"NSFW off": "NSFW aus",
|
"NSFW off": "NSFW aus",
|
||||||
|
"Name": "Name",
|
||||||
"Name is missing.": "Name fehlt.",
|
"Name is missing.": "Name fehlt.",
|
||||||
"Name of the preset?": "Name des Presets?",
|
"Name of the preset?": "Name des Presets?",
|
||||||
"Name of the preset? (e.g. “Nursery set”)": "Name der Vorlage? (z. B. „Kita-Satz\")",
|
"Name of the preset? (e.g. “Nursery set”)": "Name der Vorlage? (z. B. „Kita-Satz\")",
|
||||||
@@ -537,6 +570,7 @@
|
|||||||
"No mail sending is set up on this server yet — the address is stored, but nothing arrives yet. An admin sets that up under Admin → Email.": "Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter Admin → E-Mail ein.",
|
"No mail sending is set up on this server yet — the address is stored, but nothing arrives yet. An admin sets that up under Admin → Email.": "Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter Admin → E-Mail ein.",
|
||||||
"No models yet. Add one from the available ones:": "Noch keine Modelle. Aus verfügbaren hinzufügen:",
|
"No models yet. Add one from the available ones:": "Noch keine Modelle. Aus verfügbaren hinzufügen:",
|
||||||
"No paired chats yet.": "Noch keine gekoppelten Chats.",
|
"No paired chats yet.": "Noch keine gekoppelten Chats.",
|
||||||
|
"No picture at hand? Take ours, pick a preset below and make the PDF.": "Gerade kein Bild zur Hand? Nimm unseres, wähl unten eine Vorlage und mach das PDF.",
|
||||||
"No sending set up yet — forgotten password, job notices and mailing share links do not work until then.": "Noch kein Versand eingerichtet — Passwort vergessen, Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.",
|
"No sending set up yet — forgotten password, job notices and mailing share links do not work until then.": "Noch kein Versand eingerichtet — Passwort vergessen, Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.",
|
||||||
"No token generated yet.": "Noch kein Token erzeugt.",
|
"No token generated yet.": "Noch kein Token erzeugt.",
|
||||||
"No valid image size chosen yet.": "Noch kein gültiges Bildmaß gewählt.",
|
"No valid image size chosen yet.": "Noch kein gültiges Bildmaß gewählt.",
|
||||||
@@ -588,6 +622,7 @@
|
|||||||
"Own border colour": "Eigene Randfarbe",
|
"Own border colour": "Eigene Randfarbe",
|
||||||
"Own instruction (optional)": "Eigene Anweisung (optional)",
|
"Own instruction (optional)": "Eigene Anweisung (optional)",
|
||||||
"Own only": "Nur eigene",
|
"Own only": "Nur eigene",
|
||||||
|
"PDF": "PDF",
|
||||||
"PDF at original size, 72–1200 dpi": "PDF in Originalgröße, 72–1200 dpi",
|
"PDF at original size, 72–1200 dpi": "PDF in Originalgröße, 72–1200 dpi",
|
||||||
"PDF generated again. When printing, choose “Actual size / 100 %”.": "PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
|
"PDF generated again. When printing, choose “Actual size / 100 %”.": "PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
|
||||||
"PDF ready · {sheets}. When printing, choose “Actual size / 100 %”.": "PDF fertig · {sheets}. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
|
"PDF ready · {sheets}. When printing, choose “Actual size / 100 %”.": "PDF fertig · {sheets}. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
|
||||||
@@ -634,10 +669,12 @@
|
|||||||
"Please give a valid paper size.": "Bitte ein gültiges Papiermaß angeben.",
|
"Please give a valid paper size.": "Bitte ein gültiges Papiermaß angeben.",
|
||||||
"Polaroid · image area 79 × 79 mm": "Polaroid · Bildfläche 79 × 79 mm",
|
"Polaroid · image area 79 × 79 mm": "Polaroid · Bildfläche 79 × 79 mm",
|
||||||
"Polaroid · whole card 88 × 107 mm": "Polaroid · ganze Karte 88 × 107 mm",
|
"Polaroid · whole card 88 × 107 mm": "Polaroid · ganze Karte 88 × 107 mm",
|
||||||
|
"Port": "Port",
|
||||||
"Port reachable in {ms}ms ✓": "Port erreichbar in {ms}ms ✓",
|
"Port reachable in {ms}ms ✓": "Port erreichbar in {ms}ms ✓",
|
||||||
"Portrait": "Hochformat",
|
"Portrait": "Hochformat",
|
||||||
"Portrait (9:16)": "Hochformat (9:16)",
|
"Portrait (9:16)": "Hochformat (9:16)",
|
||||||
"Postcard 14.8 × 10.5 cm (A6 landscape)": "Postkarte 14,8 × 10,5 cm (A6 quer)",
|
"Postcard 14.8 × 10.5 cm (A6 landscape)": "Postkarte 14,8 × 10,5 cm (A6 quer)",
|
||||||
|
"Poster": "Poster",
|
||||||
"Poster & frames": "Poster & Rahmen",
|
"Poster & frames": "Poster & Rahmen",
|
||||||
"Preset deleted.": "Preset gelöscht.",
|
"Preset deleted.": "Preset gelöscht.",
|
||||||
"Preset loaded — the image was taken over for every format.": "Vorlage geladen — das Bild wurde für jedes Format übernommen.",
|
"Preset loaded — the image was taken over for every format.": "Vorlage geladen — das Bild wurde für jedes Format übernommen.",
|
||||||
@@ -654,6 +691,7 @@
|
|||||||
"Printed last": "Zuletzt gedruckt",
|
"Printed last": "Zuletzt gedruckt",
|
||||||
"Private off": "Privat aus",
|
"Private off": "Privat aus",
|
||||||
"Private session": "Private Session",
|
"Private session": "Private Session",
|
||||||
|
"Prompt": "Prompt",
|
||||||
"Prompt copied.": "Prompt kopiert.",
|
"Prompt copied.": "Prompt kopiert.",
|
||||||
"Proof": "Nachweis",
|
"Proof": "Nachweis",
|
||||||
"Protected with a passphrase": "Mit Zugangswort geschützt",
|
"Protected with a passphrase": "Mit Zugangswort geschützt",
|
||||||
@@ -670,6 +708,7 @@
|
|||||||
"Remove frame, wall and shop interface.": "Rahmen, Wand und Shop-Oberfläche entfernen.",
|
"Remove frame, wall and shop interface.": "Rahmen, Wand und Shop-Oberfläche entfernen.",
|
||||||
"Remove {images}?": "{images} entfernen?",
|
"Remove {images}?": "{images} entfernen?",
|
||||||
"Remove {name}": "{name} entfernen",
|
"Remove {name}": "{name} entfernen",
|
||||||
|
"Removed at the next cleanup": "Wird beim nächsten Aufräumen entfernt",
|
||||||
"Removed from the folder.": "Aus Ordner entfernt.",
|
"Removed from the folder.": "Aus Ordner entfernt.",
|
||||||
"Rename folder": "Ordner umbenennen",
|
"Rename folder": "Ordner umbenennen",
|
||||||
"Rename folder:": "Ordner umbenennen:",
|
"Rename folder:": "Ordner umbenennen:",
|
||||||
@@ -680,6 +719,9 @@
|
|||||||
"Reset library & jobs": "Bibliothek & Aufträge zurücksetzen",
|
"Reset library & jobs": "Bibliothek & Aufträge zurücksetzen",
|
||||||
"Resetting …": "Setze zurück …",
|
"Resetting …": "Setze zurück …",
|
||||||
"Resolution (dpi)": "Auflösung (dpi)",
|
"Resolution (dpi)": "Auflösung (dpi)",
|
||||||
|
"Restore": "Wiederherstellen",
|
||||||
|
"Restore all": "Alle wiederherstellen",
|
||||||
|
"Restored.": "Wiederhergestellt.",
|
||||||
"Results land under base folder / YYYY-MM /. Credentials are stored encrypted.": "Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.",
|
"Results land under base folder / YYYY-MM /. Credentials are stored encrypted.": "Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.",
|
||||||
"Resume": "Fortsetzen",
|
"Resume": "Fortsetzen",
|
||||||
"Retention (days)": "Aufbewahrung (Tage)",
|
"Retention (days)": "Aufbewahrung (Tage)",
|
||||||
@@ -687,6 +729,7 @@
|
|||||||
"Rotate to save space": "Zum Platzsparen drehen",
|
"Rotate to save space": "Zum Platzsparen drehen",
|
||||||
"Run it yourself": "Selbst betreiben",
|
"Run it yourself": "Selbst betreiben",
|
||||||
"Run “Check library” first — then you can see what would change.": "Erst „Bibliothek prüfen\" — dann siehst du, was sich ändern würde.",
|
"Run “Check library” first — then you can see what would change.": "Erst „Bibliothek prüfen\" — dann siehst du, was sich ändern würde.",
|
||||||
|
"Running": "Läuft",
|
||||||
"Save": "Speichern",
|
"Save": "Speichern",
|
||||||
"Save as PDF / print": "Als PDF / drucken",
|
"Save as PDF / print": "Als PDF / drucken",
|
||||||
"Save password": "Passwort speichern",
|
"Save password": "Passwort speichern",
|
||||||
@@ -731,6 +774,7 @@
|
|||||||
"Share links": "Teilen-Links",
|
"Share links": "Teilen-Links",
|
||||||
"Share links are only valid for a limited time. Whoever sent it to you can create a new one.": "Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.",
|
"Share links are only valid for a limited time. Whoever sent it to you can create a new one.": "Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.",
|
||||||
"Share {n} images": "{n} Bilder teilen",
|
"Share {n} images": "{n} Bilder teilen",
|
||||||
|
"Shared": "Geteilt",
|
||||||
"Shared links": "Geteilte Links",
|
"Shared links": "Geteilte Links",
|
||||||
"Shared links · Klarbild": "Geteilte Links · Klarbild",
|
"Shared links · Klarbild": "Geteilte Links · Klarbild",
|
||||||
"Sharing images, email, forgotten passwords": "Bilder teilen, E-Mail, Passwort vergessen",
|
"Sharing images, email, forgotten passwords": "Bilder teilen, E-Mail, Passwort vergessen",
|
||||||
@@ -743,6 +787,7 @@
|
|||||||
"Shown as": "Angezeigt als",
|
"Shown as": "Angezeigt als",
|
||||||
"Sign in": "Anmelden",
|
"Sign in": "Anmelden",
|
||||||
"Sign in · Klarbild": "Anmelden · Klarbild",
|
"Sign in · Klarbild": "Anmelden · Klarbild",
|
||||||
|
"Sign out": "Abmelden",
|
||||||
"Sign-in failed.": "Anmeldung fehlgeschlagen.",
|
"Sign-in failed.": "Anmeldung fehlgeschlagen.",
|
||||||
"Size not recognised. Examples: “13x18” (cm), “35x45mm”, “4:3/15”.": "Maß nicht erkannt. Beispiele: „13x18\" (cm), „35x45mm\", „4:3/15\".",
|
"Size not recognised. Examples: “13x18” (cm), “35x45mm”, “4:3/15”.": "Maß nicht erkannt. Beispiele: „13x18\" (cm), „35x45mm\", „4:3/15\".",
|
||||||
"Size not recognised. Examples: “21x29.7”, “329x483mm”.": "Maß nicht erkannt. Beispiele: „21x29,7\", „329x483mm\".",
|
"Size not recognised. Examples: “21x29.7”, “329x483mm”.": "Maß nicht erkannt. Beispiele: „21x29,7\", „329x483mm\".",
|
||||||
@@ -766,6 +811,8 @@
|
|||||||
"Stickers: choose Cut out + Contour together.": "Sticker: Freistellen + Kontur zusammen wählen.",
|
"Stickers: choose Cut out + Contour together.": "Sticker: Freistellen + Kontur zusammen wählen.",
|
||||||
"Storage & cleanup": "Speicher & Aufräumen",
|
"Storage & cleanup": "Speicher & Aufräumen",
|
||||||
"Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.",
|
"Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.",
|
||||||
|
"Studio": "Studio",
|
||||||
|
"Studio · Klarbild": "Studio · Klarbild",
|
||||||
"Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.",
|
"Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.",
|
||||||
"Switch back on": "Wieder freigeben",
|
"Switch back on": "Wieder freigeben",
|
||||||
"Switch off": "Abschalten",
|
"Switch off": "Abschalten",
|
||||||
@@ -778,6 +825,7 @@
|
|||||||
"Target created.": "Ziel angelegt.",
|
"Target created.": "Ziel angelegt.",
|
||||||
"Telegram bot": "Telegram-Bot",
|
"Telegram bot": "Telegram-Bot",
|
||||||
"Tell me when a job is finished": "Sag mir Bescheid, wenn ein Auftrag fertig ist",
|
"Tell me when a job is finished": "Sag mir Bescheid, wenn ein Auftrag fertig ist",
|
||||||
|
"Test": "Testen",
|
||||||
"Test connection": "Verbindung testen",
|
"Test connection": "Verbindung testen",
|
||||||
"Test message": "Testnachricht",
|
"Test message": "Testnachricht",
|
||||||
"Test message to": "Testnachricht an",
|
"Test message to": "Testnachricht an",
|
||||||
@@ -808,7 +856,10 @@
|
|||||||
"The password is stored encrypted and never shown again.": "Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.",
|
"The password is stored encrypted and never shown again.": "Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.",
|
||||||
"The problem": "Das Problem",
|
"The problem": "Das Problem",
|
||||||
"The same image once more — for a second format, say": "Dasselbe Bild noch einmal — z. B. für ein zweites Format",
|
"The same image once more — for a second format, say": "Dasselbe Bild noch einmal — z. B. für ein zweites Format",
|
||||||
|
"The sample photo could not be loaded.": "Das Beispielfoto ließ sich nicht laden.",
|
||||||
"The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.": "Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.",
|
"The second way out for large sizes: the image is spread across several sheets. The tiles overlap by a glue flap, which is printed on both neighbouring sheets and marked with a dashed line. Every sheet carries its number with row and column.": "Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.",
|
||||||
|
"The trash is empty": "Der Papierkorb ist leer",
|
||||||
|
"The trash is switched off — deleting is final.": "Der Papierkorb ist abgeschaltet — Löschen ist endgültig.",
|
||||||
"The two entries do not match.": "Die beiden Eingaben stimmen nicht überein.",
|
"The two entries do not match.": "Die beiden Eingaben stimmen nicht überein.",
|
||||||
"The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.": "Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in 100 % Größe auf die Seite passt, ist schlicht Verschwendung.",
|
"The usual route runs through a layout program or through the printer driver, and both of them mean too well: “fit to page”, “scale to fit”, “scale borderless”. What comes out is a print that is almost right. For a passport photo, “almost” is too little, and using a whole A4 sheet for a 4 × 3 snapshot, only because it would otherwise not fit on the page at 100% size, is plain waste.": "Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein 4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in 100 % Größe auf die Seite passt, ist schlicht Verschwendung.",
|
||||||
"The whole image stays visible, with a border around it": "Ganzes Bild bleibt sichtbar, außen entsteht ein Rand",
|
"The whole image stays visible, with a border around it": "Ganzes Bild bleibt sichtbar, außen entsteht ein Rand",
|
||||||
@@ -830,16 +881,21 @@
|
|||||||
"Today ${today} · 7 days ${week}": "Heute ${today} · 7 Tage ${week}",
|
"Today ${today} · 7 days ${week}": "Heute ${today} · 7 Tage ${week}",
|
||||||
"Too large": "Zu groß",
|
"Too large": "Zu groß",
|
||||||
"Too many attempts. Please try again in a quarter of an hour.": "Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.",
|
"Too many attempts. Please try again in a quarter of an hour.": "Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.",
|
||||||
|
"Trash (days)": "Papierkorb (Tage)",
|
||||||
|
"Trash emptied.": "Papierkorb geleert.",
|
||||||
|
"Trash · {n}": "Papierkorb · {n}",
|
||||||
"True to size": "Maßhaltig",
|
"True to size": "Maßhaltig",
|
||||||
"Try again": "Noch einmal versuchen",
|
"Try again": "Noch einmal versuchen",
|
||||||
|
"Try it with a sample photo": "Mit einem Beispielfoto ausprobieren",
|
||||||
"US visa 51 × 51 mm (2 × 2 in)": "Visum USA 51 × 51 mm (2 × 2 in)",
|
"US visa 51 × 51 mm (2 × 2 in)": "Visum USA 51 × 51 mm (2 × 2 in)",
|
||||||
"Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.": "Unter <b>„Dateinamen\"</b> lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen. <b>„Bibliothek prüfen\"</b> zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben sind; <b>„Jetzt umbenennen\"</b> setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr. <b>„Erneut ausliefern\"</b> lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch; die alten Dateien bleiben am Ziel liegen und räumst du dort selbst weg.",
|
"Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.": "Unter <b>„Dateinamen\"</b> lässt sich die vorhandene Bibliothek auf das eindeutige Namensschema bringen. <b>„Bibliothek prüfen\"</b> zeigt zuerst, was sich ändern würde und wie viele Namen aktuell doppelt vergeben sind; <b>„Jetzt umbenennen\"</b> setzt es um. Die Bilddateien selbst bleiben dabei unangetastet — sie liegen im Speicher unter ihrer Kennung, nicht unter ihrem Namen. Ein zweiter Lauf ändert nichts mehr. <b>„Erneut ausliefern\"</b> lädt danach alle schon ausgelieferten Bilder unter dem neuen Namen erneut hoch; die alten Dateien bleiben am Ziel liegen und räumst du dort selbst weg.",
|
||||||
"Under the hood": "Unter der Haube",
|
"Under the hood": "Unter der Haube",
|
||||||
"Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.": "Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose für den Spiegel.",
|
"Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.": "Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose für den Spiegel.",
|
||||||
|
"Undo": "Rückgängig",
|
||||||
"Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr",
|
"Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr",
|
||||||
"Unit": "Einheit",
|
"Unit": "Einheit",
|
||||||
"Unlink": "Trennen",
|
"Unlink": "Trennen",
|
||||||
"Upload all already delivered images again — under the new name?\n\nThe old files stay on the target and have to be removed there by hand. This can take a while.": "Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\nDie alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.",
|
"Upload all already delivered images again — under the new name?\n\n": "Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\nDie alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.",
|
||||||
"Upload failed": "Upload fehlgeschlagen",
|
"Upload failed": "Upload fehlgeschlagen",
|
||||||
"Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.": "Bilder hochladen (ziehen, ⌘/Strg + V) — oder <b>Aus der Bibliothek wählen</b>: alles, was Klarbild schon erzeugt hat, mit Suche, nach Ordner und mehrere auf einmal. Kein Umweg mehr über einen erneuten Upload.",
|
"Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.": "Bilder hochladen (ziehen, ⌘/Strg + V) — oder <b>Aus der Bibliothek wählen</b>: alles, was Klarbild schon erzeugt hat, mit Suche, nach Ordner und mehrere auf einmal. Kein Umweg mehr über einen erneuten Upload.",
|
||||||
"Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).": "Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).",
|
"Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).": "Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).",
|
||||||
@@ -869,6 +925,7 @@
|
|||||||
"When fitting in, the border colour stays around the outside.": "Beim Einpassen bleibt außen die Randfarbe stehen.",
|
"When fitting in, the border colour stays around the outside.": "Beim Einpassen bleibt außen die Randfarbe stehen.",
|
||||||
"When it is reached the queue stops.": "Bei Erreichen hält die Warteschlange an.",
|
"When it is reached the queue stops.": "Bei Erreichen hält die Warteschlange an.",
|
||||||
"Where to?": "Wohin?",
|
"Where to?": "Wohin?",
|
||||||
|
"While an image is in the trash it is gone from <b>share links</b> too — and comes back there when you restore it. An image deleted while it is still waiting in the queue is not generated at all, so it costs nothing.": "Solange ein Bild im Papierkorb liegt, ist es auch aus <b>geteilten Links</b> verschwunden — und kommt beim Wiederherstellen dorthin zurück. Ein Bild, das noch in der Warteschlange wartet, wird gar nicht erst erzeugt und kostet nichts.",
|
||||||
"Who generated an image is hidden from non-admins.": "Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.",
|
"Who generated an image is hidden from non-admins.": "Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.",
|
||||||
"Who sees which generations?": "Wer sieht welche Generierungen?",
|
"Who sees which generations?": "Wer sieht welche Generierungen?",
|
||||||
"Whoever sent you this link can see <b>when</b> it was opened and with what kind of device. Not <b>who</b> opened it: only an unrecognisable number is stored for your internet address, and that is deleted after 90 days.": "Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet wurde. Nicht, <b>wer</b> das war: Von deiner Internet-Adresse wird nur ein unkenntlicher Zahlenwert gespeichert, und der wird nach 90 Tagen gelöscht.",
|
"Whoever sent you this link can see <b>when</b> it was opened and with what kind of device. Not <b>who</b> opened it: only an unrecognisable number is stored for your internet address, and that is deleted after 90 days.": "Wer dir diesen Link geschickt hat, sieht, <b>wann</b> und mit welcher Art Gerät er geöffnet wurde. Nicht, <b>wer</b> das war: Von deiner Internet-Adresse wird nur ein unkenntlicher Zahlenwert gespeichert, und der wird nach 90 Tagen gelöscht.",
|
||||||
@@ -914,7 +971,7 @@
|
|||||||
"e.g. 5 (=5×5 cm) or 5x7": "z. B. 5 (=5×5 cm) oder 5x7",
|
"e.g. 5 (=5×5 cm) or 5x7": "z. B. 5 (=5×5 cm) oder 5x7",
|
||||||
"e.g. Ada in the garden": "z. B. Ada im Garten",
|
"e.g. Ada in the garden": "z. B. Ada im Garten",
|
||||||
"e.g. Nursery photos August": "z. B. Kita-Fotos August",
|
"e.g. Nursery photos August": "z. B. Kita-Fotos August",
|
||||||
"e.g. The Frame FTP": "z. B. The Frame FTP",
|
"e.g. Studio archive": "z. B. TV 16:9 FTP",
|
||||||
"e.g. mirror.local or DDNS": "z. B. spiegel.local oder DDNS",
|
"e.g. mirror.local or DDNS": "z. B. spiegel.local oder DDNS",
|
||||||
"e.g. smtp.example.com": "z. B. smtp.example.com",
|
"e.g. smtp.example.com": "z. B. smtp.example.com",
|
||||||
"e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”": "z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“",
|
"e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”": "z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“",
|
||||||
@@ -940,6 +997,7 @@
|
|||||||
"off": "aus",
|
"off": "aus",
|
||||||
"only {dpi} dpi — goes slightly soft": "nur {dpi} dpi — wird leicht weich",
|
"only {dpi} dpi — goes slightly soft": "nur {dpi} dpi — wird leicht weich",
|
||||||
"only {dpi} dpi — goes visibly blurred, take a smaller format or a bigger image": "nur {dpi} dpi — wird sichtbar unscharf, kleineres Format oder größeres Bild nehmen",
|
"only {dpi} dpi — goes visibly blurred, take a smaller format or a bigger image": "nur {dpi} dpi — wird sichtbar unscharf, kleineres Format oder größeres Bild nehmen",
|
||||||
|
"optional": "optional",
|
||||||
"or": "oder",
|
"or": "oder",
|
||||||
"or send it straight from here": "oder direkt von hier verschicken",
|
"or send it straight from here": "oder direkt von hier verschicken",
|
||||||
"own text": "eigener Text",
|
"own text": "eigener Text",
|
||||||
@@ -965,6 +1023,7 @@
|
|||||||
"top centre": "oben mittig",
|
"top centre": "oben mittig",
|
||||||
"top left": "oben links",
|
"top left": "oben links",
|
||||||
"top right": "oben rechts",
|
"top right": "oben rechts",
|
||||||
|
"transparent": "transparent",
|
||||||
"valid until {date}": "gültig bis {date}",
|
"valid until {date}": "gültig bis {date}",
|
||||||
"view only": "nur ansehen",
|
"view only": "nur ansehen",
|
||||||
"views": "Aufrufe",
|
"views": "Aufrufe",
|
||||||
@@ -988,6 +1047,8 @@
|
|||||||
"{images} on this sheet": "{images} auf diesem Bogen",
|
"{images} on this sheet": "{images} auf diesem Bogen",
|
||||||
"{n} copies to backup targets, {failed} errors.": "{n} Kopien auf Backup-Ziele, {failed} Fehler.",
|
"{n} copies to backup targets, {failed} errors.": "{n} Kopien auf Backup-Ziele, {failed} Fehler.",
|
||||||
"{n} copies to backup targets.": "{n} Kopien auf Backup-Ziele.",
|
"{n} copies to backup targets.": "{n} Kopien auf Backup-Ziele.",
|
||||||
|
"{n} days left": "Noch {n} Tage",
|
||||||
|
"{n} deleted.": "{n} gelöscht.",
|
||||||
"{n} entries in {path}": "{n} Einträge in {path}",
|
"{n} entries in {path}": "{n} Einträge in {path}",
|
||||||
"{n} extra sheet": "{n} Extrabogen",
|
"{n} extra sheet": "{n} Extrabogen",
|
||||||
"{n} extra sheets": "{n} Extrabogen",
|
"{n} extra sheets": "{n} Extrabogen",
|
||||||
@@ -1002,6 +1063,7 @@
|
|||||||
"{n} images renamed, {ok} were already fine.": "{n} Bilder umbenannt, {ok} waren schon in Ordnung.",
|
"{n} images renamed, {ok} were already fine.": "{n} Bilder umbenannt, {ok} waren schon in Ordnung.",
|
||||||
"{n} leftovers removed from “{folder}”.": "{n} Reste in „{folder}\" entfernt.",
|
"{n} leftovers removed from “{folder}”.": "{n} Reste in „{folder}\" entfernt.",
|
||||||
"{n} min ago": "vor {n} min",
|
"{n} min ago": "vor {n} min",
|
||||||
|
"{n} moved to the trash.": "{n} in den Papierkorb gelegt.",
|
||||||
"{n} names were used twice": "{n} Namen waren doppelt vergeben",
|
"{n} names were used twice": "{n} Namen waren doppelt vergeben",
|
||||||
"{n} of {checked} images would get a new name — {dupes} names are currently used twice.": "{n} von {checked} Bildern bekämen einen neuen Namen — {dupes} Namen sind aktuell doppelt vergeben.",
|
"{n} of {checked} images would get a new name — {dupes} names are currently used twice.": "{n} von {checked} Bildern bekämen einen neuen Namen — {dupes} Namen sind aktuell doppelt vergeben.",
|
||||||
"{n} of {checked} images would get a new name.": "{n} von {checked} Bildern bekämen einen neuen Namen.",
|
"{n} of {checked} images would get a new name.": "{n} von {checked} Bildern bekämen einen neuen Namen.",
|
||||||
@@ -1025,9 +1087,10 @@
|
|||||||
"{sizes} on a usable {usable}.": "{sizes} bei nutzbaren {usable}.",
|
"{sizes} on a usable {usable}.": "{sizes} bei nutzbaren {usable}.",
|
||||||
"{size} = {w} × {h} px": "{size} = {w} × {h} px",
|
"{size} = {w} × {h} px": "{size} = {w} × {h} px",
|
||||||
"{size} does not fit on {paper}.": "{size} passt nicht auf {paper}.",
|
"{size} does not fit on {paper}.": "{size} passt nicht auf {paper}.",
|
||||||
|
"{title} · Klarbild": "{title} · Klarbild",
|
||||||
"“Backup” targets automatically receive all results (like the backup mirror).": "„Backup\"-Ziele erhalten automatisch alle Ergebnisse (wie der Sicherungsspiegel).",
|
"“Backup” targets automatically receive all results (like the backup mirror).": "„Backup\"-Ziele erhalten automatisch alle Ergebnisse (wie der Sicherungsspiegel).",
|
||||||
"“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.": "„Erneut ausliefern\" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der falsche Reflex; das machst du am Auslieferungsziel selbst.",
|
"“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.": "„Erneut ausliefern\" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der falsche Reflex; das machst du am Auslieferungsziel selbst.",
|
||||||
"“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.": "„Erweitern\" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.",
|
"“Extend” produces more scene around the subject (outpainting) instead of cropping — made for wide TV frames.": "„Erweitern\" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für TV 16:9.",
|
||||||
"“Passport photos” becomes “Print” — 86 image sizes, crop repaired": "Aus „Passbilder\" wird „Drucken\" — 86 Bildmaße, Zuschnitt repariert",
|
"“Passport photos” becomes “Print” — 86 image sizes, crop repaired": "Aus „Passbilder\" wird „Drucken\" — 86 Bildmaße, Zuschnitt repariert",
|
||||||
"“Show folder” lists what actually sits where over SFTP.": "„Ordner anzeigen\" listet, was per SFTP tatsächlich wo liegt.",
|
"“Show folder” lists what actually sits where over SFTP.": "„Ordner anzeigen\" listet, was per SFTP tatsächlich wo liegt.",
|
||||||
"“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.": "„Verbindung testen\" prüft nur die Anmeldung. Erst die Testnachricht beweist, dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.",
|
"“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.": "„Verbindung testen\" prüft nur die Anmeldung. Erst die Testnachricht beweist, dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { one } from './lib/db';
|
|||||||
import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n';
|
import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n';
|
||||||
|
|
||||||
const PUBLIC_PATHS = [
|
const PUBLIC_PATHS = [
|
||||||
/^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//,
|
/^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/,
|
||||||
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
|
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
|
||||||
// Forgot password — must be reachable without being signed in, otherwise it's pointless.
|
// Forgot password — must be reachable without being signed in, otherwise it's pointless.
|
||||||
/^\/password/, /^\/api\/auth\/reset/,
|
/^\/password/, /^\/api\/auth\/reset/,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const GET: APIRoute = async () => {
|
|||||||
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
||||||
// Sharing
|
// Sharing
|
||||||
share_default_days: s.share_default_days,
|
share_default_days: s.share_default_days,
|
||||||
|
trash_days: s.trash_days,
|
||||||
} });
|
} });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,9 +65,16 @@ export const PATCH: APIRoute = async ({ request }) => {
|
|||||||
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
||||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||||
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
||||||
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
|
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days', 'trash_days']) {
|
||||||
if (col in b) {
|
if (col in b) {
|
||||||
const raw = b[col];
|
const raw = b[col];
|
||||||
|
if (col === 'trash_days') {
|
||||||
|
// NOT NULL in the database: empty means the default, anything else a whole number of days.
|
||||||
|
const n = raw === '' || raw == null ? 30 : Math.round(Number(raw));
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 3650) return json({ error: 'Trash: 0 to 3650 days.' }, 400);
|
||||||
|
set(col, n);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||||
set(col, val);
|
set(col, val);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { query, one } from '../../../lib/db';
|
|||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`);
|
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done' AND deleted_at IS NULL`);
|
||||||
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
|
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
|
||||||
const cost = async (days: number | null) => (await one<{ c: string }>(
|
const cost = async (days: number | null) => (await one<{ c: string }>(
|
||||||
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
|
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const GET: APIRoute = async ({ locals }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count
|
const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id AND deleted_at IS NULL)::int AS count
|
||||||
FROM folders f ORDER BY f.name`);
|
FROM folders f ORDER BY f.name`);
|
||||||
return json({ folders: rows });
|
return json({ folders: rows });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { one, query } from '../../../lib/db';
|
import { one } from '../../../lib/db';
|
||||||
import { deleteObject } from '../../../lib/storage';
|
import { loadItemFor } from '../../../lib/itemaccess';
|
||||||
|
import { trashItems, purgeItems } from '../../../lib/trash';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -8,7 +9,8 @@ const json = (b: unknown, s = 200) =>
|
|||||||
|
|
||||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const b = await request.json();
|
if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Not found.' }, 404);
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
const sets: string[] = []; const args: any[] = [];
|
const sets: string[] = []; const args: any[] = [];
|
||||||
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
|
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
|
||||||
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
|
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
|
||||||
@@ -22,10 +24,20 @@ export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
|||||||
return json({ item: row });
|
return json({ item: row });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
/**
|
||||||
|
* Moves the image into the trash. `?purge=1` removes an image that is already in
|
||||||
|
* the trash for good. With the trash switched off (trash_days = 0) a delete is
|
||||||
|
* final straight away, as it used to be.
|
||||||
|
*/
|
||||||
|
export const DELETE: APIRoute = async ({ params, url, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]);
|
const purge = url.searchParams.get('purge') === '1';
|
||||||
if (it?.result_path) await deleteObject(it.result_path).catch(() => {});
|
const it = await loadItemFor(locals.user, params.id!, 'write', { includeTrashed: purge });
|
||||||
await query('DELETE FROM items WHERE id=$1', [params.id]);
|
if (!it) return json({ error: 'Not found.' }, 404);
|
||||||
return json({ ok: true });
|
if (purge) {
|
||||||
|
const gone = await purgeItems(locals.user, [it.id]);
|
||||||
|
return json({ ok: gone.length === 1, purged: gone.length });
|
||||||
|
}
|
||||||
|
const moved = await trashItems(locals.user, [it.id]);
|
||||||
|
return json({ ok: moved.length === 1, trashed: moved.length });
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
|||||||
import { one, query } from '../../../../lib/db';
|
import { one, query } from '../../../../lib/db';
|
||||||
import { enqueue } from '../../../../lib/queue';
|
import { enqueue } from '../../../../lib/queue';
|
||||||
import { has, moduleOff } from '../../../../lib/modules';
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -11,6 +12,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!has('ai')) return moduleOff('ai');
|
if (!has('ai')) return moduleOff('ai');
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Not found.' }, 404);
|
||||||
const item = await one<any>(
|
const item = await one<any>(
|
||||||
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
||||||
if (!item) return json({ error: 'Not found.' }, 404);
|
if (!item) return json({ error: 'Not found.' }, 404);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
|||||||
import { one, query } from '../../../../lib/db';
|
import { one, query } from '../../../../lib/db';
|
||||||
import { enqueue } from '../../../../lib/queue';
|
import { enqueue } from '../../../../lib/queue';
|
||||||
import { has, moduleOff } from '../../../../lib/modules';
|
import { has, moduleOff } from '../../../../lib/modules';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!has('ai')) return moduleOff('ai');
|
if (!has('ai')) return moduleOff('ai');
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
if (!(await loadItemFor(locals.user, params.id!, 'write'))) return json({ error: 'Not found.' }, 404);
|
||||||
const item = await one<{ id: string; job_id: string }>(
|
const item = await one<{ id: string; job_id: string }>(
|
||||||
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
|
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
|
||||||
if (!item) return json({ error: 'Not found.' }, 404);
|
if (!item) return json({ error: 'Not found.' }, 404);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { one } from '../../../../lib/db';
|
import { one } from '../../../../lib/db';
|
||||||
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
||||||
|
import { loadItemFor } from '../../../../lib/itemaccess';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -11,6 +12,7 @@ const json = (b: unknown, s = 200) =>
|
|||||||
* the studio as a starting point. */
|
* the studio as a starting point. */
|
||||||
export const POST: APIRoute = async ({ params, locals }) => {
|
export const POST: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
if (!(await loadItemFor(locals.user, params.id!, 'read'))) return json({ error: 'There is no result.' }, 404);
|
||||||
const it = await one<{ result_path: string | null; filename: string | null }>(
|
const it = await one<{ result_path: string | null; filename: string | null }>(
|
||||||
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
||||||
if (!it?.result_path) return json({ error: 'There is no result.' }, 404);
|
if (!it?.result_path) return json({ error: 'There is no result.' }, 404);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|||||||
const isAdmin = locals.user.role === 'admin';
|
const isAdmin = locals.user.role === 'admin';
|
||||||
const shared = s?.library_visibility === 'shared';
|
const shared = s?.library_visibility === 'shared';
|
||||||
// Private jobs never show up in the library; visibility follows the setting.
|
// Private jobs never show up in the library; visibility follows the setting.
|
||||||
const where: string[] = [`i.status='done'`, `j.private=false`];
|
const where: string[] = [`i.status='done'`, `j.private=false`, `i.deleted_at IS NULL`];
|
||||||
const args: any[] = [];
|
const args: any[] = [];
|
||||||
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
||||||
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { buildSelectionZipName } from '../../../lib/naming';
|
import { buildSelectionZipName } from '../../../lib/naming';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import { query } from '../../../lib/db';
|
import { query } from '../../../lib/db';
|
||||||
import { getObject } from '../../../lib/storage';
|
import { getObject } from '../../../lib/storage';
|
||||||
|
|
||||||
@@ -13,11 +13,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const ids: string[] = b.itemIds || [];
|
const ids: string[] = b.itemIds || [];
|
||||||
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
|
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
|
||||||
|
|
||||||
|
// Only images this account may see, and none from the trash. Before, any id
|
||||||
|
// from any account went into the archive.
|
||||||
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
||||||
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
|
`SELECT i.id, i.result_path, i.filename FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.id = ANY($1::uuid[]) AND i.result_path IS NOT NULL AND i.deleted_at IS NULL
|
||||||
|
AND ($3::boolean OR j.created_by = $2
|
||||||
|
OR (NOT j.private AND (SELECT library_visibility FROM settings WHERE id=1) = 'shared'))`,
|
||||||
|
[ids.filter((x) => /^[0-9a-f-]{36}$/i.test(String(x))), locals.user.uid, locals.user.role === 'admin']);
|
||||||
if (!rows.length) return new Response('No results.', { status: 404 });
|
if (!rows.length) return new Response('No results.', { status: 404 });
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||||
const seen: Record<string, number> = {};
|
const seen: Record<string, number> = {};
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
|
|||||||
break;
|
break;
|
||||||
case 'retry-failed': {
|
case 'retry-failed': {
|
||||||
const items = await query<{ id: string }>(
|
const items = await query<{ id: string }>(
|
||||||
`UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' RETURNING id`, [id]);
|
`UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' AND deleted_at IS NULL RETURNING id`, [id]);
|
||||||
await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]);
|
await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]);
|
||||||
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import archiver from 'archiver';
|
import { ZipArchive } from 'archiver';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { query } from '../../../../lib/db';
|
import { query } from '../../../../lib/db';
|
||||||
@@ -62,11 +62,11 @@ export const GET: APIRoute = async ({ params, request, clientAddress }) => {
|
|||||||
|
|
||||||
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
|
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
|
||||||
`SELECT id, result_path, filename FROM items
|
`SELECT id, result_path, filename FROM items
|
||||||
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
|
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL AND deleted_at IS NULL`,
|
||||||
[images.map((b) => b.id)]);
|
[images.map((b) => b.id)]);
|
||||||
const byId = new Map(paths.map((p) => [p.id, p]));
|
const byId = new Map(paths.map((p) => [p.id, p]));
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||||
// Errors must not take the process with them; the stream then breaks off.
|
// Errors must not take the process with them; the stream then breaks off.
|
||||||
archive.on('error', (e: any) => console.error('[share/zip]', e?.message || e));
|
archive.on('error', (e: any) => console.error('[share/zip]', e?.message || e));
|
||||||
archive.on('warning', (e: any) => console.error('[share/zip] warning:', e?.message || e));
|
archive.on('warning', (e: any) => console.error('[share/zip] warning:', e?.message || e));
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
const isAdmin = locals.user.role === 'admin';
|
const isAdmin = locals.user.role === 'admin';
|
||||||
// One query instead of one per image: with 500 images that was 500 round trips.
|
// One query instead of one per image: with 500 images that was 500 round trips.
|
||||||
const found = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
const found = await query<{ id: string; status: string; result_path: string | null; deleted_at: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
||||||
`SELECT i.id, i.status, i.result_path, j.created_by, j.private,
|
`SELECT i.id, i.status, i.result_path, i.deleted_at, j.created_by, j.private,
|
||||||
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
||||||
FROM items i JOIN jobs j ON j.id = i.job_id
|
FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
WHERE i.id = ANY($1::uuid[])`, [ids]);
|
WHERE i.id = ANY($1::uuid[])`, [ids]);
|
||||||
if (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
|
if (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
|
||||||
|
|
||||||
for (const it of found) {
|
for (const it of found) {
|
||||||
if (!it.result_path || it.status !== 'done') return json({ error: 'One of the images is no longer available.' }, 404);
|
if (!it.result_path || it.status !== 'done' || it.deleted_at) return json({ error: 'One of the images is no longer available.' }, 404);
|
||||||
// Private jobs are deliberately never shared — the whole point is that they
|
// Private jobs are deliberately never shared — the whole point is that they
|
||||||
// leave no trace.
|
// leave no trace.
|
||||||
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
|
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { query } from '../../lib/db';
|
||||||
|
import { has, moduleOff } from '../../lib/modules';
|
||||||
|
import { trashItems, restoreItems, purgeItems, emptyTrash, trashDays, daysLeft } from '../../lib/trash';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
const json = (b: unknown, s = 200) =>
|
||||||
|
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
const UUID = /^[0-9a-f-]{36}$/i;
|
||||||
|
|
||||||
|
/** What is in the trash — your own images, or all of them for an admin. */
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const days = await trashDays();
|
||||||
|
const rows = await query<any>(
|
||||||
|
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.thumb_path, i.deleted_at, i.created_at
|
||||||
|
FROM items i JOIN jobs j ON j.id = i.job_id
|
||||||
|
WHERE i.deleted_at IS NOT NULL AND ($2::boolean OR j.created_by = $1)
|
||||||
|
ORDER BY i.deleted_at DESC LIMIT 400`, [locals.user.uid, locals.user.role === 'admin']);
|
||||||
|
return json({ days, items: rows.map((r) => ({ ...r, days_left: daysLeft(r.deleted_at, days) })) });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Body { action: 'trash' | 'restore' | 'purge', ids: [...] } — several at once, one round trip. */
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
const b = await request.json().catch(() => ({} as any));
|
||||||
|
const ids: string[] = Array.isArray(b.ids) ? b.ids.filter((x: unknown) => typeof x === 'string' && UUID.test(x)).slice(0, 500) : [];
|
||||||
|
if (!ids.length) return json({ error: 'No images selected.' }, 400);
|
||||||
|
if (b.action === 'trash') return json({ ok: true, ids: await trashItems(locals.user, ids) });
|
||||||
|
if (b.action === 'restore') return json({ ok: true, ids: await restoreItems(locals.user, ids) });
|
||||||
|
if (b.action === 'purge') return json({ ok: true, ids: await purgeItems(locals.user, ids) });
|
||||||
|
return json({ error: 'Unknown action.' }, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Empties the trash. */
|
||||||
|
export const DELETE: APIRoute = async ({ locals }) => {
|
||||||
|
if (!has('ai')) return moduleOff('ai');
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
return json({ ok: true, purged: await emptyTrash(locals.user) });
|
||||||
|
};
|
||||||
@@ -13,6 +13,16 @@ const dict = dictFor(locale);
|
|||||||
</div>
|
</div>
|
||||||
<p class="lead">{t('What is new in Klarbild. Newest changes at the top.')}</p>
|
<p class="lead">{t('What is new in Klarbild. Newest changes at the top.')}</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="ver"><span class="tag">{t('10 Oct 2026')}</span><h2>{t('1.1 — a trash, a sample photo, and an image you do not have to build')}</h2></div>
|
||||||
|
<ul>
|
||||||
|
<li set:html={t('<b>Deleting is no longer final on the spot.</b> ✕ moves an image into the trash, and for a few seconds the bottom of the screen offers <b>Undo</b>. Noticed later? The library shows a <b>Trash</b> button; everything in there can be restored for 30 days, then Klarbild clears it away by itself.')} />
|
||||||
|
<li set:html={t('While an image is in the trash it is gone from <b>share links</b> too — and comes back there when you restore it. An image deleted while it is still waiting in the queue is not generated at all, so it costs nothing.')} />
|
||||||
|
<li set:html={t('<b>Try it with a sample photo.</b> An instance with only the print studio used to offer “Pick from the library” — without having a library. Now that place holds a sample photo, so a first visit ends in a printed sheet.')} />
|
||||||
|
<li set:html={t('<b>Its own icon</b> in the browser tab and on the iPhone home screen.')} />
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Print: images from the library, and what you printed last')}</h2></div>
|
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Print: images from the library, and what you printed last')}</h2></div>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -113,7 +123,7 @@ const dict = dictFor(locale);
|
|||||||
<ul>
|
<ul>
|
||||||
<li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} />
|
<li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} />
|
||||||
<li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} />
|
<li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} />
|
||||||
<li set:html={t('<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} />
|
<li set:html={t('<b>TV 16:9 “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} />
|
||||||
<li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} />
|
<li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} />
|
||||||
<li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} />
|
<li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} />
|
||||||
<li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} />
|
<li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} />
|
||||||
@@ -132,7 +142,7 @@ const dict = dictFor(locale);
|
|||||||
<li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} />
|
<li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} />
|
||||||
<li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} />
|
<li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} />
|
||||||
<li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} />
|
<li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} />
|
||||||
<li set:html={t('<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} />
|
<li set:html={t('<b>TV 16:9</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} />
|
||||||
<li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} />
|
<li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} />
|
||||||
<li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} />
|
<li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} />
|
||||||
<li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} />
|
<li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} />
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const dict = dictFor(locale);
|
|||||||
<ol>
|
<ol>
|
||||||
<li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} />
|
<li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} />
|
||||||
<li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li>
|
<li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li>
|
||||||
<li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).')} />
|
<li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or a 16:9 TV frame, for instance), <b>Contour</b> (white sticker edge).')} />
|
||||||
<li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.')} />
|
<li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for a wide TV frame.')} />
|
||||||
<li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} />
|
<li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} />
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
@@ -46,6 +46,7 @@ const dict = dictFor(locale);
|
|||||||
<li set:html={t('<b>Before/after</b>: shows the comparison source ↔ result (not available for images made from text alone).')} />
|
<li set:html={t('<b>Before/after</b>: shows the comparison source ↔ result (not available for images made from text alone).')} />
|
||||||
<li set:html={t('<b>Manage folders</b>: tap the folder chip, then ✎ to rename it (including its folder on the target) or ✕ to delete it (the images are kept).')} />
|
<li set:html={t('<b>Manage folders</b>: tap the folder chip, then ✎ to rename it (including its folder on the target) or ✕ to delete it (the images are kept).')} />
|
||||||
<li set:html={t('<b>Several at once</b>: select images, then <b>download them as a ZIP</b> or move them <b>into a folder</b> together.')} />
|
<li set:html={t('<b>Several at once</b>: select images, then <b>download them as a ZIP</b> or move them <b>into a folder</b> together.')} />
|
||||||
|
<li set:html={t('<b>Deleting</b> (✕) moves an image into the <b>trash</b>, with <b>Undo</b> right after. The trash (a button top right, once something is in it) keeps it for 30 days — and while it is there it appears in no share link and no delivery.')} />
|
||||||
</ul>
|
</ul>
|
||||||
<p class="notice" set:html={t("<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)")} />
|
<p class="notice" set:html={t("<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)")} />
|
||||||
</section>
|
</section>
|
||||||
@@ -55,7 +56,7 @@ const dict = dictFor(locale);
|
|||||||
<p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} />
|
<p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} />
|
||||||
<ol>
|
<ol>
|
||||||
<li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} />
|
<li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} />
|
||||||
<li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} />
|
<li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 TV, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} />
|
||||||
<li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} />
|
<li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} />
|
||||||
<li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} />
|
<li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} />
|
||||||
<li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} />
|
<li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} />
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import Base from '../layouts/Base.astro';
|
|||||||
import StudioApp from '../components/StudioApp.tsx';
|
import StudioApp from '../components/StudioApp.tsx';
|
||||||
import { query } from '../lib/db';
|
import { query } from '../lib/db';
|
||||||
import { has } from '../lib/modules';
|
import { has } from '../lib/modules';
|
||||||
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
|
||||||
// Without the AI module there is no studio, and the print studio is the product.
|
// Without the AI module there is no studio, and the print studio is the product.
|
||||||
if (!has('ai')) return Astro.redirect('/print', 302);
|
if (!has('ai')) return Astro.redirect('/print', 302);
|
||||||
|
|
||||||
const locale = Astro.locals.locale ?? 'en';
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
|
const t = tFor(locale);
|
||||||
|
const dict = dictFor(locale);
|
||||||
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
|
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
|
||||||
---
|
---
|
||||||
<Base title="Studio · Klarbild">
|
<Base title={t('Studio · Klarbild')}>
|
||||||
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
|
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
|
||||||
</Base>
|
</Base>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { has } from '../lib/modules';
|
||||||
import LibraryApp from '../components/LibraryApp.tsx';
|
import LibraryApp from '../components/LibraryApp.tsx';
|
||||||
import { tFor, dictFor } from '../lib/i18n.server';
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
// A page of a module that is off does not exist for this instance.
|
||||||
|
if (!has('ai')) return Astro.redirect('/print', 302);
|
||||||
const locale = Astro.locals.locale ?? 'en';
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
const t = tFor(locale);
|
const t = tFor(locale);
|
||||||
const dict = dictFor(locale);
|
const dict = dictFor(locale);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ world such a module does not exist.
|
|||||||
|
|
||||||
## Formats (output_format)
|
## Formats (output_format)
|
||||||
- Fixed keys: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
|
- Fixed keys: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
|
||||||
A4,A3,A2,20x20,30x30, theframe (3840x2160), portrait916 (2160x3840), keep (keep the original).
|
A4,A3,A2,20x20,30x30, tv169 (3840x2160), portrait916 (2160x3840), keep (keep the original).
|
||||||
- Custom sizes without a preset: \`WIDTHxHEIGHT\` in cm (e.g. \`25x35\`) or \`sticker<N>\`
|
- Custom sizes without a preset: \`WIDTHxHEIGHT\` in cm (e.g. \`25x35\`) or \`sticker<N>\`
|
||||||
for N×N cm (e.g. \`sticker5\` = 5×5 cm). Limit 300 cm. Exact pixels: round(cm/2.54*dpi).
|
for N×N cm (e.g. \`sticker5\` = 5×5 cm). Limit 300 cm. Exact pixels: round(cm/2.54*dpi).
|
||||||
- File format (output_ext): \`png\` (lossless, transparency) or \`jpg\` (small, easy on the
|
- File format (output_ext): \`png\` (lossless, transparency) or \`jpg\` (small, easy on the
|
||||||
@@ -68,6 +68,10 @@ world such a module does not exist.
|
|||||||
- GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at).
|
- GET /api/jobs — the most recent jobs. GET /api/jobs/:id — job plus items (incl. error_message, finished_at).
|
||||||
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
|
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
|
||||||
- POST /api/items/:id/{retry|reuse|alternative}.
|
- POST /api/items/:id/{retry|reuse|alternative}.
|
||||||
|
- DELETE /api/items/:id — moves the image into the trash (restorable for settings.trash_days, default 30).
|
||||||
|
?purge=1 removes an image that is already in the trash for good.
|
||||||
|
- GET /api/trash — the trash, with days_left per image. POST /api/trash { action: trash|restore|purge, ids[] }.
|
||||||
|
DELETE /api/trash — empty it. Only your own images (admins: all).
|
||||||
- GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen),
|
- GET /api/items/:id/file — the result image. Query: ?thumb=1 (preview), ?preview=1 (small JPG for full screen),
|
||||||
?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP).
|
?download=1 (as a file), ?src=1 (the source). The content type is taken from the magic bytes (PNG/JPG/WEBP).
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ Purely local geometry: cropping (sharp) plus PDF (pdf-lib). No model, no cost.
|
|||||||
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
|
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
|
||||||
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (poster and frame) ·
|
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (poster and frame) ·
|
||||||
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
|
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
|
||||||
W16x9,W9x16,W3x2,W4x3,W5x4 (aspect ratios as a print size, The Frame among them).
|
W16x9,W9x16,W3x2,W4x3,W5x4 (aspect ratios as a print size, a 16:9 TV frame among them).
|
||||||
So every format the AI generation offers (src/lib/format.ts) can be printed here too.
|
So every format the AI generation offers (src/lib/format.ts) can be printed here too.
|
||||||
- If the image does not match the size (fit, per cell): \`cover\` = crop until it is filled (the default,
|
- If the image does not match the size (fit, per cell): \`cover\` = crop until it is filled (the default,
|
||||||
nothing stays empty, something is lost at the edges) or \`contain\` = letterbox the whole image, the
|
nothing stays empty, something is lost at the edges) or \`contain\` = letterbox the whole image, the
|
||||||
@@ -178,7 +182,7 @@ Results carry IPTC \`DigitalSourceType\` as XMP inside the file:
|
|||||||
Readable with exiftool. The print module creates nothing with AI and therefore labels nothing.
|
Readable with exiftool. The print module creates nothing with AI and therefore labels nothing.
|
||||||
|
|
||||||
## File names of the results
|
## File names of the results
|
||||||
Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-08-20_143207_team-badge_the-frame_a3f9.jpg\`.
|
Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-08-20_143207_team-badge_tv169_a3f9.jpg\`.
|
||||||
- The timestamp comes first, so any file listing (the delivery target, the backup mirror, a file
|
- The timestamp comes first, so any file listing (the delivery target, the backup mirror, a file
|
||||||
manager) sorts chronologically by itself.
|
manager) sorts chronologically by itself.
|
||||||
- The four-character short id comes from the item id. That way two images are **never** named the
|
- The four-character short id comes from the item id. That way two images are **never** named the
|
||||||
@@ -195,7 +199,7 @@ Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-
|
|||||||
## Delivery
|
## Delivery
|
||||||
- A delivery target is an SFTP/FTPS server; the folder on the target is a subfolder under the base path.
|
- A delivery target is an SFTP/FTPS server; the folder on the target is a subfolder under the base path.
|
||||||
The full path is base folder + folder (only assembled when delivering). The default folder is
|
The full path is base folder + folder (only assembled when delivering). The default folder is
|
||||||
"Gallery A"; "The Frame" → "TheFrame-Backgrounds".
|
"Gallery A"; "TV 16:9" → "TV-Backgrounds".
|
||||||
- What is delivered is the result in the chosen file format (output_ext, global or per job) — PNG or
|
- What is delivered is the result in the chosen file format (output_ext, global or per job) — PNG or
|
||||||
JPG. Cut-out motifs stay PNG.
|
JPG. Cut-out motifs stay PNG.
|
||||||
- An optional .md metadata sidecar file can be switched on per target (delivery target, backup mirror,
|
- An optional .md metadata sidecar file can be switched on per target (delivery target, backup mirror,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import PrintApp from '../components/PrintApp.tsx';
|
import PrintApp from '../components/PrintApp.tsx';
|
||||||
import { tFor, dictFor } from '../lib/i18n.server';
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
import { has } from '../lib/modules';
|
||||||
const locale = Astro.locals.locale ?? 'en';
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
const t = tFor(locale);
|
const t = tFor(locale);
|
||||||
const dict = dictFor(locale);
|
const dict = dictFor(locale);
|
||||||
@@ -11,7 +12,7 @@ const dict = dictFor(locale);
|
|||||||
<p class="page-lead">
|
<p class="page-lead">
|
||||||
{t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')}
|
{t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')}
|
||||||
</p>
|
</p>
|
||||||
<PrintApp client:load locale={locale} dict={dict} />
|
<PrintApp client:load locale={locale} dict={dict} canDeliver={has('delivery') || has('mirror')} hasLibrary={has('ai')} />
|
||||||
<style>
|
<style>
|
||||||
.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
|
.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
|
||||||
.page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
|
.page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { has } from '../lib/modules';
|
||||||
import QueueApp from '../components/QueueApp.tsx';
|
import QueueApp from '../components/QueueApp.tsx';
|
||||||
import { tFor, dictFor } from '../lib/i18n.server';
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
// A page of a module that is off does not exist for this instance.
|
||||||
|
if (!has('ai')) return Astro.redirect('/print', 302);
|
||||||
const locale = Astro.locals.locale ?? 'en';
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
const t = tFor(locale);
|
const t = tFor(locale);
|
||||||
const dict = dictFor(locale);
|
const dict = dictFor(locale);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { has } from '../lib/modules';
|
||||||
import SharesApp from '../components/SharesApp';
|
import SharesApp from '../components/SharesApp';
|
||||||
import { tFor, dictFor } from '../lib/i18n.server';
|
import { tFor, dictFor } from '../lib/i18n.server';
|
||||||
|
// A page of a module that is off does not exist for this instance.
|
||||||
|
if (!has('share')) return Astro.redirect('/print', 302);
|
||||||
const locale = Astro.locals.locale ?? 'en';
|
const locale = Astro.locals.locale ?? 'en';
|
||||||
const t = tFor(locale);
|
const t = tFor(locale);
|
||||||
const dict = dictFor(locale);
|
const dict = dictFor(locale);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal typings for `archiver`.
|
|
||||||
*
|
|
||||||
* The published `@types/archiver` describes an API this version does not have:
|
|
||||||
* it exports classes (`ZipArchive`, …) but no callable default, while the
|
|
||||||
* runtime is exactly the other way round — `archiver('zip', opts)` works and
|
|
||||||
* the classes do not exist. Rather than pin a mismatched types package, this
|
|
||||||
* declares the two things Klarbild actually uses.
|
|
||||||
*/
|
|
||||||
declare module 'archiver' {
|
|
||||||
import { Transform } from 'node:stream';
|
|
||||||
|
|
||||||
interface ArchiverOptions {
|
|
||||||
zlib?: { level?: number };
|
|
||||||
store?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntryData {
|
|
||||||
name: string;
|
|
||||||
date?: Date | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Archiver extends Transform {
|
|
||||||
append(source: Buffer | NodeJS.ReadableStream | string, data: EntryData): this;
|
|
||||||
finalize(): Promise<void>;
|
|
||||||
abort(): this;
|
|
||||||
pointer(): number;
|
|
||||||
on(event: 'error' | 'warning', listener: (err: Error & { code?: string }) => void): this;
|
|
||||||
on(event: 'entry', listener: (entry: EntryData) => void): this;
|
|
||||||
on(event: string, listener: (...args: any[]) => void): this;
|
|
||||||
}
|
|
||||||
|
|
||||||
function archiver(format: 'zip' | 'tar', options?: ArchiverOptions): Archiver;
|
|
||||||
export default archiver;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import config from '../astro.config.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Astro's config schema ignores keys it does not know — silently, with no
|
||||||
|
* warning. That is how the Content-Security-Policy went missing once: Astro 7
|
||||||
|
* moved `csp` from `experimental` to `security`, a top-level `csp` key kept
|
||||||
|
* type-checking and building, and the deployed site served no policy at all.
|
||||||
|
* Nothing in the build output said so. This test does.
|
||||||
|
*/
|
||||||
|
test('The CSP lives where Astro 7 reads it', () => {
|
||||||
|
const security = (config as any).security;
|
||||||
|
assert.ok(security, 'astro.config.mjs has no `security` block');
|
||||||
|
assert.ok(security.csp, 'the CSP must sit at `security.csp` — a top-level `csp` is ignored');
|
||||||
|
assert.ok(Array.isArray(security.csp.directives), '`security.csp.directives` must be a list');
|
||||||
|
|
||||||
|
// The directives the middleware does not add itself.
|
||||||
|
for (const d of ["default-src 'self'", "object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"]) {
|
||||||
|
assert.ok(security.csp.directives.includes(d), `missing directive: ${d}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No stray top-level `csp` or `experimental.csp` key', () => {
|
||||||
|
assert.equal((config as any).csp, undefined, 'a top-level `csp` key is ignored by Astro 7');
|
||||||
|
assert.equal((config as any).experimental?.csp, undefined, '`experimental.csp` is gone in Astro 7');
|
||||||
|
});
|
||||||
@@ -8,11 +8,11 @@ const ID2 = '77b21e00-1111-2222-3333-444455556666';
|
|||||||
|
|
||||||
test('The reported bug: two badges on the same day no longer overwrite each other', () => {
|
test('The reported bug: two badges on the same day no longer overwrite each other', () => {
|
||||||
// Same motif, same size, same second — this used to produce the same name twice.
|
// Same motif, same size, same second — this used to produce the same name twice.
|
||||||
const a = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
|
const a = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID1 });
|
||||||
const b = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 });
|
const b = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID2 });
|
||||||
assert.notEqual(a, b, 'two images must never carry the same name');
|
assert.notEqual(a, b, 'two images must never carry the same name');
|
||||||
assert.equal(a, '2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg');
|
assert.equal(a, '2026-08-20_143207_portrait-badge_tv169_a3f9.jpg');
|
||||||
assert.equal(b, '2026-08-20_143207_portrait-badge_the-frame_77b2.jpg');
|
assert.equal(b, '2026-08-20_143207_portrait-badge_tv169_77b2.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Names sort lexicographically = chronologically', () => {
|
test('Names sort lexicographically = chronologically', () => {
|
||||||
@@ -42,12 +42,12 @@ test('Midnight is written as 00, not as 24', () => {
|
|||||||
test('The short id is stable — a second run gives the same name', () => {
|
test('The short id is stable — a second run gives the same name', () => {
|
||||||
assert.equal(shortTag(ID1), shortTag(ID1));
|
assert.equal(shortTag(ID1), shortTag(ID1));
|
||||||
assert.equal(shortTag(ID1), 'a3f9');
|
assert.equal(shortTag(ID1), 'a3f9');
|
||||||
const it = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: AT };
|
const it = { id: ID1, filename: '2026-07-23_portrait_tv169.png', created_at: AT };
|
||||||
assert.equal(newNameFor(it), newNameFor(it));
|
assert.equal(newNameFor(it), newNameFor(it));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Processing an image again does not stack up stamps', () => {
|
test('Processing an image again does not stack up stamps', () => {
|
||||||
const once = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 });
|
const once = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID1 });
|
||||||
const twice = buildResultFilename(once, '30x40', 'jpg', { at: AT, id: ID2 });
|
const twice = buildResultFilename(once, '30x40', 'jpg', { at: AT, id: ID2 });
|
||||||
assert.equal(twice, '2026-08-20_143207_portrait-badge_30x40_77b2.jpg');
|
assert.equal(twice, '2026-08-20_143207_portrait-badge_30x40_77b2.jpg');
|
||||||
const thrice = buildResultFilename(twice, 'a4', 'jpg', { at: AT, id: ID1 });
|
const thrice = buildResultFilename(twice, 'a4', 'jpg', { at: AT, id: ID1 });
|
||||||
@@ -55,26 +55,26 @@ test('Processing an image again does not stack up stamps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Our own pattern recognises our own names and only those', () => {
|
test('Our own pattern recognises our own names and only those', () => {
|
||||||
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait-badge_the-frame_a3f9'));
|
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait-badge_tv169_a3f9'));
|
||||||
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait_30x40_a3f9-2'));
|
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait_30x40_a3f9-2'));
|
||||||
assert.ok(!KLARBILD_NAME.test('2026-07-23_portrait_the-frame')); // the old scheme
|
assert.ok(!KLARBILD_NAME.test('2026-07-23_portrait_tv169')); // the old scheme
|
||||||
assert.ok(!KLARBILD_NAME.test('holiday-2026_beach')); // somebody else's name
|
assert.ok(!KLARBILD_NAME.test('holiday-2026_beach')); // somebody else's name
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Old names are split up correctly', () => {
|
test('Old names are split up correctly', () => {
|
||||||
assert.deepEqual(splitOldName('2026-07-23_portrait_the-frame.png'), { motif: 'portrait', format: 'the-frame', ext: 'png' });
|
assert.deepEqual(splitOldName('2026-07-23_portrait_tv169.png'), { motif: 'portrait', format: 'tv169', ext: 'png' });
|
||||||
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
|
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
|
||||||
// A foreign file without our scheme: all of it becomes the motif, format "original".
|
// A foreign file without our scheme: all of it becomes the motif, format "original".
|
||||||
assert.deepEqual(splitOldName('Holiday Crete.JPEG'), { motif: 'holiday-crete', format: 'original', ext: 'jpeg' });
|
assert.deepEqual(splitOldName('Holiday Crete.JPEG'), { motif: 'holiday-crete', format: 'original', ext: 'jpeg' });
|
||||||
// Already in the new scheme: motif and format stay, so that renaming is idempotent.
|
// Already in the new scheme: motif and format stay, so that renaming is idempotent.
|
||||||
assert.deepEqual(splitOldName('2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'),
|
assert.deepEqual(splitOldName('2026-08-20_143207_portrait-badge_tv169_a3f9.jpg'),
|
||||||
{ motif: 'portrait-badge', format: 'the-frame', ext: 'jpg' });
|
{ motif: 'portrait-badge', format: 'tv169', ext: 'jpg' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Renaming is idempotent — a second run changes nothing', () => {
|
test('Renaming is idempotent — a second run changes nothing', () => {
|
||||||
const old = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') };
|
const old = { id: ID1, filename: '2026-07-23_portrait_tv169.png', created_at: new Date('2026-07-23T09:15:42Z') };
|
||||||
const fresh = newNameFor(old);
|
const fresh = newNameFor(old);
|
||||||
assert.equal(fresh, '2026-07-23_111542_portrait_the-frame_a3f9.png');
|
assert.equal(fresh, '2026-07-23_111542_portrait_tv169_a3f9.png');
|
||||||
assert.equal(newNameFor({ ...old, filename: fresh }), fresh, 'the second run has to give the same name');
|
assert.equal(newNameFor({ ...old, filename: fresh }), fresh, 'the second run has to give the same name');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,9 +144,9 @@ test('Folder and file names cannot leave the base folder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Real customer folders stay allowed — the check is not there to annoy', () => {
|
test('Real customer folders stay allowed — the check is not there to annoy', () => {
|
||||||
const good = ['Gallery A', 'TheFrame-Backgrounds', 'Müller & Söhne (2026)',
|
const good = ['Gallery A', 'TV-Backgrounds', 'Müller & Söhne (2026)',
|
||||||
"Wedding O'Brien", 'Daycare 2026_Group 3', 'a.b.c',
|
"Wedding O'Brien", 'Daycare 2026_Group 3', 'a.b.c',
|
||||||
'2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'];
|
'2026-08-20_143207_portrait-badge_tv169_a3f9.jpg'];
|
||||||
for (const g of good) assert.equal(safeSegment(g, 'folder'), g, `wrongly rejected: ${g}`);
|
for (const g of good) assert.equal(safeSegment(g, 'folder'), g, `wrongly rejected: ${g}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { objectKeysOf, daysLeft } from '../src/lib/trash.ts';
|
||||||
|
import { canRead, canWrite } from '../src/lib/itemaccess.ts';
|
||||||
|
|
||||||
|
test('Every stored file of an image is found, each once', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
objectKeysOf({ source_path: 'sources/a.png', result_path: 'results/a.png', thumb_path: 'thumbs/a.webp',
|
||||||
|
source_paths: ['sources/a.png', 'sources/b.png'] }),
|
||||||
|
['sources/a.png', 'results/a.png', 'thumbs/a.webp', 'sources/b.png']);
|
||||||
|
assert.deepEqual(objectKeysOf({ source_path: null, result_path: '', thumb_path: undefined, source_paths: null }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Days left in the trash count down and never go negative', () => {
|
||||||
|
const now = new Date('2026-10-10T12:00:00Z');
|
||||||
|
assert.equal(daysLeft('2026-10-10T12:00:00Z', 30, now), 30);
|
||||||
|
assert.equal(daysLeft('2026-10-09T12:00:00Z', 30, now), 29);
|
||||||
|
assert.equal(daysLeft('2026-10-09T11:00:00Z', 30, now), 29); // 28.96 days -> rounded up
|
||||||
|
assert.equal(daysLeft('2026-09-10T12:00:00Z', 30, now), 0); // exactly due
|
||||||
|
assert.equal(daysLeft('2026-01-01T00:00:00Z', 30, now), 0); // long overdue
|
||||||
|
assert.equal(daysLeft('2026-10-10T12:00:00Z', 0, now), 0); // trash switched off
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reading: own, admin, or a shared library — never another person\'s private session', () => {
|
||||||
|
const me = { uid: 'u1', role: 'user' }, admin = { uid: 'a', role: 'admin' };
|
||||||
|
const mine = { created_by: 'u1', private: false }, theirs = { created_by: 'u2', private: false };
|
||||||
|
const theirsPrivate = { created_by: 'u2', private: true };
|
||||||
|
assert.equal(canRead(me, mine, false), true);
|
||||||
|
assert.equal(canRead(me, theirs, false), false);
|
||||||
|
assert.equal(canRead(me, theirs, true), true);
|
||||||
|
assert.equal(canRead(me, theirsPrivate, true), false);
|
||||||
|
assert.equal(canRead(admin, theirsPrivate, false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Writing: own or admin only — a shared library is for looking, not for deleting', () => {
|
||||||
|
const me = { uid: 'u1', role: 'user' };
|
||||||
|
assert.equal(canWrite(me, { created_by: 'u1' }), true);
|
||||||
|
assert.equal(canWrite(me, { created_by: 'u2' }), false);
|
||||||
|
assert.equal(canWrite({ uid: null, role: 'user' }, { created_by: null }), false);
|
||||||
|
assert.equal(canWrite({ uid: 'x', role: 'admin' }, { created_by: 'u2' }), true);
|
||||||
|
});
|
||||||
@@ -2,12 +2,6 @@
|
|||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"types": [
|
"types": [
|
||||||
|
|||||||