22 Commits

Author SHA1 Message Date
f76794df78 Release version 2.1.2 2026-09-10 18:06:21 +02:00
c6667f3b05 feat(i18n): ship interface translations for every offered language
The language switcher offers all 184 ISO 639-1 languages, but only 29
had a UI catalogue, so the other 154 rendered Close, Imprint, Language
and the rest of the interface strings in English. Nothing noticed:
the existing unit test only checks the catalogues that are present.

A new integration test requires every language except the source to
ship a catalogue with a non-empty entry for each of the 11 interface
strings, and lists missing catalogues apart from incomplete ones. The
154 missing catalogues are added, written through
i18n_sync.write_catalog so they share the existing format; make i18n-ui
never overwrites them, so a later correction survives.

The translations were written without a native reviewer. Most widely
used languages are solid; these are best-effort and likely wrong:
ae, cr, ii, kr, na, nv, oj, sg, aa, hz, ho, mh, za, vo. Uncertain:
ab, ak, av, ay, bi, bm, bo, ce, ch, cu, dv, dz, ee, ff, fj, gn, gv, ie,
ik, iu, kg, ki, kj, kl, ks, kv, kw, lg, li, ln, lu, ng, nr, os, pi, qu,
rn, sa, se, ss, to, tw, ty, ve, wa, wo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:05:33 +02:00
b8891fe6fd fix(app): print the startup line only when app.py runs as a script
The "Starting app on host:port" line sat at module level, so every
import printed it, including the test suites and any WSGI server that
loads the module; CodeQL reported it as a print during import. It now
lives in the __main__ block, which the Docker CMD and the e2e runner
reach through python app.py; the e2e Flask log still shows the line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:35:07 +02:00
2c7f0a23d1 ci(actions): pin the third-party actions to commit SHAs
CodeQL flagged docker/setup-buildx-action, docker/login-action,
docker/build-push-action and cypress-io/github-action as unpinned: a
moving v3 or v6 tag lets whoever controls it change what runs with the
workflow's GHCR write token.

Each action is now pinned to the commit its tag points to today, with
the exact release as a trailing comment (v3.12.0, v3.7.0, v6.19.2,
v6.10.9), resolved through the GitHub API, so the code that runs does
not change. GitHub's own actions/* were not flagged and keep their tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:35:07 +02:00
330881260c fix(i18n): build catalogue paths from the language table, not the request
catalog() joined the requested language code straight into the UI and
content catalogue paths, and read_catalog logged those paths. Both the
negotiated Accept-Language code and the /<lang>/ route only ever pass
supported codes, but that guarantee lived in the callers, so CodeQL
reported path injection and log injection on the request value.

catalog() now resolves the code through a table of the supported
languages and builds the file names from the table's value, so an
unsupported code returns an empty catalogue and never becomes a path.
A unit test holds "../content/de" to that without reading any file,
and the translate_tree fixture moves from the made-up code "xx" to "de"
because unsupported codes now translate to English by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:35:07 +02:00
857c470c9e fix(iframe): open only configured origins from the iframe query parameter
?iframe=<url> loaded any http(s) page into the dashboard frame, and
"Open in new tab" handed the same query value to window.open unchecked,
so a crafted link could show an arbitrary site inside the trusted page.
CodeQL flagged both as client-side URL redirection and XSS. The scheme
check sat before the fade callback that sets the iframe src, so it did
not guard that sink, and isSafeUrl itself assigned the untrusted value
to an anchor's href to parse it.

isAllowedIframeUrl now requires a safe scheme and an origin that is the
page's own or one of the configured .iframe-link targets; both query
string entry points check it before openIframe or window.open run.
Clicks on configured links and popup entries keep calling openIframe
directly, which still rejects unsafe schemes. isSafeUrl parses with
new URL instead of a detached anchor.

The Cypress case that expected https://example.com/ to open from the
query string encoded the redirection, so it now asserts that a
configured target opens and that a foreign origin neither loads in the
frame nor reaches window.open. make test passes with 109 Cypress tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:35:07 +02:00
793e2de899 build(docker): ship only the runtime assets, without node or dev packages
The image ran npm install without --omit=dev, so every published tag
carried Cypress with its downloaded binary, ESLint and the whole dev
tree next to nodejs and npm. The npm install layer alone was 229 MB of
the 349 MB compressed 2.0.0 image, and all seven npm audit findings
(extract-zip, form-data, tmp, qs, uuid via @cypress/request) came from
it, although the app only serves five vendored asset directories.

A node:22-slim stage now runs npm install --omit=dev, whose postinstall
writes static/vendor, and the final runtime stage copies just that
directory onto the Python base. A dev stage keeps nodejs and npm for
docker-compose, which bind-mounts app/ and runs npm install on start;
compose now builds that target. .dockerignore keeps a local
app/node_modules and app/static/vendor out of COPY app/.

Measured on make build: the runtime image is 142 MB uncompressed, has
no node, npm, node_modules or Cypress cache, and serves /, /de/ and
every vendored asset with 200. The dev stage has node 20.19.2 and npm
9.2.0. make test passes, hadolint included, with 107 Cypress tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:05 +02:00
f830153cf4 build(make): run every local suite from make test in a project virtualenv
make test installed the project into whatever PYTHON pointed at, which is
the system python3 by default. On Manjaro, Debian and Ubuntu that Python
is externally managed, so pip refuses (PEP 668) and not a single suite
runs, although AGENTS.md requires make test before every commit. It also
stopped at the first failing suite, so one broken linter hid the result
of every test behind it.

make test now creates .venv on first use and runs ci with --keep-going
and that interpreter, so every suite runs and make lists each failed
target. The path is fixed to the checkout: an exported VENV, which other
tooling sets, would otherwise redirect it. It is absolute because
run-e2e.sh changes into app/ before starting Flask. All other targets
keep PYTHON ?= python3.

Verified with a full make test: actionlint, ruff, yamllint, eslint,
hadolint, shellcheck, the lint, security, unit and integration suites,
bandit, pip-audit and 107 Cypress tests all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:05 +02:00
308741346e fix(ci): install the project before running the lint tests
tests/lint/test_tooling_configuration.py imports yaml, but the lint test
job only set up Python and ran unittest, so every push since the test
arrived failed with "ModuleNotFoundError: No module named 'yaml'". The
job gates the end-to-end tests and the image publish, which is why no
image exists for 2.1.0 or 2.1.1.

The job now installs the project exactly like the unit, integration and
security jobs, and make test-lint depends on install like its siblings.
Reproduced in a clean virtualenv: the lint suite fails on the missing
module without the install and passes all 18 tests with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:04 +02:00
d7149b83e9 Release version 2.1.1 2026-09-10 16:07:26 +02:00
e00ff9437c fix(navigation): keep menu icon glyphs out of the links' accessible names
Font Awesome 6 paints each icon through ::before with a private-use
character, and the menu's <i> elements carried no aria-hidden, so every
menu link's accessible name began with that glyph: the Login link was
announced as " Login". Screen readers read the glyph, and exact
name matches such as Playwright's getByRole("link", { name: /^login$/i })
never found the link, which left infinito-nexus's post-login check that
the Login control is gone passing without ever looking at it.

The icons of the navigation macro and of the language menu are now
aria-hidden, as Font Awesome recommends for decorative icons. A unit
test holds every icon of the rendered header to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:03:21 +02:00
d115fc99b2 Release version 2.1.0 2026-08-22 17:26:20 +02:00
e96e8684e6 feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled,
on the grounds that no backend tells the menu label "Pictures" from the
brand "Mastodon". That left the visible half of a card in English. They
are filled now, and the two key sets collapse into one.

The brands need somewhere to be named instead. app/i18n/keep.txt lists
them, one per line, and every entry is stored as itself in every target
language: no request, and never over an entry written by hand. --keep adds
one-off strings, --keep-file points elsewhere.

The shipped list holds the 43 product names that appear as name: or title:
in config.sample.yaml. Generic labels — Pictures, Imprint, Settings,
Certificates — are deliberately absent, and so are Cybermaster, Polymath
and Yachtmaster, which read as brand or as job title depending on who is
asking.

Two of these behaviours first shipped unguarded. A test that protected a
string and asserted the hand-written value survived passed either way,
because a run where nothing is missing reports "complete" and never
writes; and nothing exercised main(), so the keep file could stop being
read without a failure. Both are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:19:16 +02:00
747ce379cc fix(app): stop trusting X-Forwarded-For, and pin what the audit found
ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never
disabled it: request.remote_addr and the access log were forgeable by any
client that reached the app directly. It is x_for=0 now, asserted rather
than assumed.

A mutation audit over the change set reverted 196 deliberate behaviours
and found 47 that no test noticed. This closes the ones that carry damage:

- apod_background lost its key check, its transport guard, its status
  guard and its media-type check without a single test failing. Each one
  turns a slow or unhappy NASA into a 500 on every page.
- Untrusted values reached innerHTML through window.I18N, which the
  translation backend writes, and the modal's click handlers stacked so a
  later click opened an earlier popup's URL.
- The sync tool could ask for HTML instead of text, translate from "auto"
  instead of English, run without a timeout, store an empty translation
  that marks the string done for good, abandon 28 languages because one
  could not be written, and report success after reaching nothing.
- Neither the lint target, the CI jobs, the vendored RTL stylesheet, the
  documented environment keys, nor any of the four hardenings in
  scripts/run-e2e.sh was observed by anything.

Three of the new tests passed for the wrong reason on their first cut —
a mock that answered None whether or not the guard existed, a
raise_for_status that was never called, a string that stayed in the file
after the mutation. The audit found those too; all 24 reverts now fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:19:15 +02:00
ef1c8ff09a feat(i18n): offer every ISO 639-1 language
The table was thirty languages typed by hand. It is now generated:
utils/generate_languages.py takes the 184 alpha-2 codes from pycountry,
the display names from CLDR through babel, and the writing direction from
CLDR character order. 159 languages carry their endonym; the remaining 25
have no CLDR entry and carry their English ISO name.

That corrects the right-to-left set, which had four entries and needs ten
— dv, ks, ps, sd, ug and yi were simply missed.

Only 29 languages ship an interface catalogue, so the other 155 render in
English until one is filled. make i18n-ui fills app/i18n/ui/ for them, and
make i18n now covers the interface strings as well; neither asks for a
string a shipped catalogue already answers, so hand-written entries stay.

184 entries do not fit on a screen, so the language menu scrolls inside
itself. overscroll-behavior keeps the page behind it from moving once the
list reaches its end.

babel and pycountry are dev dependencies: the generator needs them, the
application does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:30 +02:00
0c67f999f6 chore: ignore the local MCP configuration
.mcp.json is written per machine and carries no repository state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:30 +02:00
efabbd3b2e fix(modal): stop untrusted content reaching innerHTML and the iframe
Every one of these paths checked a string that the browser reinterprets
afterwards. isSafeUrl now hangs the value on an <a> and reads back
probe.protocol, so the check sees what the browser will see: a pre-parse
test reads "&#106;avascript:" as a relative path and passes it, and the
HTML parser then decodes it to "javascript:".

marked passes raw HTML through and emits hrefs unescaped. renderMarkdown
escapes the angle brackets before parsing, parses into an inert DOMParser
document where no script runs and no image loads, and drops anchors and
images whose scheme is not http, https or mailto. Blockquotes and
<autolinks> stop working as a result; neither appears in the configuration.

modalTitle and the alternatives list interpolated subitem.name and
icon.class into innerHTML. Both are built as nodes now. name is a
translatable key, so it arrives from the machine-written catalogues.

The link kept its click handler and its class across popups, because one
anchor serves all of them: a later, unrelated click opened whatever an
earlier popup pointed at, and addEventListener stacked one handler per
open. Both are reset per popup and the handler is assigned, not added.

openIframe guards its own argument. Removing the href alone left the
handler passing the raw URL on, and ?iframe= in the query string reaches
the same sink with no configuration involved at all.

Verified in headless Chromium: decimal and hex character references,
&Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a
link's text, and the two name sinks all executed before these changes.
injection.spec.js keeps all fifteen payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:29 +02:00
2a35b2910a feat(i18n): serve every page in 30 languages
The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.

Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.

negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.

The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.

Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.

Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.

read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:29 +02:00
a42930a699 build(make): run e2e without act, lint yaml, js and shell
test-e2e drove Cypress through act, which fails to start where it cannot
resolve a host address for its artifact server. It now starts Flask and
Cypress in one shell via scripts/run-e2e.sh, so both share a network
namespace; the act path stays available as test-e2e-act.

The script guards the cases that made the old target lie: it aborts when
something already serves the port instead of testing that server, checks
that its own Flask is alive before trusting a response, pins Cypress to
the same origin Flask binds, and drops ELECTRON_RUN_AS_NODE, which VS Code
exports and which makes Cypress' bundled Electron reject its own flags.

30 YAML and 18 JavaScript files had no linter. yamllint runs correctness
rules only, because the repository predates it and its cosmetic findings
would be noise; key-duplicates is the one that earns its keep, since
PyYAML keeps the last of two identical keys without complaining. eslint
runs the recommended set and already found a dead getBoundingClientRect()
call in navigation.js. Both get a CI job so make lint and the workflows
stop diverging.

flask>=3.1 because app.config["TRUSTED_HOSTS"] arrived in 3.1 and an older
Flask accepts the key and ignores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:27 +02:00
03b7cef90e Release version 2.0.0 2026-05-18 12:26:26 +02:00
4b29448b33 lint 2026-05-18 12:21:05 +02:00
3f30621630 feat(assets): probe-first resolver + SPOT for IMAGE_NAME/PORT + README screenshot
Probe-first asset resolution (regression fix)
---------------------------------------------

cache_manager.cache_file() returned either a relative cache path
(success) or None (failure). The previous app.py fallback
asset['cache'] = cached or asset['source'] mixed both types into
one field, which the template wrapped in url_for('static', ...)
regardless — producing broken
/static/https://file.infinito.nexus/.../logo.png URLs whenever the
source couldn't be downloaded.

- New app/utils/asset_resolver.py: HEAD-probes the URL (3 s
  timeout, image/* content type). On hit, embed directly via a
  new external_url field — no download required. On miss, fall
  back to cache_manager.cache_file. If that also fails, expose
  the source URL via external_url so the browser shows the alt
  text instead of an empty src.
- app.py exposes an asset_src(asset) context processor that
  picks external_url first, then url_for('static', cache),
  so the template never wraps an absolute URL in a static prefix.
- Templates (base, navigation, card) switch to asset_src(...) and
  gate the card image branch on cache or external_url.
- 16 unit tests cover every probe/cache/fallback branch; one live
  integration test exercises the canonical
  https://file.infinito.nexus/assets/img/logo.png to prove the
  probe-first path works end-to-end (cache dir stays empty).
- config.sample.yaml: new Infinito.Nexus card driven by the same
  canonical asset URL.

Single source of truth for IMAGE_NAME and PORT
----------------------------------------------

- env.example is now the only place the literal values live.
- Makefile and docker-compose.yml reference \$(IMAGE_NAME) /
  \${IMAGE_NAME:?…} (same for PORT); no defaults, no silent
  fallbacks.
- New make env / make config bootstrap .env / app/config.yaml
  from their checked-in templates. Idempotent.
- All container-using targets depend on the two bootstrap targets
  so a fresh checkout runs in a single invocation.
- Recipes source .env at recipe-execution time so they pick up a
  freshly bootstrapped .env in the same make invocation.

README
------

- Screenshot added under the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:19:15 +02:00
228 changed files with 5651 additions and 141 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
app/node_modules/
app/static/vendor/

View File

@@ -70,11 +70,11 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: steps.semver.outputs.found == 'true' if: steps.semver.outputs.found == 'true'
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GHCR - name: Login to GHCR
if: steps.semver.outputs.found == 'true' if: steps.semver.outputs.found == 'true'
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -82,7 +82,7 @@ jobs:
- name: Build and publish image - name: Build and publish image
if: steps.semver.outputs.found == 'true' if: steps.semver.outputs.found == 'true'
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -19,6 +19,61 @@ jobs:
- name: Run actionlint - name: Run actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
lint-yaml:
name: Lint YAML
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Run yamllint
run: yamllint --strict .
lint-js:
name: Lint JavaScript
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package.json
- name: Install Node dependencies
working-directory: app
run: npm install
- name: Run eslint
working-directory: app
run: npx eslint .
lint-shell:
name: Lint shell scripts
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run shellcheck
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
lint-python: lint-python:
name: Lint Python name: Lint Python
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -21,6 +21,11 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install lint test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run lint test suite - name: Run lint test suite
run: python -m unittest discover -s tests/lint -t . run: python -m unittest discover -s tests/lint -t .
@@ -185,7 +190,7 @@ jobs:
xvfb xvfb
- name: Run Cypress tests - name: Run Cypress tests
uses: cypress-io/github-action@v6 uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
with: with:
working-directory: app working-directory: app
install: false install: false

3
.gitignore vendored
View File

@@ -1,12 +1,15 @@
app/config.yaml app/config.yaml
app/i18n/content/
*__pycache__* *__pycache__*
app/static/cache/* app/static/cache/*
.env .env
app/cypress/screenshots/* app/cypress/screenshots/*
.ruff_cache/ .ruff_cache/
.venv/
app/node_modules/ app/node_modules/
app/static/vendor/ app/static/vendor/
hadolint-results.sarif hadolint-results.sarif
build/ build/
*.egg-info/ *.egg-info/
app/core.* app/core.*
.mcp.json

23
.yamllint Normal file
View File

@@ -0,0 +1,23 @@
---
# Correctness only. No `extends: default`, so nothing but the rules below runs:
# the repository predates this linter and its cosmetic findings (indentation,
# line length, trailing spaces) would be noise nobody acts on.
#
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
# identical keys without complaining, so a duplicated entry in a translation
# catalogue silently drops a translation.
ignore: |
.git/
.venv/
venv/
node_modules/
app/node_modules/
app/static/vendor/
build/
.ruff_cache/
rules:
key-duplicates: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true

View File

@@ -1,3 +1,42 @@
# Changelog
## [2.1.2] - 2026-09-10
* CI: lint tests install the project first, so the image publishes again
* Tooling: make test runs every local suite in .venv and keeps going
* Image: multi-stage build ships only runtime assets, no node or dev packages
* Security: CodeQL findings fixed; iframe links only open configured origins
* i18n: interface strings translated for all 184 languages, test-enforced
## [2.1.1] - 2026-09-10
* Accessibility: header icons are *aria-hidden*, links read by label alone
* Test coverage: unit test requires *aria-hidden* on every header icon
## [2.1.0] - 2026-08-22
* Multilingual site: every ISO 639-1 language has its own URL, */* follows the visitor's browser language, and a switcher in the navbar lists all 184 in their own script
* Right-to-left layouts: Arabic, Hebrew, Persian, Urdu and six more mirror the page automatically
* Machine translation: *make i18n* fills your card and menu texts from a LibreTranslate instance, *make i18n-ui* the interface strings; hand-corrected entries are never overwritten
* Brand protection: list your product names in *app/i18n/keep.txt* and they stay untranslated in every language
* Search engines: each page declares a canonical URL and an *hreflang* alternate per language
* Security: configuration and translations are HTML-escaped, script URLs are stripped from links, images and the iframe view — set *TRUSTED_HOSTS* in *.env* to pin your public hostname
* Reliability: a slow NASA APOD lookup no longer takes the page down
* Developer tooling: *make test-e2e* runs Cypress directly instead of through act, and YAML, JavaScript and shell now have linters in CI
## [2.0.0] - 2026-05-18
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
* README: PortUI screenshot added under the title
* Lint: removed an unused sys import in the live integration test
## [1.2.0] - 2026-05-11 ## [1.2.0] - 2026-05-11
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle * * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle

View File

@@ -1,12 +1,16 @@
FROM python:3.12-slim FROM node:22-slim AS assets
WORKDIR /app
COPY app/package.json ./
COPY app/scripts ./scripts
RUN npm install --omit=dev --no-audit --no-fund
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0 FLASK_HOST=0.0.0.0
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/build WORKDIR /tmp/build
COPY pyproject.toml README.md main.py ./ COPY pyproject.toml README.md main.py ./
@@ -15,6 +19,14 @@ RUN python -m pip install --no-cache-dir .
WORKDIR /app WORKDIR /app
COPY app/ . COPY app/ .
RUN npm install --prefix /app
CMD ["python", "app.py"] CMD ["python", "app.py"]
FROM base AS dev
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
FROM base AS runtime
COPY --from=assets /app/static/vendor ./static/vendor

144
Makefile
View File

@@ -5,24 +5,63 @@ ifneq (,$(wildcard .env))
export $(shell sed 's/=.*//' .env) export $(shell sed 's/=.*//' .env)
endif endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
PYTHON ?= python3 PYTHON ?= python3
ACT ?= act ACT ?= act
TEST_VENV := $(CURDIR)/.venv
TEST_PYTHON ?= $(TEST_VENV)/bin/python
# Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
# Default port (can be overridden with PORT env var)
.PHONY: build .PHONY: build
build: build: env config
# Build the Docker image. # Build the Docker image.
docker build -t application-portfolio . @$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache .PHONY: build-no-cache
build-no-cache: build-no-cache: env config
# Build the Docker image without cache. # Build the Docker image without cache.
docker build --no-cache -t application-portfolio . @$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up .PHONY: up
up: up: env config
# Start the application using docker-compose with build. # Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate docker-compose up -d --build --force-recreate
@@ -34,23 +73,29 @@ down:
- docker-compose down - docker-compose down
.PHONY: run-dev .PHONY: run-dev
run-dev: run-dev: env config config
# Run the container in development mode (hot-reload). # Run the container in development mode (hot-reload).
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \ docker run -d \
-p $(PORT):$(PORT) \ -p "$$PORT:$$PORT" \
--name portfolio \ --name portfolio \
-v $(PWD)/app/:/app \ -v "$(PWD)/app/:/app" \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
-e FLASK_APP=app.py \ -e FLASK_APP=app.py \
-e FLASK_ENV=development \ -e FLASK_ENV=development \
application-portfolio "$$IMAGE_NAME"
.PHONY: run-prod .PHONY: run-prod
run-prod: run-prod: env config config
# Run the container in production mode. # Run the container in production mode.
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \ docker run -d \
-p $(PORT):$(PORT) \ -p "$$PORT:$$PORT" \
--name portfolio \ --name portfolio \
application-portfolio -e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
"$$IMAGE_NAME"
.PHONY: logs .PHONY: logs
logs: logs:
@@ -58,12 +103,12 @@ logs:
docker logs -f portfolio docker logs -f portfolio
.PHONY: dev .PHONY: dev
dev: dev: env config
# Start the application in development mode using docker-compose. # Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d FLASK_ENV=development docker-compose up -d
.PHONY: prod .PHONY: prod
prod: prod: env config
# Start the application in production mode using docker-compose (with build). # Start the application in production mode using docker-compose (with build).
docker-compose up -d --build docker-compose up -d --build
@@ -78,9 +123,10 @@ delete:
- docker rm -f portfolio - docker rm -f portfolio
.PHONY: browse .PHONY: browse
browse: browse: env
# Open the application in the browser at http://localhost:$(PORT) # Open the application in the browser at http://localhost:$$PORT
chromium http://localhost:$(PORT) @$(call _require_env,PORT); \
chromium "http://localhost:$$PORT"
.PHONY: install .PHONY: install
install: install:
@@ -92,6 +138,23 @@ install-dev:
# Install runtime and developer dependencies from pyproject.toml. # Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]" $(PYTHON) -m pip install -e ".[dev]"
.PHONY: i18n
i18n: env config
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: i18n-ui
i18n-ui: env
# Fill missing interface translations in app/i18n/ui/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--catalog ui \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: lint-actions .PHONY: lint-actions
lint-actions: lint-actions:
# Lint GitHub Actions workflows. # Lint GitHub Actions workflows.
@@ -108,8 +171,23 @@ lint-docker:
# Lint the Dockerfile. # Lint the Dockerfile.
docker run --rm -i hadolint/hadolint < Dockerfile docker run --rm -i hadolint/hadolint < Dockerfile
.PHONY: lint-yaml
lint-yaml: install-dev
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
$(PYTHON) -m yamllint --strict .
.PHONY: lint-js
lint-js: node-deps
# Lint the browser and Cypress JavaScript.
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
.PHONY: lint-shell
lint-shell:
# Lint the shell scripts.
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
.PHONY: test-lint .PHONY: test-lint
test-lint: test-lint: install
# Run lint guardrail tests. # Run lint guardrail tests.
$(PYTHON) -m unittest discover -s tests/lint -t . $(PYTHON) -m unittest discover -s tests/lint -t .
@@ -129,7 +207,7 @@ test-security: install
$(PYTHON) -m unittest discover -s tests/security -t . $(PYTHON) -m unittest discover -s tests/security -t .
.PHONY: lint .PHONY: lint
lint: lint-actions lint-python lint-docker test-lint lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
# Run the full lint suite. # Run the full lint suite.
.PHONY: security .PHONY: security
@@ -139,9 +217,20 @@ security: install-dev test-security
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt $(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt $(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: node-deps
node-deps:
# Install the Cypress binary and the browser vendor assets into app/.
cd app && npm install
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: test-e2e: env config node-deps
# Run Cypress end-to-end tests via act (stop portfolio container to free port first). # Run Cypress against a locally started Flask app — no act, no runner image.
@$(call _require_env,PORT); \
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
.PHONY: test-e2e-act
test-e2e-act:
# Run the CI end-to-end job through act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true -docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e $(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
-docker start portfolio 2>/dev/null || true -docker start portfolio 2>/dev/null || true
@@ -165,5 +254,8 @@ ci: lint security test-unit test-integration test-e2e
# Run the local CI suite. # Run the local CI suite.
.PHONY: test .PHONY: test
test: ci test:
# Run the full validation suite. # Run every local suite in a project virtualenv, since PEP 668 refuses pip on
# a system Python, and keep going so each failing suite reports.
@[ -x "$(TEST_PYTHON)" ] || python3 -m venv "$(TEST_VENV)"
$(MAKE) --keep-going ci PYTHON="$(TEST_PYTHON)"

View File

@@ -4,6 +4,8 @@
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes. A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
![PortUI screenshot](assets/img/screenshot.png)
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces. > 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo) > 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site) > 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)
@@ -20,6 +22,8 @@ A lightweight, Docker-powered portfolio/landing-page generator—fully customiza
Auto-cache assets for lightning-fast loading. Auto-cache assets for lightning-fast loading.
- **Responsive Design** - **Responsive Design**
Built on Bootstrap; looks great on desktop, tablet & mobile. Built on Bootstrap; looks great on desktop, tablet & mobile.
- **184 Languages**
Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
- **YAML-Driven** - **YAML-Driven**
All content & structure defined in a simple `config.yaml`. All content & structure defined in a simple `config.yaml`.
- **CLI Control** - **CLI Control**
@@ -137,12 +141,75 @@ company:
--- ---
## 🌍 Languages
Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display
name in its own script and a writing direction. The interface ships translated
for 29 of them; the rest fall back to English string by string until a
catalogue is filled. `/` serves the best match for the visitor's
`Accept-Language` header, `/<code>/` forces one, and a switcher in the navbar
lists them all. The ten right-to-left languages get `dir="rtl"` and Bootstrap's RTL
stylesheet automatically.
Translations live in two catalogues, both keyed by the English source string:
| Path | Tracked | Holds |
| --- | --- | --- |
| `app/i18n/ui/<code>.yaml` | yes | Interface strings. Shipped for 29 languages; English is the source and has no file. |
| `app/i18n/content/<code>.yaml` | no | Your `config.yaml` prose, generated per deployment. |
A string with no catalogue entry falls back to English, so a half-filled
catalogue degrades instead of breaking.
Fill the content catalogues from a [LibreTranslate](https://libretranslate.com/)
instance — set `LIBRETRANSLATE_URL` in `.env`, then:
```bash
make i18n
```
This fills the interface strings of the languages that ship no catalogue as
well. Existing entries are never overwritten, and a string the shipped
catalogue already covers is never requested, so corrections you make by hand
survive later runs.
`name`, `title`, `description`, `text`, `warning`, `info` and `subtitel` are
translated; `url`, `link_text`, `identifier` and icon classes never are.
A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so
list the brands in `app/i18n/keep.txt`, one per line — they are then stored as
themselves in every language and cost no request:
```
# Strings utils/i18n_sync.py stores as themselves instead of translating.
Mastodon
Nextcloud
freelancermap.de
```
Add one-off entries with `--keep Foo Bar`, or point somewhere else with
`--keep-file`. A protected string never replaces an entry you already wrote by
hand.
---
## 🚢 Production Deployment ## 🚢 Production Deployment
* Use a reverse proxy (NGINX/Apache). * Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS. * Secure with SSL/TLS.
* Swap to a production database if needed. * Swap to a production database if needed.
Because every page carries a canonical URL and 184 `hreflang` alternates, two
details of the proxy setup now matter:
* **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated.
Left empty, the app reflects whatever `Host` header arrives into its canonical,
`hreflang` and redirect URLs — so a shared cache in front of it can be made to
store a redirect pointing somewhere else.
* **Have the proxy send `X-Forwarded-Proto`.** Without it the app cannot know TLS
terminated upstream and every canonical URL claims `http://`. `X-Forwarded-Host`
is deliberately *not* trusted; set `Host` to the public name instead.
--- ---
## 📜 License ## 📜 License

View File

@@ -3,24 +3,31 @@ import os
import requests import requests
import yaml import yaml
from flask import Flask, current_app, render_template from flask import Flask, current_app, make_response, render_template, request, url_for
from markupsafe import Markup from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
try: try:
from app.utils import i18n
from app.utils.asset_resolver import asset_src, resolve_asset_cache
from app.utils.cache_manager import CacheManager from app.utils.cache_manager import CacheManager
from app.utils.compute_card_classes import compute_card_classes from app.utils.compute_card_classes import compute_card_classes
from app.utils.configuration_resolver import ConfigurationResolver from app.utils.configuration_resolver import ConfigurationResolver
except ImportError: # pragma: no cover - supports running from the app/ directory. except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.asset_resolver import asset_src, resolve_asset_cache
from utils.cache_manager import CacheManager from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver from utils.configuration_resolver import ConfigurationResolver
from utils import i18n
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production") FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1") FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000))) FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
# Initialize the CacheManager # Initialize the CacheManager
cache_manager = CacheManager() cache_manager = CacheManager()
@@ -40,32 +47,43 @@ def load_config(app):
resolver = ConfigurationResolver(config) resolver = ConfigurationResolver(config)
resolver.resolve_links() resolver.resolve_links()
app.config.update(resolver.get_config()) app.config.update(resolver.get_config())
app.config["TRANSLATED_CONFIG"] = {}
i18n.clear_catalogs()
def cache_icons_and_logos(app): def cache_icons_and_logos(app):
"""Cache all icons and logos to local files, with a source fallback.""" """Resolve every icon/logo/favicon to either a local cache path or
an external URL (see ``resolve_asset_cache``)."""
for card in app.config["cards"]: for card in app.config["cards"]:
icon = card.get("icon", {}) icon = card.get("icon")
if icon.get("source"): if icon:
cached = cache_manager.cache_file(icon["source"]) resolve_asset_cache(icon, cache_manager)
icon["cache"] = cached or icon["source"]
company_logo = app.config["company"]["logo"] resolve_asset_cache(app.config["company"]["logo"], cache_manager)
cached = cache_manager.cache_file(company_logo["source"]) resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
company_logo["cache"] = cached or company_logo["source"] resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"]
platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"]
# Initialize Flask app # Initialize Flask app
app = Flask(__name__) app = Flask(__name__)
app.jinja_options = {**app.jinja_options, "autoescape": True}
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1)
def trusted_hosts(raw):
"""Parse a comma-separated host list, or None when nothing is configured.
Args:
raw: the ``TRUSTED_HOSTS`` value, possibly empty.
"""
hosts = [host.strip() for host in raw.split(",") if host.strip()]
return hosts or None
app.config["TRUSTED_HOSTS"] = trusted_hosts(os.getenv("TRUSTED_HOSTS", ""))
# Load configuration and cache assets on startup # Load configuration and cache assets on startup
load_config(app) load_config(app)
cache_icons_and_logos(app) cache_icons_and_logos(app)
@@ -83,7 +101,10 @@ def utility_processor():
except OSError: except OSError:
return "" return ""
return dict(include_svg=include_svg) def template_asset_src(asset):
return asset_src(asset, lambda filename: url_for("static", filename=filename))
return dict(include_svg=include_svg, asset_src=template_asset_src)
@app.before_request @app.before_request
@@ -94,37 +115,81 @@ def reload_config_in_dev():
cache_icons_and_logos(app) cache_icons_and_logos(app)
@app.route("/") def translated_config(lang):
def index(): """Return the configuration sections translated into ``lang``, memoized.
"""Render the main index page."""
cards = app.config["cards"] The memo is dropped by ``load_config``, so a development reload picks up
lg_classes, md_classes = compute_card_classes(cards) edited content on the next request.
apod_bg = None """
memo = app.config["TRANSLATED_CONFIG"]
if lang not in memo:
source = {section: app.config[section] for section in TRANSLATED_SECTIONS}
memo[lang] = i18n.translate_tree(source, lang)
return memo[lang]
def apod_background():
"""Return today's NASA APOD image URL, or None when unavailable."""
api_key = app.config.get("NASA_API_KEY") api_key = app.config.get("NASA_API_KEY")
if api_key: if not api_key:
return None
try:
resp = requests.get( resp = requests.get(
"https://api.nasa.gov/planetary/apod", "https://api.nasa.gov/planetary/apod",
params={"api_key": api_key}, params={"api_key": api_key},
timeout=10, timeout=10,
) )
if resp.ok: except requests.RequestException:
logging.warning("APOD lookup failed", exc_info=True)
return None
if not resp.ok:
return None
data = resp.json() data = resp.json()
if data.get("media_type") == "image": return data.get("url") if data.get("media_type") == "image" else None
apod_bg = data.get("url")
def render_index(lang):
"""Render the index page in ``lang``."""
config = translated_config(lang)
cards = config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
return render_template( return render_template(
"pages/index.html.j2", "pages/index.html.j2",
cards=cards, cards=cards,
company=app.config["company"], company=config["company"],
navigation=app.config["navigation"], navigation=config["navigation"],
platform=app.config["platform"], platform=config["platform"],
lg_classes=lg_classes, lg_classes=lg_classes,
md_classes=md_classes, md_classes=md_classes,
apod_bg=apod_bg, apod_bg=apod_background(),
lang=lang,
lang_dir=i18n.direction(lang),
languages=i18n.LANGUAGES,
ui_strings=i18n.ui_strings(lang),
t=lambda source: i18n.catalog(lang).get(source, source),
) )
@app.route("/")
def index():
"""Render the index page in the language the browser asks for."""
response = make_response(render_index(i18n.negotiate(request.accept_languages)))
response.headers["Vary"] = "Accept-Language"
return response
@app.route(f"/<any({','.join(i18n.LANGUAGES)}):lang>/")
def localized_index(lang):
"""Render the index page in an explicitly requested language."""
return render_index(lang)
if __name__ == "__main__": if __name__ == "__main__":
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
app.run( app.run(
debug=(FLASK_ENV == "development"), debug=(FLASK_ENV == "development"),
host=FLASK_HOST, host=FLASK_HOST,

View File

@@ -198,6 +198,15 @@ accounts:
url: https://s.veen.world/cloud url: https://s.veen.world/cloud
cards: cards:
- icon:
source: https://file.infinito.nexus/assets/img/logo.png
title: Infinito.Nexus
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
shared identity, asset, and observability services. The platform that hosts
this site, including the dashboard you are looking at.
url: https://infinito.nexus
link_text: infinito.nexus
iframe: true
- icon: - icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach title: Agile Coach

View File

@@ -0,0 +1,243 @@
// cypress/e2e/i18n.spec.js
const GERMAN_BROWSER = { headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } };
describe('Language negotiation', () => {
it('serves English to an English browser', () => {
cy.visit('/', { headers: { 'Accept-Language': 'en-US,en;q=0.9' } });
cy.get('html').should('have.attr', 'lang', 'en');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Imprint');
});
it('serves German to a German browser', () => {
cy.visit('/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'de');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Impressum');
});
it('falls back to English for an unsupported browser language', () => {
cy.visit('/', { headers: { 'Accept-Language': 'xx-XX' } });
cy.get('html').should('have.attr', 'lang', 'en');
});
it('lets the URL prefix override the browser language', () => {
cy.visit('/fr/', GERMAN_BROWSER);
cy.get('html').should('have.attr', 'lang', 'fr');
cy.get('footer.footer a.iframe-link').should('contain.text', 'Mentions légales');
});
it('rejects an unsupported language code', () => {
cy.request({ url: '/xx/', failOnStatusCode: false })
.its('status')
.should('eq', 404);
});
it('does not turn unrelated single-segment paths into redirects', () => {
['/robots.txt', '/favicon.ico', '/sitemap.xml'].forEach(path => {
cy.request({ url: path, failOnStatusCode: false, followRedirect: false })
.its('status')
.should('eq', 404);
});
});
it('marks the negotiated route as varying by language', () => {
cy.request('/')
.its('headers.vary')
.should('contain', 'Accept-Language');
});
});
describe('Language switcher', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/en/');
});
it('names the active language and offers every ISO 639-1 code', () => {
cy.get('#navbarDropdownLanguage')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('contain.text', 'English');
cy.get('#navbarDropdownLanguage')
.parent('.nav-item')
.find('> .dropdown-menu a.dropdown-item')
.should('have.length', 184);
});
it('marks the active language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item.active[hreflang="en"]').should('exist');
});
it('navigates to the chosen language', () => {
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu a.dropdown-item[hreflang="de"]')
.should('have.text', 'Deutsch')
.click();
cy.url().should('match', /\/de\/$/);
cy.get('html').should('have.attr', 'lang', 'de');
});
});
describe('Translated interface strings', () => {
it('translates the strings rendered by the templates', () => {
cy.visit('/de/');
cy.get('#dynamicCopyButton').should('have.text', 'Kopieren');
cy.get('#dynamicChildrenSection h6').should('have.text', 'Optionen:');
cy.get('#dynamicAlternativesSection h6').should('have.text', 'Alternativen:');
cy.get('.modal-footer button').should('have.text', 'Schließen');
});
it('exposes the catalogue to client-side code', () => {
cy.visit('/de/');
cy.window().its('I18N').should('deep.include', {
Open: 'Öffnen',
'Open Link': 'Link öffnen',
'Identifier copied to clipboard!': 'Kennung in die Zwischenablage kopiert!',
});
});
it('leaves the catalogue untranslated in the source language', () => {
cy.visit('/en/');
cy.window().its('I18N').should('deep.include', { Open: 'Open' });
});
});
describe('Strings translated by modal.js', () => {
const item = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } },
],
};
beforeEach(() => {
cy.visit('/de/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
it('translates the button of a list entry', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicAlternativesList button').should('have.text', 'Öffnen');
});
it('translates the link label when the entry has no description', () => {
cy.window().invoke('openDynamicPopup', {
...item,
url: 'https://example.com',
description: null,
});
cy.get('#dynamicModalLinkHref').should('have.text', 'Link öffnen');
});
it('translates the clipboard confirmation', () => {
cy.window().invoke('openDynamicPopup', item);
cy.get('#dynamicCopyButton').click();
cy.window()
.its('alert')
.should('have.been.calledWith', 'Kennung in die Zwischenablage kopiert!');
});
});
describe('Right-to-left languages', () => {
it('flips the document and loads the RTL stylesheet', () => {
cy.visit('/ar/');
cy.get('html').should('have.attr', 'dir', 'rtl');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('exist');
cy.get('link[href*="vendor/bootstrap/css/bootstrap.min.css"]').should('not.exist');
cy.get('body').should('have.css', 'direction', 'rtl');
});
it('keeps left-to-right languages on the default stylesheet', () => {
cy.visit('/en/');
cy.get('html').should('have.attr', 'dir', 'ltr');
cy.get('link[href*="bootstrap.rtl.min.css"]').should('not.exist');
});
it('actually serves the RTL stylesheet it links to', () => {
cy.visit('/ar/');
cy.get('link[href*="bootstrap.rtl.min.css"]')
.should('have.attr', 'href')
.then(href => {
cy.request(href).its('status').should('eq', 200);
});
});
});
describe('Translated interface details', () => {
it('translates the strings only a screen reader sees', () => {
cy.visit('/de/');
cy.get('#dynamicModal .btn-close').should('have.attr', 'aria-label', 'Schließen');
});
it('translates the alert headings', () => {
cy.visit('/fr/');
cy.get('#dynamicModalWarning h5').should('contain.text', 'Avertissement');
cy.get('#dynamicModalInfo h5').should('contain.text', 'Informations');
});
it('translates the language switcher tooltip', () => {
cy.visit('/de/');
cy.get('#navbarDropdownLanguage').should('have.attr', 'title', 'Sprache');
});
it('tags each switcher entry with its own language', () => {
cy.visit('/en/');
cy.get('.dropdown-menu a.dropdown-item[hreflang="ja"]').should(
'have.attr',
'lang',
'ja',
);
});
it('scrolls inside the language menu instead of past the page', () => {
cy.viewport(1280, 720);
cy.visit('/en/');
cy.get('#navbarDropdownLanguage').click();
cy.get('.dropdown-menu.language-menu').should($menu => {
const menu = $menu[0];
expect(menu.scrollHeight, 'taller than it shows').to.be.greaterThan(
menu.clientHeight,
);
expect(menu.getBoundingClientRect().height).to.be.lessThan(720);
expect(getComputedStyle(menu).overflowY).to.eq('auto');
});
});
it('offers the switcher in the header only', () => {
cy.viewport(1280, 720);
cy.visit('/en/');
cy.get('#navbarNavheader #navbarDropdownLanguage').should('exist');
cy.get('#navbarNavfooter #navbarDropdownLanguage').should('not.exist');
});
});
describe('Search engine metadata', () => {
beforeEach(() => {
cy.visit('/de/');
});
it('declares an alternate for every language plus a default', () => {
cy.get('link[rel="alternate"][hreflang]').should('have.length', 185);
cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist');
cy.get('link[rel="alternate"][hreflang="ja"]')
.should('have.attr', 'href')
.and('match', /\/ja\/$/);
});
it('points the canonical URL at the language actually served', () => {
cy.get('link[rel="canonical"]').should('have.attr', 'href').and('match', /\/de\/$/);
});
});

View File

@@ -0,0 +1,285 @@
// cypress/e2e/injection.spec.js
describe('Untrusted content in the modal', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
describe('markdown rendered into innerHTML', () => {
it('strips a plain script URL', () => {
open({
warning: '[click me](javascript:window.__xss = true)',
info: '![x](data:text/html;base64,PHNjcmlwdD4=)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'click me');
cy.get('#dynamicModalInfoText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL hidden behind character references', () => {
open({
warning:
'[a](&#106;avascript:window.__xss=1) [b](&#x6A;avascript:window.__xss=1)',
info: '[c](java&Tab;script:window.__xss=1) [d](java&NewLine;script:window.__xss=1)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL written as a reference-style link', () => {
open({
warning: '[click me][ref]\n\n[ref]: &#106;avascript:window.__xss=1',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML', () => {
open({
warning: '<img src=x onerror="window.__xss = true">',
info: '<a href="javascript:window.__xss = true">x</a>',
});
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML used as the text of a stripped link', () => {
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('keeps a relative link', () => {
open({ warning: 'See [the notes](/#anchor)' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', '/#anchor');
});
it('keeps the text of a link it strips', () => {
open({ warning: '[read this](javascript:window.__xss=1)' });
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
cy.window().should('not.have.property', '__xss');
});
it('keeps ordinary markdown', () => {
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', 'https://matrix.org/');
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
});
});
describe('values interpolated outside markdown', () => {
it('does not treat an interface string as markup', () => {
cy.window().then(win => {
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
it('falls back to the English source when a string is missing', () => {
cy.window().then(win => {
delete win.I18N;
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
});
it('renders no placeholder for a missing name', () => {
open({ name: undefined });
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
});
it('does not treat the name or the icon class as markup', () => {
open({
name: '<img src=x onerror="window.__xss = true">',
icon: { class: 'fa" onmouseover="window.__xss = true' },
alternatives: [
{
name: '<img src=y onerror="window.__xss = true">',
identifier: 'ALT1',
icon: { class: 'fa-alt' },
},
],
});
cy.get('#dynamicModalLabel').find('img').should('not.exist');
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
});
describe('the link the modal offers', () => {
it('drops a URL that uses an unsafe scheme', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
cy.window().should('not.have.property', '__xss');
});
it('keeps an ordinary URL', () => {
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('keeps a URL that carries surrounding whitespace', () => {
open({ url: ' https://example.com ', description: 'Good' });
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
});
it('keeps a mailto URL', () => {
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'mailto:kevin@veen.world',
);
});
it('restores the link after a popup whose URL was dropped', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('does not let one popup iframe handler outlive it', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
open({ url: 'https://b.test/', description: 'B' });
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
cy.get('#dynamicModalLinkHref').should($anchor => {
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
});
});
it('opens the current popup URL, not an earlier one', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
open({ url: 'https://b.test/', description: 'B', iframe: true });
cy.get('#dynamicModalLinkHref').click();
cy.get('#main')
.find('iframe', { timeout: 4000 })
.should('have.attr', 'src', 'https://b.test/');
});
});
});
describe('Untrusted content reaching the iframe', () => {
const AFTER_THE_FADE = 3000;
it('refuses to open a script URL handed over by the modal', () => {
cy.visit('/');
cy.window().invoke('openDynamicPopup', {
name: 'Bad',
icon: { class: 'fa fa-test' },
url: 'javascript:window.__xss = true',
description: 'Watch',
iframe: true,
});
cy.get('#dynamicModalLinkHref').click({ force: true });
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('refuses a script URL supplied through the query string', () => {
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('still opens a configured iframe target from the query string', () => {
cy.visit('/');
cy.get('a.iframe-link').first().invoke('prop', 'href').then((href) => {
cy.visit(`/?iframe=${encodeURIComponent(href)}`);
cy.get('#main')
.find('iframe', { timeout: AFTER_THE_FADE })
.should('have.attr', 'src', href);
});
});
it('refuses a foreign origin supplied through the query string', () => {
cy.visit('/?iframe=https://example.com/');
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
});
it('does not open a foreign query-string origin in a new tab', () => {
cy.visit('/?iframe=https://example.com/', {
onBeforeLoad(win) {
cy.stub(win, 'open').as('open');
cy.stub(win, 'alert');
},
});
cy.window().then((win) => win.openIframeInNewTab());
cy.get('@open').should('not.have.been.called');
});
});

78
app/eslint.config.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* Correctness only — eslint's recommended set, no stylistic rules.
*
* The browser scripts are plain <script> tags sharing one global scope: each
* file declares some functions and calls others declared elsewhere. That is why
* they are listed as globals and why no-redeclare is off — the declaring file
* would otherwise be reported for defining its own function.
*/
const js = require('@eslint/js');
const globals = require('globals');
// Vendored libraries plus the functions static/js files call across each other.
// Keep this to names that really cross a file boundary. Every superfluous
// entry is a permanent no-undef blind spot for a typo of that name.
const SHARED = {
bootstrap: 'readonly',
marked: 'readonly',
$: 'readonly',
jQuery: 'readonly',
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',
setFullWidth: 'readonly',
initFullWidthFromUrl: 'readonly',
adjustScrollContainerHeight: 'readonly',
updateCustomScrollbar: 'readonly',
};
module.exports = [
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
{
files: ['static/js/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: { ...globals.browser, ...SHARED },
},
rules: {
...js.configs.recommended.rules,
'no-redeclare': 'off',
// vars: 'local' — a top-level function here is the API other files and
// the templates call, so only unused locals are a defect.
'no-unused-vars': [
'error',
{ vars: 'local', args: 'none', caughtErrors: 'none' },
],
},
},
{
files: ['cypress/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
...globals.mocha,
cy: 'readonly',
Cypress: 'readonly',
expect: 'readonly',
assert: 'readonly',
},
},
rules: js.configs.recommended.rules,
},
{
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: globals.node,
},
rules: js.configs.recommended.rules,
},
];

46
app/i18n/keep.txt Normal file
View File

@@ -0,0 +1,46 @@
# Strings utils/i18n_sync.py stores as themselves instead of translating.
# One per line; blank lines and lines starting with # are ignored.
Baserow
Big Blue Button
Bluesky
Buy me a Coffee
Discourse
Duolingo
Eversports
Facebook
Friendica
Garmin
Gitea
GitHub
GitHub Sponsors
Infinito.Nexus
Instagram
Keycloak
LDAP
LinkedIn
Mailu
Mastodon
Matomo
Matrix
Nextcloud
Open Project
Patreon
PayPal
Peertube
Pixelfed
Signal
Snipe IT
Spotify
Taiga
Telegram
Twitter
WhatsApp
XING
YouTube
Yourls
Zoom
freelancermap.de
malt
phpMyAdmin
upwork.com

11
app/i18n/ui/aa.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Tan doorit
Close: Alif
Copy: Garraab
Identifier copied to clipboard!: Astooti tixi rakaakayih culte!
Imprint: Qaanuunih warsa
Information: Warsa
Language: Af
Open: Fak
Open Link: Tixi fak
Options: Doorit
Warning: Kassiisa

11
app/i18n/ui/ab.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативақәа
Close: Аркра
Copy: Акопиа ахыхра
Identifier copied to clipboard!: Аидентификатор абуфер ахь иқәыргылоуп!
Imprint: Азакәантә информациа
Information: Аинформациа
Language: Абызшәа
Open: Аартра
Open Link: Азхьарԥш аартра
Options: Апараметрқәа
Warning: Агәаҽанижьра

11
app/i18n/ui/ae.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Aniiā
Close: Pairi.dāraiia
Copy: Paitiš.kərənu
Identifier copied to clipboard!: Nāman paitiš.kərətəm!
Imprint: Dātō.xvarənah
Information: Vaēdiia
Language: Hizuuā
Open: Vīuuāraiia
Open Link: Paθō vīuuāraiia
Options: Vərəθra
Warning: Aiβi.saŋha

11
app/i18n/ui/af.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatiewe
Close: Maak toe
Copy: Kopieer
Identifier copied to clipboard!: Identifiseerder na knipbord gekopieer!
Imprint: Regskennisgewing
Information: Inligting
Language: Taal
Open: Maak oop
Open Link: Maak skakel oop
Options: Opsies
Warning: Waarskuwing

11
app/i18n/ui/ak.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Nneɛma foforɔ
Close: To mu
Copy: Twa
Identifier copied to clipboard!: Wɔatwa nsɛnkyerɛnneɛ no akɔ clipboard so!
Imprint: Mmara ho nsɛm
Information: Nsɛm
Language: Kasa
Open: Bue
Open Link: Bue link no
Options: Nhyehyɛe
Warning: Kɔkɔbɔ

11
app/i18n/ui/am.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: አማራጮች
Close: ዝጋ
Copy: ቅዳ
Identifier copied to clipboard!: መለያው ወደ ቅንጥብ ሰሌዳ ተቀድቷል!
Imprint: ሕጋዊ መረጃ
Information: መረጃ
Language: ቋንቋ
Open: ክፈት
Open Link: ማገናኛውን ክፈት
Options: ምርጫዎች
Warning: ማስጠንቀቂያ

11
app/i18n/ui/an.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Zarrar
Copy: Copiar
Identifier copied to clipboard!: Identificador copiau en o portafuellas!
Imprint: Aviso legal
Information: Información
Language: Idioma
Open: Ubrir
Open Link: Ubrir o vinclo
Options: Opcions
Warning: Alvertencia

11
app/i18n/ui/ar.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: بدائل
Close: إغلاق
Copy: نسخ
Identifier copied to clipboard!: تم نسخ المعرّف إلى الحافظة!
Imprint: معلومات قانونية
Information: معلومات
Language: اللغة
Open: فتح
Open Link: فتح الرابط
Options: خيارات
Warning: تحذير

11
app/i18n/ui/as.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: বিকল্পসমূহ
Close: বন্ধ কৰক
Copy: কপি কৰক
Identifier copied to clipboard!: চিনাক্তকাৰী ক্লিপবোৰ্ডলৈ কপি কৰা হ'ল!
Imprint: আইনী তথ্য
Information: তথ্য
Language: ভাষা
Open: খোলক
Open Link: লিংক খোলক
Options: বিকল্প
Warning: সতৰ্কবাণী

11
app/i18n/ui/av.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативаби
Close: Къазе
Copy: Копия гьабе
Identifier copied to clipboard!: Идентификатор буфералде копия гьабуна!
Imprint: Закон баян
Information: Хабар
Language: МацӀ
Open: Рагье
Open Link: Ссылка рагье
Options: Параметрал
Warning: ТӀаде кӀвар

11
app/i18n/ui/ay.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Yaqha ajlliwinaka
Close: Jist'antaña
Copy: Qillqaña
Identifier copied to clipboard!: Uñt'ayiri clipboard ukar qillqatawa!
Imprint: Kamachi yatiyawi
Information: Yatiyawi
Language: Aru
Open: Jist'araña
Open Link: Link jist'araña
Options: Ajlliwinaka
Warning: Iwxt'awi

11
app/i18n/ui/az.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativlər
Close: Bağla
Copy: Kopyala
Identifier copied to clipboard!: İdentifikator mübadilə buferinə kopyalandı!
Imprint: Hüquqi məlumat
Information: Məlumat
Language: Dil
Open:
Open Link: Keçidi aç
Options: Seçimlər
Warning: Xəbərdarlıq

11
app/i18n/ui/ba.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативалар
Close: Ябырға
Copy: Күсерергә
Identifier copied to clipboard!: Идентификатор алмашыу буферына күсерелде!
Imprint: Хоҡуҡи мәғлүмәт
Information: Мәғлүмәт
Language: Тел
Open: Асырға
Open Link: Һылтанманы асырға
Options: Параметрҙар
Warning: Иҫкәртеү

11
app/i18n/ui/be.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтэрнатывы
Close: Закрыць
Copy: Капіяваць
Identifier copied to clipboard!: Ідэнтыфікатар скапіяваны ў буфер абмену!
Imprint: Прававая інфармацыя
Information: Інфармацыя
Language: Мова
Open: Адкрыць
Open Link: Адкрыць спасылку
Options: Параметры
Warning: Папярэджанне

11
app/i18n/ui/bg.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Алтернативи
Close: Затвори
Copy: Копирай
Identifier copied to clipboard!: Идентификаторът е копиран в клипборда!
Imprint: Правна информация
Information: Информация
Language: Език
Open: Отвори
Open Link: Отвори връзката
Options: Опции
Warning: Предупреждение

11
app/i18n/ui/bi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Narafala jois
Close: Klosem
Copy: Kopi
Identifier copied to clipboard!: Namba blong aedentifikesen i kopi finis!
Imprint: Loa infomesen
Information: Infomesen
Language: Lanwis
Open: Openem
Open Link: Openem link
Options: Ol jois
Warning: Woning

11
app/i18n/ui/bm.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Sugandili wɛrɛw
Close: A datugu
Copy: A kopi
Identifier copied to clipboard!: Tɔgɔ taamasiyɛn kopilen don!
Imprint: Sariya kunnafoni
Information: Kunnafoni
Language: Kan
Open: A dayɛlɛ
Open Link: Jɛɲɔgɔnya dayɛlɛ
Options: Sugandiliw
Warning: Lasɔmini

11
app/i18n/ui/bn.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: অন্যান্য বিকল্প
Close: বন্ধ করুন
Copy: অনুলিপি
Identifier copied to clipboard!: শনাক্তকারী ক্লিপবোর্ডে অনুলিপি করা হয়েছে!
Imprint: আইনি তথ্য
Information: তথ্য
Language: ভাষা
Open: খুলুন
Open Link: লিঙ্ক খুলুন
Options: বিকল্প
Warning: সতর্কতা

11
app/i18n/ui/bo.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: གདམ་ག་གཞན།
Close: ཁ་རྒྱག
Copy: འདྲ་བཤུས།
Identifier copied to clipboard!: ངོས་འཛིན་རྟགས་སྦྱར་སྡེར་དུ་འདྲ་བཤུས་བྱས་ཟིན།
Imprint: ཁྲིམས་ཀྱི་གསལ་བསྒྲགས།
Information: ཆ་འཕྲིན།
Language: སྐད་ཡིག
Open: ཁ་ཕྱེ།
Open Link: སྦྲེལ་ཐག་ཁ་ཕྱེ།
Options: གདམ་ག
Warning: ཉེན་བརྡ།

11
app/i18n/ui/br.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Dibaboù all
Close: Serriñ
Copy: Eilañ
Identifier copied to clipboard!: Eilet eo bet an anaouder er golver!
Imprint: Titouroù lezennel
Information: Titouroù
Language: Yezh
Open: Digeriñ
Open Link: Digeriñ al liamm
Options: Dibarzhioù
Warning: Diwall

11
app/i18n/ui/bs.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Zatvori
Copy: Kopiraj
Identifier copied to clipboard!: Identifikator je kopiran u međuspremnik!
Imprint: Pravne informacije
Information: Informacije
Language: Jezik
Open: Otvori
Open Link: Otvori link
Options: Opcije
Warning: Upozorenje

11
app/i18n/ui/ca.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatives
Close: Tanca
Copy: Copia
Identifier copied to clipboard!: S'ha copiat l'identificador al porta-retalls!
Imprint: Avís legal
Information: Informació
Language: Idioma
Open: Obre
Open Link: Obre l'enllaç
Options: Opcions
Warning: Advertiment

11
app/i18n/ui/ce.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативаш
Close: ДӀакъовла
Copy: Копи ян
Identifier copied to clipboard!: Идентификатор буфере копи йина!
Imprint: Бакъонан хаам
Information: Хаам
Language: Мотт
Open: ДӀаелла
Open Link: ТӀе хьажорг дӀаелла
Options: Параметраш
Warning: Кхайкхам

11
app/i18n/ui/ch.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Otro na ayek
Close: Huchom
Copy: Kopia
Identifier copied to clipboard!: Ma kopia i identifier gi clipboard!
Imprint: Infotmasion lai
Information: Infotmasion
Language: Lengguahi
Open: Baba
Open Link: Baba i link
Options: Ayek
Warning: Adbietensia

11
app/i18n/ui/co.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Chjode
Copy: Cupià
Identifier copied to clipboard!: L'identificatore hè statu cupiatu in u preme-papers!
Imprint: Menzioni legali
Information: Infurmazione
Language: Lingua
Open: Apre
Open Link: Apre u ligame
Options: Uzzione
Warning: Avertimentu

11
app/i18n/ui/cr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: kotakak
Close: kipahamok
Copy: ayisinahikêwin
Identifier copied to clipboard!: kiskinowâcihcikan kî-ayisinahikâtêw!
Imprint: wiyasiwêwin kiskêyihtamowin
Information: kiskêyihtamowin
Language: pîkiskwêwin
Open: yôhtênamok
Open Link: yôhtêna mâmawikan
Options: nawasônamowina
Warning: kitahamâkêwin

11
app/i18n/ui/cs.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativy
Close: Zavřít
Copy: Kopírovat
Identifier copied to clipboard!: Identifikátor zkopírován do schránky!
Imprint: Tiráž
Information: Informace
Language: Jazyk
Open: Otevřít
Open Link: Otevřít odkaz
Options: Možnosti
Warning: Upozornění

11
app/i18n/ui/cu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Инаѧ
Close: Затвори
Copy: Списати
Identifier copied to clipboard!: Именованїе списано бысть!
Imprint: Законное вѣдѣнїе
Information: Вѣдѣнїе
Language: Ѧзыкъ
Open: Отверзи
Open Link: Отверзи съвѧзь
Options: Изволенїѧ
Warning: Предостереженїе

11
app/i18n/ui/cv.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Альтернативӑсем
Close: Хуп
Copy: Копиле
Identifier copied to clipboard!: Идентификатор буфера копиленӗ!
Imprint: Саккун информацийӗ
Information: Информаци
Language: Чӗлхе
Open: Уҫ
Open Link: Каҫӑна уҫ
Options: Параметрсем
Warning: Асӑрхаттару

11
app/i18n/ui/cy.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Dewisiadau eraill
Close: Cau
Copy: Copïo
Identifier copied to clipboard!: Wedi copïo'r dynodwr i'r clipfwrdd!
Imprint: Gwybodaeth gyfreithiol
Information: Gwybodaeth
Language: Iaith
Open: Agor
Open Link: Agor y ddolen
Options: Opsiynau
Warning: Rhybudd

11
app/i18n/ui/da.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativer
Close: Luk
Copy: Kopiér
Identifier copied to clipboard!: Identifikator kopieret til udklipsholder!
Imprint: Juridisk information
Information: Information
Language: Sprog
Open: Åbn
Open Link: Åbn link
Options: Valgmuligheder
Warning: Advarsel

11
app/i18n/ui/de.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativen
Close: Schließen
Copy: Kopieren
Identifier copied to clipboard!: Kennung in die Zwischenablage kopiert!
Imprint: Impressum
Information: Information
Language: Sprache
Open: Öffnen
Open Link: Link öffnen
Options: Optionen
Warning: Warnung

11
app/i18n/ui/dv.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ބަދަލު އިޚްތިޔާރުތައް
Close: ބަންދުކުރޭ
Copy: ކޮޕީކުރޭ
Identifier copied to clipboard!: އައިޑެންޓިފަޔަރ ކްލިޕްބޯޑަށް ކޮޕީކުރެވިއްޖެ!
Imprint: ޤާނޫނީ މަޢުލޫމާތު
Information: މަޢުލޫމާތު
Language: ބަސް
Open: ހުޅުވާ
Open Link: ލިންކު ހުޅުވާ
Options: އިޚްތިޔާރުތައް
Warning: އިންޒާރު

11
app/i18n/ui/dz.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: གདམ་ཁ་གཞན།
Close: ཁ་བསྡམས།
Copy: འདྲ་བཤུས།
Identifier copied to clipboard!: ངོས་འཛིན་འདི་འདྲ་བཤུས་འབད་ཡི།
Imprint: ཁྲིམས་དོན་བརྡ་དོན།
Information: བརྡ་དོན།
Language: ཁ་སྐད།
Open: ཁ་ཕྱེ།
Open Link: འབྲེལ་མཐུད་ཁ་ཕྱེ།
Options: གདམ་ཁ།
Warning: ཉེན་བརྡ།

11
app/i18n/ui/ee.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Tiatia bubuwo
Close: Tu
Copy: Kɔpi
Identifier copied to clipboard!: Wokɔpi dzesidede ɖe clipboard dzi!
Imprint: Se ŋuti nyatakakawo
Information: Nyatakaka
Language: Gbe
Open: Ʋu
Open Link: Ʋu kadodoa
Options: Tiatiawo
Warning: Nuxlɔ̃ame

11
app/i18n/ui/el.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Εναλλακτικές
Close: Κλείσιμο
Copy: Αντιγραφή
Identifier copied to clipboard!: Το αναγνωριστικό αντιγράφηκε στο πρόχειρο!
Imprint: Νομικές πληροφορίες
Information: Πληροφορίες
Language: Γλώσσα
Open: Άνοιγμα
Open Link: Άνοιγμα συνδέσμου
Options: Επιλογές
Warning: Προειδοποίηση

11
app/i18n/ui/eo.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativoj
Close: Fermi
Copy: Kopii
Identifier copied to clipboard!: Identigilo kopiita al la tondujo!
Imprint: Jura informo
Information: Informo
Language: Lingvo
Open: Malfermi
Open Link: Malfermi ligilon
Options: Opcioj
Warning: Averto

11
app/i18n/ui/es.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Cerrar
Copy: Copiar
Identifier copied to clipboard!: ¡Identificador copiado al portapapeles!
Imprint: Aviso legal
Information: Información
Language: Idioma
Open: Abrir
Open Link: Abrir enlace
Options: Opciones
Warning: Advertencia

11
app/i18n/ui/et.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatiivid
Close: Sulge
Copy: Kopeeri
Identifier copied to clipboard!: Identifikaator kopeeriti lõikelauale!
Imprint: Õiguslik teave
Information: Teave
Language: Keel
Open: Ava
Open Link: Ava link
Options: Valikud
Warning: Hoiatus

11
app/i18n/ui/eu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatibak
Close: Itxi
Copy: Kopiatu
Identifier copied to clipboard!: Identifikatzailea arbelean kopiatu da!
Imprint: Lege-oharra
Information: Informazioa
Language: Hizkuntza
Open: Ireki
Open Link: Ireki esteka
Options: Aukerak
Warning: Abisua

11
app/i18n/ui/fa.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: جایگزین‌ها
Close: بستن
Copy: کپی
Identifier copied to clipboard!: شناسه در کلیپ‌بورد کپی شد!
Imprint: اطلاعات حقوقی
Information: اطلاعات
Language: زبان
Open: باز کردن
Open Link: باز کردن پیوند
Options: گزینه‌ها
Warning: هشدار

11
app/i18n/ui/ff.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Suɓaaji goɗɗi
Close: Uddu
Copy: Natto
Identifier copied to clipboard!: Innde heɓtirde natta!
Imprint: Humpito laawol
Information: Humpito
Language: Ɗemngal
Open: Uddit
Open Link: Uddit jokkorde
Options: Suɓaaji
Warning: Reentino

11
app/i18n/ui/fi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Vaihtoehdot
Close: Sulje
Copy: Kopioi
Identifier copied to clipboard!: Tunniste kopioitu leikepöydälle!
Imprint: Oikeudelliset tiedot
Information: Tiedot
Language: Kieli
Open: Avaa
Open Link: Avaa linkki
Options: Valinnat
Warning: Varoitus

11
app/i18n/ui/fj.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Na ka tale e so
Close: Sogota
Copy: Kopitaka
Identifier copied to clipboard!: Sa kopitaki na ivakatakilakila!
Imprint: iTukutuku vakalawa
Information: iTukutuku
Language: Vosa
Open: Dolava
Open Link: Dolava na isema
Options: Digidigi
Warning: iVakasala

11
app/i18n/ui/fo.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativ
Close: Lat aftur
Copy: Avrita
Identifier copied to clipboard!: Eyðmerki avritað í setiborðið!
Imprint: Løgfrøðilig kunning
Information: Kunning
Language: Mál
Open: Lat upp
Open Link: Lat leinkju upp
Options: Valmøguleikar
Warning: Ávaring

11
app/i18n/ui/fr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatives
Close: Fermer
Copy: Copier
Identifier copied to clipboard!: Identifiant copié dans le presse-papiers !
Imprint: Mentions légales
Information: Informations
Language: Langue
Open: Ouvrir
Open Link: Ouvrir le lien
Options: Options
Warning: Avertissement

11
app/i18n/ui/fy.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativen
Close: Slute
Copy: Kopiearje
Identifier copied to clipboard!: Identifikaasje nei it klamboerd kopiearre!
Imprint: Kolofon
Information: Ynformaasje
Language: Taal
Open: Iepenje
Open Link: Keppeling iepenje
Options: Opsjes
Warning: Warskôging

11
app/i18n/ui/ga.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Roghanna eile
Close: Dún
Copy: Cóipeáil
Identifier copied to clipboard!: Cóipeáladh an t-aitheantóir chuig an ghearrthaisce!
Imprint: Fógra dlíthiúil
Information: Faisnéis
Language: Teanga
Open: Oscail
Open Link: Oscail an nasc
Options: Roghanna
Warning: Rabhadh

11
app/i18n/ui/gd.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Roghainnean eile
Close: Dùin
Copy: Dèan lethbhreac
Identifier copied to clipboard!: Chaidh lethbhreac dhen aithnichear a chur air an stòr-bhòrd!
Imprint: Fiosrachadh laghail
Information: Fiosrachadh
Language: Cànan
Open: Fosgail
Open Link: Fosgail an ceangal
Options: Roghainnean
Warning: Rabhadh

11
app/i18n/ui/gl.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Pechar
Copy: Copiar
Identifier copied to clipboard!: Identificador copiado no portapapeis!
Imprint: Aviso legal
Information: Información
Language: Idioma
Open: Abrir
Open Link: Abrir ligazón
Options: Opcións
Warning: Advertencia

11
app/i18n/ui/gn.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Ambue jeporavorã
Close: Mboty
Copy: Monguatia
Identifier copied to clipboard!: Oñemonguatia pe mboheraguapy!
Imprint: Léi marandu
Information: Marandu
Language: Ñe'ẽ
Open: Mbojuruja
Open Link: Mbojuruja joajuha
Options: Jeporavorã
Warning: Ñemomarandu

11
app/i18n/ui/gu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: અન્ય વિકલ્પો
Close: બંધ કરો
Copy: કૉપિ કરો
Identifier copied to clipboard!: ઓળખકર્તા ક્લિપબોર્ડ પર કૉપિ કર્યો!
Imprint: કાનૂની માહિતી
Information: માહિતી
Language: ભાષા
Open: ખોલો
Open Link: લિંક ખોલો
Options: વિકલ્પો
Warning: ચેતવણી

11
app/i18n/ui/gv.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Reihghyn elley
Close: Dooney
Copy: Jean lhiasaghey
Identifier copied to clipboard!: Ta'n enmyssagh er ny lhiasaghey!
Imprint: Fys leighoil
Information: Fys
Language: Çhengey
Open: Foshil
Open Link: Foshil y kiangley
Options: Reihghyn
Warning: Raaue

11
app/i18n/ui/ha.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Madadai
Close: Rufe
Copy: Kwafi
Identifier copied to clipboard!: An kwafi mai ganowa zuwa allon rubutu!
Imprint: Bayanin doka
Information: Bayani
Language: Harshe
Open: Buɗe
Open Link: Buɗe mahaɗi
Options: Zaɓuɓɓuka
Warning: Gargaɗi

11
app/i18n/ui/he.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: חלופות
Close: סגור
Copy: העתק
Identifier copied to clipboard!: המזהה הועתק ללוח!
Imprint: מידע משפטי
Information: מידע
Language: שפה
Open: פתח
Open Link: פתח קישור
Options: אפשרויות
Warning: אזהרה

11
app/i18n/ui/hi.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: अन्य विकल्प
Close: बंद करें
Copy: कॉपी करें
Identifier copied to clipboard!: पहचानकर्ता क्लिपबोर्ड पर कॉपी हो गया!
Imprint: कानूनी सूचना
Information: जानकारी
Language: भाषा
Open: खोलें
Open Link: लिंक खोलें
Options: विकल्प
Warning: चेतावनी

11
app/i18n/ui/ho.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Ma haida
Close: Koua
Copy: Kopi
Identifier copied to clipboard!: Ladana ia kopi vadaeni!
Imprint: Taravatu sivaraina
Information: Sivaraina
Language: Gado
Open: Kehoa
Open Link: Link kehoa
Options: Abia hidi
Warning: Hadibaia

11
app/i18n/ui/hr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Zatvori
Copy: Kopiraj
Identifier copied to clipboard!: Identifikator je kopiran u međuspremnik!
Imprint: Pravne informacije
Information: Informacije
Language: Jezik
Open: Otvori
Open Link: Otvori poveznicu
Options: Mogućnosti
Warning: Upozorenje

11
app/i18n/ui/ht.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Lòt chwa
Close: Fèmen
Copy: Kopye
Identifier copied to clipboard!: Idantifyan an kopye nan klipbòd la!
Imprint: Enfòmasyon legal
Information: Enfòmasyon
Language: Lang
Open: Louvri
Open Link: Louvri lyen an
Options: Opsyon
Warning: Avètisman

11
app/i18n/ui/hu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatívák
Close: Bezárás
Copy: Másolás
Identifier copied to clipboard!: Az azonosító a vágólapra másolva!
Imprint: Impresszum
Information: Információ
Language: Nyelv
Open: Megnyitás
Open Link: Hivatkozás megnyitása
Options: Lehetőségek
Warning: Figyelmeztetés

11
app/i18n/ui/hy.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Այլընտրանքներ
Close: Փակել
Copy: Պատճենել
Identifier copied to clipboard!: Նույնացուցիչը պատճենվեց սեղմատախտակին!
Imprint: Իրավական տեղեկություններ
Information: Տեղեկություն
Language: Լեզու
Open: Բացել
Open Link: Բացել հղումը
Options: Ընտրանքներ
Warning: Զգուշացում

11
app/i18n/ui/hz.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Ovikeṱa oviṱjaṱa
Close: Pata
Copy: Hiṱa
Identifier copied to clipboard!: Ondjiviro ya hiṱwa!
Imprint: Omambo woveta
Information: Omambo
Language: Ombango
Open: Paturura
Open Link: Paturura omukambo
Options: Ovikeṱa
Warning: Omaṱoororo

11
app/i18n/ui/ia.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativas
Close: Clauder
Copy: Copiar
Identifier copied to clipboard!: Identificator copiate al area de transferentia!
Imprint: Information legal
Information: Information
Language: Lingua
Open: Aperir
Open Link: Aperir ligamine
Options: Optiones
Warning: Advertimento

11
app/i18n/ui/id.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatif
Close: Tutup
Copy: Salin
Identifier copied to clipboard!: Pengenal disalin ke papan klip!
Imprint: Informasi hukum
Information: Informasi
Language: Bahasa
Open: Buka
Open Link: Buka tautan
Options: Opsi
Warning: Peringatan

11
app/i18n/ui/ie.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatives
Close: Clúder
Copy: Copiar
Identifier copied to clipboard!: Identificator copiat al paperiere!
Imprint: Information legal
Information: Information
Language: Lingue
Open: Aperter
Open Link: Aperter li ligament
Options: Optiones
Warning: Avise

11
app/i18n/ui/ig.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Nhọrọ ndị ọzọ
Close: Mechie
Copy: Detuo
Identifier copied to clipboard!: Edepụtara njirimara na klipbọọdụ!
Imprint: Ozi gbasara iwu
Information: Ozi
Language: Asụsụ
Open: Mepee
Open Link: Mepee njikọ
Options: Nhọrọ
Warning: Ịdọ aka na ntị

11
app/i18n/ui/ii.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ꀋꅉ
Close:
Copy:
Identifier copied to clipboard!: ꂓꂷ ꀕꀐ!
Imprint: ꃀꇖ ꌋꑍ
Information: ꌋꑍ
Language:
Open:
Open Link: ꇤꉈ
Options: ꀋꅉ
Warning: ꋍꅉ

11
app/i18n/ui/ik.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Allat piksrat
Close: Umiksiuŋ
Copy: Aglaakkaŋ
Identifier copied to clipboard!: Atiŋa aglaaksiruq!
Imprint: Maligutaq ilisimaaġutit
Information: Ilisimaaġutit
Language: Uqausiq
Open: Aŋmaġuŋ
Open Link: Aŋmaġuŋ link
Options: Piksrat
Warning: Kaŋiqsiġutit

11
app/i18n/ui/io.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternativi
Close: Klozar
Copy: Kopiar
Identifier copied to clipboard!: Identigilo kopiesis a la tondilo!
Imprint: Legala informo
Information: Informo
Language: Linguo
Open: Apertar
Open Link: Apertar ligilo
Options: Opcioni
Warning: Averto

11
app/i18n/ui/is.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Aðrir kostir
Close: Loka
Copy: Afrita
Identifier copied to clipboard!: Auðkenni afritað á klippispjald!
Imprint: Lagalegar upplýsingar
Information: Upplýsingar
Language: Tungumál
Open: Opna
Open Link: Opna tengil
Options: Valkostir
Warning: Viðvörun

11
app/i18n/ui/it.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternative
Close: Chiudi
Copy: Copia
Identifier copied to clipboard!: Identificatore copiato negli appunti!
Imprint: Note legali
Information: Informazioni
Language: Lingua
Open: Apri
Open Link: Apri collegamento
Options: Opzioni
Warning: Avviso

11
app/i18n/ui/iu.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ᐊᓯᖏᑦ ᓂᕈᐊᒐᒃᓴᐃᑦ
Close: ᒪᑐᐃᕐᓗᒍ
Copy: ᐊᔾᔨᓕᐅᕐᓗᒍ
Identifier copied to clipboard!: ᓇᓗᓇᐃᒃᑯᑕᖅ ᐊᔾᔨᓕᐅᖅᑕᐅᔪᖅ!
Imprint: ᒪᓕᒐᓕᕆᓂᕐᒧᑦ ᑐᓴᒐᒃᓴᖅ
Information: ᑐᓴᒐᒃᓴᖅ
Language: ᐅᖃᐅᓯᖅ
Open: ᐅᒃᑯᐃᓗᒍ
Open Link: ᐊᑐᕆᐊᖅ ᐅᒃᑯᐃᓗᒍ
Options: ᓂᕈᐊᒐᒃᓴᐃᑦ
Warning: ᖃᐅᔨᒋᐊᕐᓂᖅ

11
app/i18n/ui/ja.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: 代替
Close: 閉じる
Copy: コピー
Identifier copied to clipboard!: 識別子をクリップボードにコピーしました!
Imprint: 法的情報
Information: 情報
Language: 言語
Open: 開く
Open Link: リンクを開く
Options: オプション
Warning: 警告

11
app/i18n/ui/jv.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Alternatif
Close: Tutup
Copy: Salin
Identifier copied to clipboard!: Pengenal disalin menyang papan klip!
Imprint: Informasi hukum
Information: Informasi
Language: Basa
Open: Bukak
Open Link: Bukak pranala
Options: Pilihan
Warning: Pèngetan

11
app/i18n/ui/ka.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ალტერნატივები
Close: დახურვა
Copy: კოპირება
Identifier copied to clipboard!: იდენტიფიკატორი დაკოპირდა ბუფერში!
Imprint: იურიდიული ინფორმაცია
Information: ინფორმაცია
Language: ენა
Open: გახსნა
Open Link: ბმულის გახსნა
Options: პარამეტრები
Warning: გაფრთხილება

11
app/i18n/ui/kg.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Mapona ya nkaka
Close: Kanga
Copy: Kopia
Identifier copied to clipboard!: Kinsinsu me kopiama!
Imprint: Nsangu ya nsiku
Information: Nsangu
Language: Ndinga
Open: Kangula
Open Link: Kangula nkangu
Options: Mapona
Warning: Lukebisu

11
app/i18n/ui/ki.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Mathuura mangĩ
Close: Hinga
Copy: Kobia
Identifier copied to clipboard!: Kĩmenyithia nĩ kĩakobio!
Imprint: Ũhoro wa watho
Information: Ũhoro
Language: Rũthiomi
Open: Hingũra
Open Link: Hingũra kĩhoti
Options: Mathuura
Warning: Mũkaana

11
app/i18n/ui/kj.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Omahoololo vali
Close: Pata
Copy: Shanga
Identifier copied to clipboard!: Edina la shangwa!
Imprint: Omauyelele opaveta
Information: Omauyelele
Language: Elaka
Open: Yeulula
Open Link: Yeulula ekwatakanifo
Options: Omahoololo
Warning: Elondwelo

11
app/i18n/ui/kk.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Баламалар
Close: Жабу
Copy: Көшіру
Identifier copied to clipboard!: Идентификатор алмасу буферіне көшірілді!
Imprint: Құқықтық ақпарат
Information: Ақпарат
Language: Тіл
Open: Ашу
Open Link: Сілтемені ашу
Options: Параметрлер
Warning: Ескерту

11
app/i18n/ui/kl.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Allat periarfissat
Close: Matuguk
Copy: Assiliiguk
Identifier copied to clipboard!: Kinaassusersiut assilineqarpoq!
Imprint: Inatsisitigut paasissutissat
Information: Paasissutissat
Language: Oqaatsit
Open: Ammaruk
Open Link: Aqqutissiaq ammaruk
Options: Periarfissat
Warning: Mianersoqqussut

11
app/i18n/ui/km.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ជម្រើសផ្សេងទៀត
Close: បិទ
Copy: ចម្លង
Identifier copied to clipboard!: បានចម្លងអត្តសញ្ញាណទៅក្ដារតម្បៀតខ្ទាស់!
Imprint: ព័ត៌មានផ្លូវច្បាប់
Information: ព័ត៌មាន
Language: ភាសា
Open: បើក
Open Link: បើកតំណ
Options: ជម្រើស
Warning: ការព្រមាន

11
app/i18n/ui/kn.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: ಪರ್ಯಾಯಗಳು
Close: ಮುಚ್ಚಿ
Copy: ನಕಲಿಸಿ
Identifier copied to clipboard!: ಗುರುತಿಸುವಿಕೆಯನ್ನು ಕ್ಲಿಪ್ಬೋರ್ಡ್ಗೆ ನಕಲಿಸಲಾಗಿದೆ!
Imprint: ಕಾನೂನು ಮಾಹಿತಿ
Information: ಮಾಹಿತಿ
Language: ಭಾಷೆ
Open: ತೆರೆಯಿರಿ
Open Link: ಲಿಂಕ್ ತೆರೆಯಿರಿ
Options: ಆಯ್ಕೆಗಳು
Warning: ಎಚ್ಚರಿಕೆ

11
app/i18n/ui/ko.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: 대안
Close: 닫기
Copy: 복사
Identifier copied to clipboard!: 식별자를 클립보드에 복사했습니다!
Imprint: 법적 고지
Information: 정보
Language: 언어
Open: 열기
Open Link: 링크 열기
Options: 옵션
Warning: 경고

11
app/i18n/ui/kr.yaml Normal file
View File

@@ -0,0 +1,11 @@
Alternatives: Kəla gade
Close: Kaltə
Copy: Kopi
Identifier copied to clipboard!: Suna kopi dəwo!
Imprint: Labar shariya
Information: Labar
Language: Təla
Open: Buwo
Open Link: Link buwo
Options: Kəla
Warning: Dəwo

Some files were not shown because too many files have changed in this diff Show More