From 0c5f29303beac184327c38e1d3c15da725dfa04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 13:51:15 +0100 Subject: [PATCH] docs(backup): say what account copies and the JSON import can do Several surfaces promised more than the per-account copies deliver: - The full-backup export card said it had the same shape as the weekly copy. It is a portable export with documents as metadata only; it now says so, and that it cannot recreate an account on another server. - The export page offered to "import from a backup". The JSON import adds measurements and mood entries; it is now named that way, and its help states the 16 MB and 10,000-entries-per-list limits from the constants the route enforces. - The weekly copy is labelled an account rollback on this server, and the backups page says a copy cannot rebuild a lost server. The upload says the account must exist here. Two restore refusals pointed to a "disaster-recovery" copy; they now name the copies that exist. - The instance-settings restore option was described as being for rebuilding a host. docs/ops/backup-restore.md is rewritten short and accurate: what each copy is, the deletion grace window, that database dumps keep a deleted account until they age out, how to restore one account, and a link to the disaster-recovery procedure for a lost server. --- docs/api/openapi.yaml | 5 +- docs/ops/backup-restore.md | 596 ++++-------------- messages/de.json | 21 +- messages/en.json | 21 +- messages/es.json | 21 +- messages/fr.json | 21 +- messages/it.json | 21 +- messages/ko.json | 21 +- messages/nl.json | 21 +- messages/pl.json | 21 +- messages/pt-BR.json | 21 +- .../backups-restore-refusal-notice.test.tsx | 2 +- .../import-panel/json-import-card.tsx | 15 +- src/lib/export/app-settings-backup.ts | 8 +- src/lib/import/json/limits.ts | 7 + src/lib/import/json/schema.ts | 5 +- src/lib/openapi/routes/admin-backups.ts | 2 +- 17 files changed, 258 insertions(+), 571 deletions(-) create mode 100644 src/lib/import/json/limits.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ff5fabb48..abe86ff15 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -37908,7 +37908,10 @@ components: `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only - when rebuilding a host from a snapshot, because those settings apply to every account on the installation." + when this server's instance settings themselves need to go back to the ones in the copy, because those settings + apply to every account on the installation. An account copy restores one existing account on this server; it + does not rebuild a lost server, which takes a database dump and the encryption keys + (`docs/ops/disaster-recovery.md`)." CreateSymptomDefinitionRequest: type: object properties: diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 09524b2c8..4a97885f2 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -1,507 +1,161 @@ -# Off-host backup & restore +# Account copies and restore -HealthLog ships with an optional daily off-host backup that ships every -user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key -(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2, -AWS S3, Backblaze B2, MinIO, etc. +HealthLog keeps copies of each account so that one account can be rolled back +on this server. These copies are per account. They cannot rebuild a lost +server: a restore writes into an account that already exists here under the +same id, and refuses a copy whose account is gone. To recover a server, you +need a database dump and the encryption keys. That procedure is in +[disaster-recovery.md](disaster-recovery.md). -The backup runs at **02:30 Europe/Berlin** every day from the worker -container (queue `data-backup-offhost`). Object key layout: +## What exists -``` -/YYYY-MM-DD/user-.json.enc -``` +| Copy | Where | When | Kept | +| ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week | +| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it | +| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted | +| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it | +| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it | -## Wire format (binary) +The weekly, pre-wipe and off-host copies carry the whole record, document +files and ECG traces included, with the database's ciphertext inside them as +stored. An uploaded copy holds whatever its file held. The personal +export is portable: readable values, and documents as metadata only. It is +meant for the person to keep and read. A restore refuses it when the account +holds any document, because the file has no document content to put back. -``` -magic = "HLBK" (4 bytes, ASCII) -version = 0x04 (1 byte) -keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key)) -iv = 12 random bytes (AES-GCM nonce) -ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY, - associated data = "healthlog/offhost-backup/v4|") -authTag = 16 bytes (AES-GCM tag, trailing) -``` +Two kinds of data are not in any account copy: workout GPS routes and sample +series. Credentials, devices, share links and account grants are left out on +purpose (see "What a restore does not touch"). -Version `0x04` (from v1.39.3) adds two things. The key id says which off-host -key an object needs, so a reader holding a retired key as well finds the right -one. The associated data is the object's own key in the bucket, -`YYYY-MM-DD/user-.json.enc`: an object copied to another account's name or -another date does not decrypt, where before it opened as if it belonged there. -The restore script passes the key it downloaded, so nothing changes for an -operator. +The JSON import in Settings → Export & import is not a restore. It adds +measurements and mood entries, up to 16 MB and 10,000 entries per list. -Four versions exist in the wild and all four restore. `0x01` encrypted the -JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the -ciphertext, which is what made them impossible to write a piece at a time — -GCM only produces the tag once the last block is in, so a leading tag means the -whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still -covers every ciphertext byte, and the reader still verifies it before returning -a single byte of plaintext. Objects already in your bucket stay readable, and -the restore script needs no flag to tell them apart. +## The deletion grace window -## Required env vars +`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an +erasure waits: -| Var | Required | Notes | -| --------------------------------- | -------- | ----------------------------------------------------------------------------------- | -| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** | -| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.r2.cloudflarestorage.com` | -| `BACKUP_S3_BUCKET` | yes | | -| `BACKUP_S3_ACCESS_KEY` | yes | | -| `BACKUP_S3_SECRET_KEY` | yes | | -| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) | -| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule | -| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") | +- **Deleting an account**, or a guardian deleting a **managed profile**, locks + the account at once. Every session, refresh token, API token, connector and + share link is revoked, sign-in is refused with the date the account is + erased, and nothing runs for the account any more: no reminders, syncs, + copies, scores or AI work. The record, its stored copies and its off-host + objects stay as they are. **Admin → Users** shows the account with its + erasure date and an **Undo deletion** button. When the window ends, the + hourly `account-deletion-purge` job erases the account and asks the bucket to + delete its off-host objects. +- **Delete all data** first stores the whole record as a copy of its own + (shown as "Before all data was deleted" in Admin → Backups), then empties the + record. An admin can restore that copy until the window ends. The off-host + objects from before the wipe are kept until then too. -## Bucket permissions +Database dumps are not purged per account: a deleted account stays in them +until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's +lifecycle rule for `database/`). The deletion dialogs say so. -The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and -`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token -covers all of them. +`0` erases immediately, as releases before 1.46 did. The Data & Privacy page +and the deletion dialogs state the configured number. -`DeleteObject` is needed because the app deletes objects in two cases, and -only those: +## Restoring an account -- **An account is deleted, or its data is wiped.** Deleting an account, - deleting a managed profile and "Delete all data" each write a deletion - request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every - `YYYY-MM-DD/user-.json.enc` of that account and removes the request once - nothing of the account is left. It runs right after the deletion and again - every night at 03:40. A copy the nightly upload was writing while the account - was deleted is removed by the upload itself. -- **The connection test** deletes its own `_healthcheck/.bin`. +In **Admin → Backups**, every stored copy has **Restore**. The dialog first +shows what the copy holds, then asks you to type `RESTORE`. The restore runs +in the background and replaces the account's data tables: every row the copy +holds comes back under its original id, and every row the account gained +after the copy was taken is deleted. If the current state is worth keeping, +press **Backup now** first. -The admin wipe of every account deletes nothing in the bucket. It is you -clearing your own host, the bucket is yours, and its copies are the way back -from a wipe pressed by mistake. If you are handing the host on, empty the -bucket yourself. +_Also restore instance settings_ writes the registration, MFA, locale, module, +notification and AI settings from the copy back for the whole server. Leave it +clear unless those settings themselves need to go back. -Earlier versions of this page said the worker never deletes an object. That -was the design until v1.39.3, and it meant a deleted account's copies stayed -in the bucket for as long as the lifecycle rule kept them, or forever without -one. If the bucket refuses the delete (a credential without `DeleteObject`), -the request stays, the job retries it every night, and **Admin → Backups → -Off-host** shows how many deletions are outstanding and the bucket's answer. - -`AbortMultipartUpload` only reaches an upload the same worker started and is -what clears the parts of a run that failed halfway. Without it, a failed -upload leaves parts that are billed and do not show in a bucket listing. - -## Bucket lifecycle (required for retention) - -The app does not expire objects by age, and nothing in the app reads -`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set -to match the number you wrote there. Without one, every nightly copy of every -account is kept forever. **Admin → Backups → Off-host** reads the bucket's -lifecycle configuration and says so when there is none, or when the credential -may not read it (grant `GetLifecycleConfiguration` to let it check): - -``` -Filter: "" (all objects) -Action: Expire after 30 days -``` - -For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**. - -## Smoke test - -After deploying, hit `POST /api/admin/backup/test` (admin-only). It -performs a 1-byte PUT + GET round-trip and returns: - -```json -{ - "data": { - "endpoint": "https://...r2.cloudflarestorage.com", - "bucket": "healthlog-backups", - "region": "auto", - "putLatencyMs": 142, - "getLatencyMs": 38, - "ok": true - }, - "error": null -} -``` - -The credentials are never returned. - -## Restore - -Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket -and run the restore script with the same backup credentials and -encryption key the backup was written under — a freshly generated -`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object: +To restore from an **off-host object**, fetch and decrypt it from a source +checkout with the backup variables of the server that wrote it: ```bash -BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \ -BACKUP_S3_BUCKET=healthlog-backups \ -BACKUP_S3_ACCESS_KEY=... \ -BACKUP_S3_SECRET_KEY=... \ -BACKUP_S3_REGION=auto \ -BACKUP_ENCRYPTION_KEY= \ -pnpm dlx tsx scripts/restore-backup.ts \ - 2026-05-08/user-clx123.json.enc \ - /tmp/restored.json +BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \ +BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \ +BACKUP_ENCRYPTION_KEY= \ +pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-.json.enc /tmp/restored.json ``` -Run this command from a source checkout with the production backup variables -exported. The script imports the full application dependency graph, which the -minimal production image does not expose as an operator scripting environment. +Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after +compression) and restore it from its row. The account `` must exist on this +server. -The script downloads the object, decrypts it, and writes the JSON dump -to disk. Importing the JSON back into a HealthLog instance is left to -the operator (use `prisma db seed` or a custom script). +Every restore, preview and upload checks first that this server holds every +application key the copy's content was written under, and refuses with the key +id named if not. Nothing is changed by a refused restore. -Restoring is not streamed and does not need to be. It holds the whole document, -because the next thing anyone does with a backup is parse it as one JSON -object, and handing back plaintext the auth tag has not yet covered would trade -the authentication for memory. It runs on your machine rather than in the -container, so give it room: a record of several hundred thousand measurements -decompresses to a few hundred megabytes, and -`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for -a 445 000-measurement account. +### What a restore does not touch -### Restoring from the backups console - -An admin can restore a stored snapshot from the browser instead of the shell. -`/admin/backups` lists one row per account and per backup type: **Backup now** -enqueues the `data-backup` pass for every account on the instance, **Download** -decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog -that first shows what the file carries and then requires the word `RESTORE` to -be typed before it will run. The restore targets the account the snapshot was -taken for, never the admin running it, and it refuses a file whose declared -owner does not match the stored row. Instance-wide settings ride along only -when you tick _Also restore instance settings_ in the dialog; leave it clear to -restore the account alone. - -A stored copy this instance can no longer open — the key that wrote it dropped -from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones -written — is refused by all three buttons with `422` and -`meta.errorCode = backup.payload.undecryptable`, before anything is read back. - -### What a backup deliberately does not carry - -Every credential-shaped row is left out, and this is not an oversight to fix: API tokens, trusted devices, step-up elevations, known devices, clinician share -links, and the account grants behind shared record access. Restoring data is -rolling a record back to a known state. Restoring an authorization is different -in kind, because a grant the owner revoked on Tuesday would come back alive out -of Monday's file with nobody deciding it and neither person told. +links and account grants are not in any copy. A restore on this server leaves +them as they are, so shared access carries on. The reasoning per model is in +`src/lib/export/backup-plan.ts`. -What this means in practice depends on where you restore to. +## Off-host copies -**Onto the same instance.** Nothing changes. The restore replaces the account's -data tables and does not touch grants, tokens or devices, so shared access -carries on exactly as it was. Somebody who had read access before the restore -still has it afterwards, now looking at the restored data. +| Variable | Required | Notes | +| --------------------------------- | -------- | --------------------------------------------------------------------- | +| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` | +| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.r2.cloudflarestorage.com` | +| `BACKUP_S3_BUCKET` | yes | | +| `BACKUP_S3_ACCESS_KEY` | yes | | +| `BACKUP_S3_SECRET_KEY` | yes | | +| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) | +| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number to put in the bucket's lifecycle rule | +| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only | -**Onto a fresh instance.** None of it comes with the file. Nobody has access to -anybody's record, every API token has to be reissued, every device re-trusted, -and both people have to invite and accept again before sharing works. That is -the fail-safe direction — access lost, never access resumed — and re-consenting -is the right amount of ceremony for handing someone your health record a second -time. Plan for it rather than discovering it. +Objects are named `YYYY-MM-DD/user-.json.enc`: the record as JSON, +gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the +object's own name as associated data and the key's id in the header. -The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where -every excluded model carries a written verdict and a structural test refuses to -let a new model land without one. +**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`, +`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers +them). It deletes only two things: the objects of an account that was erased or +wiped, once the grace window has ended, and its own connection-test object. A +deletion the bucket refuses is retried every night and shown in Admin → +Backups → Off-host. The admin wipe of every account deletes nothing in the +bucket. -## Container memory (the nightly off-host job) +**Retention.** The app never expires objects by age. Set a lifecycle rule on +the bucket (for example, expire all objects after 30 days); Admin → Backups +says when there is none. -This is the part that bites, and it bit the nightly job a release after it bit -the weekly one. The job runs inside the app process, so V8's heap limit is the -app's heap limit. Node sizes it from the container's memory cap: 1 GB gives a -524 MB old-space limit, the compose default of 1536m (since 1.40.1) gives 792 MB. A long-lived Next.js server is already holding a large share -of that before the job starts. +**Checking it works.** Admin → Backups → Off-host shows, per account, when the +last object landed: fresh, due (one night missed), stale (two missed), never, +or no record yet. A night that uploaded nothing for anybody fails the job with +the bucket's own error. Accounts pending deletion are not uploaded and are not +listed. On the 1st of each month at 04:11, a drill downloads the newest object +of every account, decrypts it and checks its shape and keys. It restores +nothing. -The uploader used to build the whole backup JSON as one string, gzipped -that whole string, ran a whole-buffer cipher pass over the result and handed -the finished buffer to a single `PutObject` — four full copies of the record -alive at once. On an account of 445 000 measurements the JSON alone is 242 MB, -and the first configured run took the container down seventeen seconds in with -`FATAL ERROR: Reached heap limit`. Because the job shares the app process, one -account's size restarted the instance for everybody on it. +**Rotating the off-host key.** Put the new key in `BACKUP_ENCRYPTION_KEY`, move +the old one to `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, restart. Remove the old key +once the lifecycle rule has expired every object written under it. -It streams now. The JSON is produced a page at a time, gzip and the cipher -consume it as it arrives, and the object goes up as a multipart upload that -holds two 8 MB parts. What the process holds is fixed by that pipeline's shape -rather than by the size of the record going through it: measured on the same -445 000-measurement account under `--max-old-space-size=450`, the old path -died and the new one finished holding tens of megabytes, writing a 9.1 MB -object that restores to the identical record. +## Keys a copy needs -Up to 1.43.4 that held for the tables that are long in rows, not for the -ones that are heavy in bytes. The stored documents and the ECG traces were -still read whole, and a vault of 190 MB cost about five times that in heap: -the bytes, their base64, the section's JSON and the preview's copies of each -row. A single account with that many documents took a 524 MB process down on -every weekly run, while 1.25 million measurements beside them fitted easily. -Both writers now read the traces a page at a time and the documents one at a -time, each document's bytes in slices of 1.5 MB. Measured on that account -under `--max-old-space-size=524`, the peak went from 930 MB (a crash) to -209 MB. The restore had the same problem in the other direction: it kept -every document of the file in memory and peaked at 1.25 GB on the same copy. -It now leaves the stored bytes out of its first read and writes the -documents one at a time from a second one, and the copy restores every -document byte for byte inside the 524 MB heap. +A copy carries the database's ciphertext as stored, so it needs the +application keys it was written under, also after a rotation. Keep a retired +`ENCRYPTION_KEYS` entry until no copy you may still restore needs it. **Admin → +Encryption → Keys the backups still need** lists them. Off-host objects also +need `BACKUP_ENCRYPTION_KEY` (or the previous key that wrote them). -One ceiling remains, and it is structural rather than a memory bound: a -multipart upload carries 10 000 parts, so 80 GB is the largest object one -account can produce. Past it the account's backup fails with a clear refusal, -is counted in the run's `offhost_backup_oversized` meta, and the pass carries -on with everybody else. +## Size and memory -### Reading a failed run +Writing, reading and restoring a copy streams: the record goes through a page +at a time, documents one at a time, and a stored copy is kept as sealed pieces +of about a megabyte. Memory does not grow with the record. Give the app +container at least 1 GB. -A nightly run that could not upload for **anybody** now fails the pg-boss job -instead of reporting success. This is the case wrong credentials, a missing -bucket and an unreachable endpoint all land in, and the target's own sentence -rides out as the failure cause — `The request signature we calculated does not -match the signature you provided`, `The specified bucket does not exist`, -`connect ECONNREFUSED`. Check `offhost_backup_uploaded` against -`offhost_backup_total_users`: before this change a run where every single -upload failed still read `ok: true`, so a bucket could stay empty while the -jobs page looked healthy. +`BACKUP_MAX_STORED_MB` (default 2048) caps one account's stored copy. An account +past it fails alone, keeps its previous copy, and is counted as +`records_oversized`; the message names the setting. -A run where SOME account got a copy still succeeds, with the rest counted in -`offhost_backup_failed`. Failing the whole queue over one account's object -would re-upload everybody's on every retry. - -### A run the process died under - -Both backup jobs carry a pg-boss heartbeat of five minutes. While a run is -alive the worker refreshes it; a run whose process died (a restart, or a -container killed for memory) stops refreshing, and pg-boss fails the job with -`job heartbeat timeout` within minutes instead of leaving it `active` until its -two- or four-hour expiry. The retries the queue already allows then pick it up. - -Each pass also notes when it started and when it got past every account -(`backup_pass_attempts`). An account whose last attempt started and never -finished is the one the process died under, and the next run takes it last, so -one record too large for the container cannot keep every account after it from -a copy. **Admin console → Backups** shows when the run in progress started and -names such an account under both the weekly and the off-host card. Seeing the -same account there twice means it needs more memory than the container has. - -### Which account has no copy - -The counts above say how many accounts were uploaded, never which. An account -whose object has failed every night for a month reads as `99 uploaded, 1 -failed` each time, which looks like weather. - -**Admin console → Backups → Off-host copies** answers it per account: when this -host last put that account's encrypted copy in the bucket, how big it was, and -how that compares with the nightly schedule. **Fresh** is inside one run, -**due** means one run produced nothing for that account, **stale** means two -did not, **never** means a run walked the account and no object has ever -landed for it, and **no record yet** means no run has recorded the account at -all. On a host without `BACKUP_S3_*` the card says off-host backup is not -configured and stops there. - -The ledger starts empty on the release that adds it, so on a host that has -been uploading nightly for months every account reads **no record yet** until -the first run after the upgrade. That is the ledger having no history, not the -bucket being empty — which is why it is a separate word from **never**, and -why the row says so rather than claiming nothing has ever reached the bucket. - -Each verdict carries six hours of slack on top of the schedule's own 24, and -that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin, -so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the -timestamp is when _that account's_ object landed, so on a cohort walked one -account at a time an account reached later tonight than last night drifts by -hours. Without the slack a healthy host would paint its whole cohort **due** -once a year. - -The verdicts come from a ledger the worker writes when the object lands -(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's -grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card -still tells the truth. A row that goes stale while the run reports success is -that account's object being refused, and the reason is in the run's -`offhost_backup_failures` meta. - -## The weekly in-database backup (`data-backup`) - -Separate from the off-host job above, and easy to confuse with it. A second -pg-boss job writes one `WEEKLY_AUTO` copy per user into the database, the same -JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` / -`ENCRYPTION_KEYS`, staying inside the instance. It is what -`/api/admin/backups//restore` reads, and an uploaded backup file is stored -the same way. - -### How a copy is stored - -From v1.39.2 a copy is kept as ordered pieces of about a megabyte in -`data_backup_chunks`, with the list of pieces on its `data_backups` row. The -writer produces the JSON a page at a time, gzips it as it goes and seals each -megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore, -preview, download) takes the pieces one at a time. Nothing on either path holds -the whole copy, so the size of a copy has nothing to do with the app's memory. -Measured on an account of 2.6 million readings in a 1 GB container, -with the web server and the worker in one process: the backup took 87 seconds -and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the -1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every -reading exactly. The container peaked at 567 MB and did not restart. What the -backup, preview and download hold on top of the running app stays at a few tens -of megabytes and does not grow with the account. A 512 MB container is too -small for the app itself: its heap limit is 259 MB and a backup of any account, -even one with 130 000 readings, runs it out of heap. - -Each piece carries, inside its encryption, which copy it belongs to, its -position, and whether it is the last. Before a restore deletes anything, every -piece is checked: a piece that is missing, moved, altered, taken from another -copy, or a copy that stops short of its last piece is refused with -`backup.payload.undecryptable`, and the account is left as it was. - -The write is one transaction. Until the new copy is complete the previous one -stays in place and readable; a run that fails halfway leaves it untouched. A -second backup of the same account started while one is running (a manual run -during the weekly one, say) waits for the first to finish and then replaces -its copy; neither leaves a partial copy behind. - -While a restore of an account's copy is queued or running, the weekly and -manual backup leave that account's copy alone and back up everyone else. The -run's `users_skipped_restoring` meta counts such accounts; the next run -replaces the copy as usual. If a copy is replaced anyway while it is being -read (a download or preview in progress when the weekly run finishes), the -reader answers `409` with `backup_changed` and "The backup was replaced by a -newer one while it was being read", not the undecryptable error, and a restore -rolls back with nothing changed. Start it again to use the new copy. A -download already sending when this happens stops partway; download again. - -Copies written before v1.39.2 are a single value in `data_backups.data` and -still restore, preview and download as they did. The next weekly run replaces -the weekly copy in the new form, and a key rotation (below) converts every -remaining one, including uploaded copies. Until then those older copies are -read as one value, as before, which needs the memory they needed when they were -written. - -Migration 0352 cannot be undone by going back to v1.39.1. That release reads -only the single value, so it finds a copy in pieces empty, and if it writes a -weekly copy into a row that has pieces, the row holds both forms. v1.39.2 then -refuses to read that row, because nothing says which of the two is current -("This backup holds both a single stored value and pieces ..."); the next -weekly backup replaces it. If you must go back, restore the database backup -you took before upgrading. - -### The size limit - -The one limit left is on storage: a copy of one account may take at most -`BACKUP_MAX_STORED_MB` megabytes (default 2048). It exists so a runaway copy -fails with a message instead of filling the database volume, which holds the -old and the new copy side by side while a backup runs. An account past it fails -for that account alone, is counted in the run's `users_failed` and -`records_oversized` meta, and keeps its previous copy; the pass carries on with -everybody else. The message names the setting. Raise it in `.env`, recreate the -app container, and make sure the database volume has room for two copies of -that size. - -Before v1.39.2 the limit was a fifth of the app's heap, 105 MB in a 1 GB -container, because the copy had to pass through the app as one value. An -account with 1.75 million readings was past it, and the only way round was more -memory. That is no longer the case: `records_oversized` means the storage limit, -never memory. - -The writer deliberately reads no heap gauge. A version that did shipped in -v1.38.6 and compared the whole process's live usage, garbage included, against -80 % of the limit. A Next.js server that has been up for a week sits at 400 MB -of largely collectable heap, so the weekly pass aborted every account on the -first chunk, including one whose whole stored copy is 1.2 MB, and reported -success while doing it. If you are on v1.38.6, a `data-backup` run that finishes -in seconds having backed up nothing is that defect and not your record. - -### Key rotation - -Rotation reaches a stored backup's envelope and nothing inside it. The -content of a disaster-recovery backup is the database's ciphertext, carried -as it is stored, and a restore writes it back verbatim: a note, a document or -a coach message inside a copy taken before a rotation still needs the key the -rotation retired. The same holds for every copy in the off-host bucket and for -every file an operator has downloaded. So the rule is: **keep a retired -`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.** -**Admin → Encryption → Keys the backups still need** lists, per key id, the -stored copies that need it with the oldest date, and when the last off-host -copy needing it expires under the bucket's lifecycle rule. Copies written -before v1.39.3 did not record their keys and are listed as such. - -Every restore, the restore preview, the admin upload and the monthly drill -check this before anything is changed: a file whose content needs a key this -server does not have (or an id whose key material differs) is refused with the -key id named, and the account is left as it was. - -Re-encrypting the content of stored backups during rotation was considered and -not done: it would mean decrypting, parsing, re-encrypting and re-writing every -copy in full, and it still could not reach the off-host copies or downloaded -files, so the rule above would be needed regardless. - -`scripts/rotate-encryption-key.ts` and the rotation in the admin console -re-seal every piece under the active key, in small batches, without reading -the backup inside. The link between a piece, its copy and its position is -inside the encryption, so it comes through rotation unchanged. - -A copy still in the older single-value form (any of them, including the -`~hlgcm1.` form v1.38.6 to v1.39.1 wrote) is not re-sealed in place, which would need the -whole copy in memory several times over. Rotation converts it into pieces -under the active key instead, one copy at a time, reading the stored value a -few megabytes at a time; the copy keeps its date. The conversion of a copy is -one transaction, so a copy that fails its check is left exactly as it was and -counted as an error. Rotation up to v1.39.1 counted every `~hlgcm1.` copy -as an error; run it again after updating before retiring the old key. - -### What a restore replaces - -A restore replaces the account's data tables; it does not merge into them. -Every row the file carries is written back under its original id, and every row -the account gained after the snapshot was taken — readings, doses, mood -entries, documents — is deleted with the rest of the class before the file is -read back. Restoring Monday's copy on Wednesday therefore costs the account -everything it recorded on Tuesday. If the current state is worth keeping, take -a fresh snapshot from the backups console before restoring the old one. - -## Rotating the off-host key - -`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate -it, set the new key there and move the old one into -`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then -restart. New objects are written under the new key; older objects stay -readable because every version-4 object names its key and older objects are -tried against each key in turn. Remove the old key once the lifecycle rule has -retired every object written under it, that is `BACKUP_RETENTION_DAYS` after -the restart. - -## Monthly restore drill (automatic) - -Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` — -04:11 on the 1st of each month) exercises the read path end-to-end: -fetch every backup object of the most recent date from the bucket (one per -account), decrypt each under the current `BACKUP_ENCRYPTION_KEY`, JSON-parse -it, and sanity-check the payload shape. It performs **no database restore** — -it validates the artefacts, not the import path. Each account is checked and -reported on its own: one object that fails is named, and the others are still -read. - -Outcomes: - -- **Success** — record counts, object age, and sizes land in the - wide-event meta (`job.restore_drill`). -- **Stale chain** — the newest object is older than 3 days: the nightly - uploader has stalled (or the lifecycle rule is too aggressive). The - drill pages via the worker error reporter (stderr + GlitchTip). -- **Failure** — empty bucket, fetch error, decryption failure (wrong or - rotated key), malformed JSON: pages the same way, once per failing object, - with the object key and how many accounts were checked. A decryption - failure right after a `BACKUP_ENCRYPTION_KEY` change means the new - key cannot read the existing objects: put the old key into - `BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key"). -- **Content under a missing application key** — the object opens, but its - content was written under an `ENCRYPTION_KEYS` entry this server no longer - has, or under an id whose key material changed. The drill decrypts the - shortest value of each section under every key id it finds, and pages naming - the key when none of them opens. A restore of that object - would write back rows nobody can open; put the key back before restoring. -- **Not configured** — deployments without the `BACKUP_S3_*` vars skip - silently (wide-event warning only). - -The drill lists the bucket to find the newest object, so it needs -`ListBucket` on top of the uploader's `PutObject`, `GetObject` and -`AbortMultipartUpload`. Cloudflare R2's **Object Read & Write** token covers -all four. +Both backup passes note which account they are on. An account whose last +attempt never finished (the process died under it) is taken last on the next +run and named in Admin → Backups, so one record too large for the container +cannot keep everyone else from a copy. diff --git a/messages/de.json b/messages/de.json index af95c8814..f7ae333e9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", "noRecoveryWarning": "Es gibt keine Wiederherstellung. Geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.", - "detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten; das Manifest der Datei nennt, was drinsteht.", + "detail": "Eine portable Kopie deiner Akte zum Aufbewahren und Lesen: Werte als einfaches JSON, Dokumente nur mit ihren Metadaten. Sie ist nicht die wöchentliche Kontokopie, mit der dieser Server ein Konto zurücksetzt, und sie kann kein Konto auf einem anderen Server neu anlegen. Einige Datenarten fehlen noch; das Manifest der Datei nennt, was enthalten ist.", "contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}" }, "json": { - "title": "JSON-Datei", + "title": "Messwerte und Stimmung (JSON)", "description": "JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.", "pasteLabel": "JSON einfügen", "fileInputLabel": "Eine JSON-Datei zum Import auswählen", @@ -5800,7 +5800,8 @@ "droppedLinks": "Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}", "keptLabels": "Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}", "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", - "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." + "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge.", + "limitsHint": "Bis {mb} MB und bis zu {rows} Einträge je Liste. Das ergänzt deine Akte um Einträge; ein vollständiges Backup lässt sich so nicht wiederherstellen." }, "csv": { "title": "CSV-Datei", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." + "subtitle": "Exportiere deine Daten oder importiere Messwerte und Einträge aus anderen Apps und Dateien." }, "about": { "title": "Über diese App", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Backups", - "subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.", - "description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.", + "subtitle": "Kontokopien in der Datenbank dieses Servers, um ein einzelnes Konto auf diesem Server zurückzusetzen.", + "description": "Jedes Konto hat eine wöchentliche Kopie, die jeden Sonntag ersetzt wird, dazu hier hochgeladene Kopien und die Kopie, die vor „Alle Daten löschen“ angelegt wird. Eine Kopie setzt ein bestehendes Konto auf diesem Server zurück. Einen verlorenen Server kann sie nicht wiederherstellen: Dafür braucht es einen Datenbank-Dump und die Verschlüsselungsschlüssel. Die Größen zeigen die verschlüsselte Kopie. Beim Herunterladen wird sie für den anfragenden Admin entschlüsselt; der Geheimtext bleibt in der Datenbank.", "runNow": "Jetzt sichern", "runEnqueued": "Backup-Job eingeplant", "runFailed": "Backup konnte nicht eingeplant werden", @@ -8171,7 +8172,7 @@ "emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr, oder erstelle jetzt einen ersten Snapshot.", "colUser": "Nutzer", "colType": "Typ", - "typeWeeklyAuto": "Wöchentlich (auto)", + "typeWeeklyAuto": "Wöchentliche Kontokopie", "typeManual": "Manuell", "colSize": "Größe", "colCreatedAt": "Letzter Snapshot", @@ -8194,7 +8195,7 @@ "downloadFailed": "Download fehlgeschlagen", "downloadStarted": "Download gestartet", "uploadTitle": "Backup hochladen", - "uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.", + "uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.", "uploadButton": "Datei hochladen", "uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.", "uploadSuccess": "Backup hochgeladen ({count} Datensätze)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Kontoeinstellung", "restoreMissingTitle": "Wiederherstellung abgelehnt: Die Datei ist unvollständig", "restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.", - "restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.", + "restoreMissingRemedy": "Nutze eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers, oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest als ausgelassen angibt, ist etwas anderes und wird normal wiederhergestellt.", "restoreMissingDocuments": "Dokumente", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Psychische Fragebögen", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.", "restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.", "restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.", - "restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.", + "restoreFailureDocument": "Die Datei enthält Dokumente ohne ihren Inhalt, wie ein portabler Export. Nutze stattdessen eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers.", "restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.", "restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.", "restoreFailureCauseTimeout": "Die Wiederherstellung hat länger gedauert, als die Datenbank zulässt, deshalb wurde alles zurückgerollt. War der Datenbank-Host ausgelastet, starte sie erneut, wenn er ruhiger ist. Das Server-Log nennt den Schritt.", diff --git a/messages/en.json b/messages/en.json index e6c733222..4555be756 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Passphrase (min. 12 characters)", "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", "noRecoveryWarning": "There is no recovery. If you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.", - "detail": "Same shape as the weekly auto-backup. Some record types are not carried yet; the file's manifest names what is in it.", + "detail": "A portable copy of your record for you to keep and read: values in plain JSON, documents as metadata only. It is not the weekly account copy this server keeps for rolling an account back, and it cannot recreate an account on another server. Some record types are not carried yet; the file's manifest names what is in it.", "contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} cycle days couldn't be written: {reason}" }, "json": { - "title": "JSON file", + "title": "Measurements and mood (JSON)", "description": "Paste JSON or upload a .json file with measurements and mood entries.", "pasteLabel": "Paste JSON", "fileInputLabel": "Choose a JSON file to import", @@ -5800,7 +5800,8 @@ "droppedLinks": "Imported without these tags, because this account has none by that name: {keys}", "keptLabels": "This account already has tags or groups with these keys under its own names, so those were kept: {keys}", "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", - "resultEmpty": "That file carried no measurements and no mood entries." + "resultEmpty": "That file carried no measurements and no mood entries.", + "limitsHint": "Up to {mb} MB and up to {rows} entries in each list. It adds entries to your record; it does not restore a full backup." }, "csv": { "title": "CSV file", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Export your data or import from a backup." + "subtitle": "Export your data, or import measurements and records from other apps and files." }, "about": { "title": "About this app", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Backups", - "subtitle": "Weekly per-user data snapshots stored in PostgreSQL.", - "description": "Each user gets one rolling snapshot per backup type. Sizes show the encrypted blob. Download decrypts the snapshot for the admin who asks; the ciphertext stays in the database.", + "subtitle": "Account copies kept in this server's database, for rolling one account back on this server.", + "description": "Each account has one weekly copy, replaced every Sunday, plus any copy uploaded here and the copy taken before \"Delete all data\". A copy brings one existing account back on this server. It cannot rebuild a lost server: that takes a database dump and the encryption keys. Sizes show the encrypted copy. Download decrypts it for the admin who asks; the ciphertext stays in the database.", "runNow": "Backup now", "runEnqueued": "Backup job enqueued", "runFailed": "Failed to enqueue backup", @@ -8171,7 +8172,7 @@ "emptyDescription": "Backups run automatically every Sunday at 03:00, or trigger one now to create the first snapshot.", "colUser": "User", "colType": "Type", - "typeWeeklyAuto": "Weekly (auto)", + "typeWeeklyAuto": "Weekly account rollback", "typeManual": "Manual", "colSize": "Size", "colCreatedAt": "Last snapshot", @@ -8194,7 +8195,7 @@ "downloadFailed": "Download failed", "downloadStarted": "Download started", "uploadTitle": "Upload backup", - "uploadDescription": "Validate a backup file and store it as a manual snapshot. Restoring is a separate step.", + "uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.", "uploadButton": "Upload file", "uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.", "uploadSuccess": "Backup uploaded ({count} records)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Account setting", "restoreMissingTitle": "Restore refused: the file is incomplete", "restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.", - "restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.", + "restoreMissingRemedy": "Use one of this server's account copies or an off-host copy, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.", "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Mental-health screeners", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "The file inside the backup belongs to a different account than the backup row.", "restoreFailureOwnerMissing": "The account this backup belongs to no longer exists.", "restoreFailureSectionMissing": "The file is incomplete: its manifest names a section it does not carry.", - "restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use a disaster-recovery backup.", + "restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use one of this server's account copies or an off-host copy instead.", "restoreFailureTimeBudget": "This backup is too large to restore within the background job's time limit.", "restoreFailureTransaction": "The database refused the restore part-way, so everything was rolled back. The server log names the error.", "restoreFailureCauseTimeout": "The restore ran longer than the database allowed, so everything was rolled back. If the database host was busy, start the restore again when it is quieter. The server log names the step.", diff --git a/messages/es.json b/messages/es.json index 13dd5d742..b3fb385fc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", "noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.", - "detail": "El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.", + "detail": "Una copia portátil de tu historial para guardarla y leerla: valores en JSON simple, documentos solo con sus metadatos. No es la copia semanal de la cuenta que este servidor guarda para restaurar una cuenta, y no puede recrear una cuenta en otro servidor. Algunos tipos de datos aún no se incluyen; el manifiesto del archivo indica qué contiene.", "contents": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} días de ciclo no se pudieron escribir: {reason}" }, "json": { - "title": "Archivo JSON", + "title": "Mediciones y estado de ánimo (JSON)", "description": "Pega JSON o sube un archivo .json con mediciones y entradas de ánimo.", "pasteLabel": "Pegar JSON", "fileInputLabel": "Elegir un archivo JSON para importar", @@ -5800,7 +5800,8 @@ "droppedLinks": "Importado sin estas etiquetas, porque esta cuenta no tiene ninguna con ese nombre: {keys}", "keptLabels": "Esta cuenta ya tiene etiquetas o grupos con estas claves y con su propio nombre, así que se han conservado: {keys}", "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", - "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." + "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo.", + "limitsHint": "Hasta {mb} MB y hasta {rows} entradas por lista. Añade entradas a tu historial; no restaura una copia de seguridad completa." }, "csv": { "title": "Archivo CSV", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Exporta tus datos o importa desde una copia de seguridad." + "subtitle": "Exporta tus datos o importa mediciones y registros de otras apps y archivos." }, "about": { "title": "Acerca de esta app", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Copias de seguridad", - "subtitle": "Instantáneas de datos semanales por usuario almacenadas en PostgreSQL.", - "description": "Cada usuario tiene una instantánea rotativa por tipo de copia. Los tamaños muestran el blob cifrado. Descargar descifra la instantánea para el administrador que la pide; el texto cifrado permanece en la base de datos.", + "subtitle": "Copias de cuentas guardadas en la base de datos de este servidor, para restaurar una cuenta en este servidor.", + "description": "Cada cuenta tiene una copia semanal, que se reemplaza cada domingo, además de las copias subidas aquí y la copia que se toma antes de «Eliminar todos los datos». Una copia restaura una cuenta existente en este servidor. No puede reconstruir un servidor perdido: para eso hacen falta un volcado de la base de datos y las claves de cifrado. Los tamaños muestran la copia cifrada. La descarga la descifra para el administrador que la pide; el texto cifrado se queda en la base de datos.", "runNow": "Hacer copia ahora", "runEnqueued": "Tarea de copia de seguridad en cola", "runFailed": "No se pudo poner en cola la copia de seguridad", @@ -8171,7 +8172,7 @@ "emptyDescription": "Las copias de seguridad se ejecutan automáticamente cada domingo a las 03:00, o inicia una ahora para crear la primera instantánea.", "colUser": "Usuario", "colType": "Tipo", - "typeWeeklyAuto": "Semanal (auto)", + "typeWeeklyAuto": "Copia semanal de la cuenta", "typeManual": "Manual", "colSize": "Tamaño", "colCreatedAt": "Última instantánea", @@ -8194,7 +8195,7 @@ "downloadFailed": "La descarga falló", "downloadStarted": "Descarga iniciada", "uploadTitle": "Subir copia de seguridad", - "uploadDescription": "Valida un archivo de copia de seguridad y guárdalo como instantánea manual. La restauración es un paso aparte.", + "uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.", "uploadButton": "Subir archivo", "uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.", "uploadSuccess": "Copia de seguridad subida ({count} registros)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Ajuste de la cuenta", "restoreMissingTitle": "Restauración rechazada: el archivo está incompleto", "restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.", - "restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.", + "restoreMissingRemedy": "Usa una de las copias de cuenta de este servidor o una copia fuera del servidor, o exporta de nuevo la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.", "restoreMissingDocuments": "Documentos", "restoreMissingWorkouts": "Entrenamientos", "restoreMissingMentalHealth": "Cuestionarios de salud mental", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "El archivo de la copia pertenece a otra cuenta que la fila de la copia.", "restoreFailureOwnerMissing": "La cuenta a la que pertenece esta copia ya no existe.", "restoreFailureSectionMissing": "El archivo está incompleto: su manifiesto nombra una sección que no contiene.", - "restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa una copia de recuperación ante desastres.", + "restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa en su lugar una de las copias de cuenta de este servidor o una copia fuera del servidor.", "restoreFailureTimeBudget": "Esta copia es demasiado grande para restaurarse dentro del límite de tiempo del proceso en segundo plano.", "restoreFailureTransaction": "La base de datos rechazó la restauración a mitad, así que se deshizo todo. El registro del servidor indica el error.", "restoreFailureCauseTimeout": "La restauración tardó más de lo que permite la base de datos, así que se deshizo todo. Si el servidor de la base de datos estaba ocupado, vuelve a iniciarla cuando esté más tranquilo. El registro del servidor indica el paso.", diff --git a/messages/fr.json b/messages/fr.json index 350e8119f..c01ea578a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", "noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.", - "detail": "Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.", + "detail": "Une copie portable de votre dossier, à conserver et à lire : valeurs en JSON simple, documents limités à leurs métadonnées. Ce n’est pas la copie hebdomadaire du compte que ce serveur garde pour restaurer un compte, et elle ne peut pas recréer un compte sur un autre serveur. Certains types de données ne sont pas encore inclus ; le manifeste du fichier indique ce qu’il contient.", "contents": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}" }, "json": { - "title": "Fichier JSON", + "title": "Mesures et humeur (JSON)", "description": "Collez du JSON ou importez un fichier .json contenant des mesures et des entrées d'humeur.", "pasteLabel": "Coller du JSON", "fileInputLabel": "Choisir un fichier JSON à importer", @@ -5800,7 +5800,8 @@ "droppedLinks": "Importé sans ces étiquettes, car ce compte n’en a aucune de ce nom : {keys}", "keptLabels": "Ce compte a déjà des étiquettes ou des groupes avec ces clés sous son propre nom, ils ont été conservés : {keys}", "resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.", - "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur." + "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur.", + "limitsHint": "Jusqu’à {mb} Mo et jusqu’à {rows} entrées par liste. Cela ajoute des entrées à votre dossier ; cela ne restaure pas une sauvegarde complète." }, "csv": { "title": "Fichier CSV", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Exportez vos données ou importez depuis une sauvegarde." + "subtitle": "Exportez vos données, ou importez des mesures et des entrées depuis d’autres applis et fichiers." }, "about": { "title": "À propos de cette app", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Sauvegardes", - "subtitle": "Instantanés de données hebdomadaires par utilisateur stockés dans PostgreSQL.", - "description": "Chaque utilisateur dispose d'un instantané glissant par type de sauvegarde. Les tailles indiquent le blob chiffré. Télécharger déchiffre l'instantané pour l'administrateur qui le demande ; le texte chiffré reste dans la base de données.", + "subtitle": "Copies de comptes conservées dans la base de données de ce serveur, pour restaurer un compte sur ce serveur.", + "description": "Chaque compte dispose d’une copie hebdomadaire, remplacée chaque dimanche, ainsi que des copies téléversées ici et de la copie prise avant « Supprimer toutes les données ». Une copie restaure un compte existant sur ce serveur. Elle ne peut pas reconstruire un serveur perdu : il faut pour cela une sauvegarde de la base de données et les clés de chiffrement. Les tailles indiquent la copie chiffrée. Le téléchargement la déchiffre pour l’administrateur qui la demande ; le texte chiffré reste dans la base de données.", "runNow": "Sauvegarder maintenant", "runEnqueued": "Tâche de sauvegarde mise en file d'attente", "runFailed": "Échec de la mise en file d'attente de la sauvegarde", @@ -8171,7 +8172,7 @@ "emptyDescription": "Les sauvegardes s'exécutent automatiquement chaque dimanche à 03:00, ou lancez-en une maintenant pour créer le premier instantané.", "colUser": "Utilisateur", "colType": "Type", - "typeWeeklyAuto": "Hebdomadaire (auto)", + "typeWeeklyAuto": "Copie hebdomadaire du compte", "typeManual": "Manuelle", "colSize": "Taille", "colCreatedAt": "Dernier instantané", @@ -8194,7 +8195,7 @@ "downloadFailed": "Échec du téléchargement", "downloadStarted": "Téléchargement démarré", "uploadTitle": "Téléverser une sauvegarde", - "uploadDescription": "Validez un fichier de sauvegarde et stockez-le comme instantané manuel. La restauration est une étape distincte.", + "uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.", "uploadButton": "Téléverser un fichier", "uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.", "uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Réglage du compte", "restoreMissingTitle": "Restauration refusée : le fichier est incomplet", "restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.", - "restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.", + "restoreMissingRemedy": "Utilisez l’une des copies de compte de ce serveur ou une copie hors du serveur, ou exportez à nouveau le compte et téléversez le nouveau fichier. Une section que le manifeste déclare omise est un autre cas et se restaure normalement.", "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Séances", "restoreMissingMentalHealth": "Questionnaires de santé mentale", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "Le fichier de la sauvegarde appartient à un autre compte que la ligne de sauvegarde.", "restoreFailureOwnerMissing": "Le compte auquel appartient cette sauvegarde n'existe plus.", "restoreFailureSectionMissing": "Le fichier est incomplet : son manifeste nomme une section qu'il ne contient pas.", - "restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilise une sauvegarde de reprise après sinistre.", + "restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilisez plutôt l’une des copies de compte de ce serveur ou une copie hors du serveur.", "restoreFailureTimeBudget": "Cette sauvegarde est trop volumineuse pour être restaurée dans le temps imparti au processus en arrière-plan.", "restoreFailureTransaction": "La base de données a refusé la restauration en cours de route, tout a donc été annulé. Le journal du serveur indique l'erreur.", "restoreFailureCauseTimeout": "La restauration a duré plus longtemps que la base de données ne l'autorise, tout a donc été annulé. Si l'hôte de la base était chargé, relancez-la quand il est plus calme. Le journal du serveur indique l'étape.", diff --git a/messages/it.json b/messages/it.json index fe7dd1edc..99fcdeec7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", "noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.", - "detail": "Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.", + "detail": "Una copia portabile della tua cartella da conservare e leggere: valori in JSON semplice, documenti solo con i metadati. Non è la copia settimanale dell’account che questo server tiene per riportare indietro un account, e non può ricreare un account su un altro server. Alcuni tipi di dati non sono ancora inclusi; il manifest del file indica cosa contiene.", "contents": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}" }, "json": { - "title": "File JSON", + "title": "Misurazioni e umore (JSON)", "description": "Incolla JSON o carica un file .json con misurazioni e voci dell'umore.", "pasteLabel": "Incolla JSON", "fileInputLabel": "Scegli un file JSON da importare", @@ -5800,7 +5800,8 @@ "droppedLinks": "Importato senza questi tag, perché questo account non ne ha nessuno con quel nome: {keys}", "keptLabels": "Questo account ha già tag o gruppi con queste chiavi e con il proprio nome, quindi sono stati mantenuti: {keys}", "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", - "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." + "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore.", + "limitsHint": "Fino a {mb} MB e fino a {rows} voci per elenco. Aggiunge voci alla tua cartella; non ripristina un backup completo." }, "csv": { "title": "File CSV", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Esporta i tuoi dati o importa da un backup." + "subtitle": "Esporta i tuoi dati o importa misurazioni e voci da altre app e file." }, "about": { "title": "Informazioni su questa app", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Backup", - "subtitle": "Istantanee settimanali dei dati per utente archiviate in PostgreSQL.", - "description": "Ogni utente ha un'istantanea a rotazione per tipo di backup. Le dimensioni mostrano il blob cifrato. Il download decifra l'istantanea per l'amministratore che la richiede; il testo cifrato resta nel database.", + "subtitle": "Copie degli account conservate nel database di questo server, per riportare indietro un account su questo server.", + "description": "Ogni account ha una copia settimanale, sostituita ogni domenica, più le copie caricate qui e la copia presa prima di «Elimina tutti i dati». Una copia riporta indietro un account esistente su questo server. Non può ricostruire un server perso: per quello servono un dump del database e le chiavi di cifratura. Le dimensioni indicano la copia cifrata. Il download la decifra per l’amministratore che la chiede; il testo cifrato resta nel database.", "runNow": "Esegui backup ora", "runEnqueued": "Processo di backup messo in coda", "runFailed": "Impossibile mettere in coda il backup", @@ -8171,7 +8172,7 @@ "emptyDescription": "I backup vengono eseguiti automaticamente ogni domenica alle 03:00, oppure avviane uno ora per creare la prima istantanea.", "colUser": "Utente", "colType": "Tipo", - "typeWeeklyAuto": "Settimanale (auto)", + "typeWeeklyAuto": "Copia settimanale dell’account", "typeManual": "Manuale", "colSize": "Dimensione", "colCreatedAt": "Ultima istantanea", @@ -8194,7 +8195,7 @@ "downloadFailed": "Download non riuscito", "downloadStarted": "Download avviato", "uploadTitle": "Carica backup", - "uploadDescription": "Convalida un file di backup e archivialo come istantanea manuale. Il ripristino è un passaggio separato.", + "uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.", "uploadButton": "Carica file", "uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.", "uploadSuccess": "Backup caricato ({count} record)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Impostazione dell'account", "restoreMissingTitle": "Ripristino rifiutato: il file è incompleto", "restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.", - "restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.", + "restoreMissingRemedy": "Usa una delle copie dell’account di questo server o una copia fuori dal server, oppure esporta di nuovo l’account e carica il nuovo file. Una sezione che il manifest dichiara omessa è un’altra cosa e si ripristina normalmente.", "restoreMissingDocuments": "Documenti", "restoreMissingWorkouts": "Allenamenti", "restoreMissingMentalHealth": "Questionari sulla salute mentale", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "Il file nel backup appartiene a un account diverso da quello della riga di backup.", "restoreFailureOwnerMissing": "L'account a cui appartiene questo backup non esiste più.", "restoreFailureSectionMissing": "Il file è incompleto: il suo manifesto nomina una sezione che non contiene.", - "restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un'esportazione portabile. Usa un backup di disaster recovery.", + "restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un’esportazione portabile. Usa invece una delle copie dell’account di questo server o una copia fuori dal server.", "restoreFailureTimeBudget": "Questo backup è troppo grande per essere ripristinato entro il tempo concesso al processo in background.", "restoreFailureTransaction": "Il database ha rifiutato il ripristino a metà, quindi tutto è stato annullato. Il log del server indica l'errore.", "restoreFailureCauseTimeout": "Il ripristino è durato più di quanto il database consente, quindi tutto è stato annullato. Se l'host del database era sotto carico, riavvialo quando è più tranquillo. Il log del server indica il passaggio.", diff --git a/messages/ko.json b/messages/ko.json index ed3ad6faf..59c9c62b9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "암호문구 (12자 이상)", "passphraseHelp": "Argon2id로 보관 파일 키를 만들고 AES-256-GCM으로 봉인해요. 파일은 .hlx 확장자로 저장해 주세요.", "noRecoveryWarning": "복구 수단이 없어요. 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.", - "detail": "주간 자동 백업과 같은 형식이에요. 아직 담기지 않는 기록 유형이 있고, 파일의 매니페스트에 무엇이 들었는지 적혀 있어요.", + "detail": "보관하고 읽기 위한 기록의 휴대용 사본이에요. 값은 일반 JSON으로, 문서는 메타데이터만 들어 있어요. 이 서버가 계정을 되돌리려고 보관하는 주간 계정 사본이 아니며, 다른 서버에서 계정을 다시 만들 수 없어요. 아직 포함되지 않는 기록 유형도 있어요. 파일의 매니페스트에 들어 있는 항목이 나와요.", "contents": "측정, 복약, 기분, 생리 주기, 검사, 아픈 날 에피소드, 알레르기, 가족력, 영양, 운동, 건강 프로필, 사용자 지표를 JSON 파일 하나에." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "생리 주기 {count}일치를 쓰지 못했어요: {reason}" }, "json": { - "title": "JSON 파일", + "title": "측정값과 기분 (JSON)", "description": "측정과 기분 기록이 담긴 JSON을 붙여 넣거나 .json 파일을 올려 주세요.", "pasteLabel": "JSON 붙여 넣기", "fileInputLabel": "불러올 JSON 파일 선택", @@ -5800,7 +5800,8 @@ "droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}", "keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}", "resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.", - "resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요." + "resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요.", + "limitsHint": "최대 {mb}MB, 목록마다 최대 {rows}개 항목까지 가능해요. 기록에 항목을 추가할 뿐이며, 전체 백업을 복원하지는 않아요." }, "csv": { "title": "CSV 파일", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "데이터를 내보내거나 백업에서 불러와요." + "subtitle": "데이터를 내보내거나, 다른 앱과 파일에서 측정값과 기록을 가져와요." }, "about": { "title": "앱 정보", @@ -8135,8 +8136,8 @@ }, "backups": { "title": "백업", - "subtitle": "PostgreSQL에 저장되는 주간 사용자별 데이터 스냅샷이에요.", - "description": "사용자마다 백업 유형별로 최신 스냅샷 하나를 굴려가며 보관해요. 크기는 암호화된 blob 기준이에요. 내려받기는 요청한 관리자에게만 스냅샷을 복호화해 주고, 암호문은 데이터베이스에 그대로 남아요.", + "subtitle": "이 서버의 데이터베이스에 보관된 계정 사본이에요. 이 서버에서 계정 하나를 되돌릴 때 써요.", + "description": "각 계정에는 매주 일요일마다 교체되는 주간 사본 하나와, 여기서 올린 사본, \"모든 데이터 삭제\" 전에 만든 사본이 있어요. 사본은 이 서버에 있는 계정 하나를 되돌려요. 잃어버린 서버를 다시 만들 수는 없어요. 그러려면 데이터베이스 덤프와 암호화 키가 필요해요. 크기는 암호화된 사본 기준이에요. 다운로드하면 요청한 관리자를 위해 복호화되고, 암호문은 데이터베이스에 남아요.", "runNow": "지금 백업", "runEnqueued": "백업 작업을 큐에 넣었어요", "runFailed": "백업을 큐에 넣지 못했어요", @@ -8146,7 +8147,7 @@ "emptyDescription": "백업은 매주 일요일 03:00에 자동으로 돌아요. 지금 실행해서 첫 스냅샷을 만들 수도 있어요.", "colUser": "사용자", "colType": "유형", - "typeWeeklyAuto": "주간 (자동)", + "typeWeeklyAuto": "주간 계정 사본", "typeManual": "수동", "colSize": "크기", "colCreatedAt": "최근 스냅샷", @@ -8169,7 +8170,7 @@ "downloadFailed": "다운로드하지 못했어요", "downloadStarted": "다운로드를 시작했어요", "uploadTitle": "백업 업로드", - "uploadDescription": "백업 파일을 검사한 뒤 수동 스냅샷으로 저장해요. 복원은 별도 단계예요.", + "uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.", "uploadButton": "파일 업로드", "uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.", "uploadSuccess": "백업을 업로드했어요 (기록 {count}개)", @@ -8229,7 +8230,7 @@ "restoreSkippedAccountSetting": "계정 설정", "restoreMissingTitle": "복원을 중단했어요. 파일이 불완전해요", "restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.", - "restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.", + "restoreMissingRemedy": "이 서버의 계정 사본이나 서버 밖 사본을 쓰거나, 계정을 다시 내보내 새 파일을 올려요. 매니페스트에서 생략됐다고 밝힌 섹션은 다른 경우이며 정상적으로 복원돼요.", "restoreMissingDocuments": "문서", "restoreMissingWorkouts": "운동", "restoreMissingMentalHealth": "정신 건강 선별 검사", @@ -8282,7 +8283,7 @@ "restoreFailureOwnerMismatch": "백업 안의 파일이 백업 행과 다른 계정의 것이에요.", "restoreFailureOwnerMissing": "이 백업이 속한 계정이 더 이상 없어요.", "restoreFailureSectionMissing": "파일이 불완전해요. 매니페스트에 적힌 항목이 파일에 없어요.", - "restoreFailureDocument": "휴대용 내보내기처럼 내용 없이 문서만 담긴 파일이에요. 재해 복구용 백업을 사용해 주세요.", + "restoreFailureDocument": "이 파일에는 휴대용 내보내기처럼 내용 없이 문서만 들어 있어요. 대신 이 서버의 계정 사본이나 서버 밖 사본을 써요.", "restoreFailureTimeBudget": "이 백업은 너무 커서 백그라운드 작업의 시간 제한 안에 복원할 수 없어요.", "restoreFailureTransaction": "데이터베이스가 복원 도중에 거부해서 모든 변경을 되돌렸어요. 서버 로그에 오류가 나와 있어요.", "restoreFailureCauseTimeout": "복원이 데이터베이스가 허용하는 시간보다 오래 걸려서 모든 변경을 되돌렸어요. 데이터베이스 호스트가 바빴다면 한가할 때 다시 시작하세요. 서버 로그에 해당 단계가 나와 있어요.", diff --git a/messages/nl.json b/messages/nl.json index 560d89ade..be0f707df 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Wachtzin (min. 12 tekens)", "passphraseHelp": "Leidt de archiefsleutel af met Argon2id en versleutelt het archief met AES-256-GCM. Sla het bestand op met de extensie .hlx.", "noRecoveryWarning": "Herstellen is niet mogelijk. Als je deze wachtzin kwijtraakt, kan het archief niet worden geopend. Bewaar hem op een veilige plek.", - "detail": "Zelfde opbouw als de wekelijkse automatische back-up. Sommige soorten gegevens worden nog niet meegenomen; het manifest van het bestand vermeldt wat erin zit.", + "detail": "Een draagbare kopie van je dossier om te bewaren en te lezen: waarden als gewone JSON, documenten alleen als metadata. Het is niet de wekelijkse accountkopie die deze server bewaart om een account terug te zetten, en het kan geen account op een andere server opnieuw aanmaken. Sommige soorten gegevens zitten er nog niet in; het manifest van het bestand noemt wat erin staat.", "contents": "Je metingen, medicatie, stemming, cyclus, labuitslagen, ziekte-episodes, allergieën, familiegeschiedenis, voedingsstoffen, trainingen, gezondheidsprofiel en eigen meetwaarden in één JSON-bestand." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} cyclusdagen konden niet worden opgeslagen: {reason}" }, "json": { - "title": "JSON-bestand", + "title": "Metingen en stemming (JSON)", "description": "Plak JSON of upload een .json-bestand met metingen en stemmingsregistraties.", "pasteLabel": "JSON plakken", "fileInputLabel": "Kies een JSON-bestand om te importeren", @@ -5800,7 +5800,8 @@ "droppedLinks": "Geïmporteerd zonder deze tags, omdat dit account er geen met die naam heeft: {keys}", "keptLabels": "Dit account heeft al tags of groepen met deze sleutels onder een eigen naam, die zijn behouden: {keys}", "resultNothing": "Er is niets geïmporteerd. Alle {skipped} registraties zijn overgeslagen.", - "resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties." + "resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties.", + "limitsHint": "Tot {mb} MB en tot {rows} items per lijst. Het voegt items toe aan je dossier; het zet geen volledige back-up terug." }, "csv": { "title": "CSV-bestand", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Exporteer je gegevens of importeer ze uit een back-up." + "subtitle": "Exporteer je gegevens, of importeer metingen en gegevens uit andere apps en bestanden." }, "about": { "title": "Over deze app", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Back-ups", - "subtitle": "Wekelijkse snapshots van de gegevens per gebruiker, opgeslagen in PostgreSQL.", - "description": "Elke gebruiker krijgt per back-uptype één doorlopende snapshot. De getoonde grootte is die van de versleutelde blob. Bij downloaden wordt de snapshot ontsleuteld voor de beheerder die erom vraagt; de versleutelde gegevens blijven in de database.", + "subtitle": "Accountkopieën in de database van deze server, om één account op deze server terug te zetten.", + "description": "Elk account heeft één wekelijkse kopie, die elke zondag wordt vervangen, plus kopieën die hier zijn geüpload en de kopie die vóór 'Alle gegevens verwijderen' wordt gemaakt. Een kopie zet een bestaand account op deze server terug. Een verloren server kan ze niet herbouwen: daarvoor zijn een databasedump en de encryptiesleutels nodig. De groottes gelden voor de versleutelde kopie. Downloaden ontsleutelt die voor de beheerder die erom vraagt; de versleutelde tekst blijft in de database.", "runNow": "Nu back-up maken", "runEnqueued": "Back-uptaak in de wachtrij gezet", "runFailed": "Back-up in de wachtrij zetten mislukt", @@ -8171,7 +8172,7 @@ "emptyDescription": "Back-ups worden automatisch elke zondag om 03:00 gemaakt. Je kunt er ook nu een starten om de eerste snapshot te maken.", "colUser": "Gebruiker", "colType": "Type", - "typeWeeklyAuto": "Wekelijks (automatisch)", + "typeWeeklyAuto": "Wekelijkse accountkopie", "typeManual": "Handmatig", "colSize": "Grootte", "colCreatedAt": "Laatste snapshot", @@ -8194,7 +8195,7 @@ "downloadFailed": "Downloaden mislukt", "downloadStarted": "Download gestart", "uploadTitle": "Back-up uploaden", - "uploadDescription": "Controleer een back-upbestand en sla het op als handmatige snapshot. Terugzetten is een aparte stap.", + "uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.", "uploadButton": "Bestand uploaden", "uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.", "uploadSuccess": "Back-up geüpload ({count} records)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Accountinstelling", "restoreMissingTitle": "Terugzetten geweigerd: het bestand is onvolledig", "restoreMissingDescription": "Volgens het eigen manifest van het bestand bevat het de onderstaande sectie, maar die ontbreekt. Er is niets gewijzigd: terugzetten vervangt een complete categorie records, dus het terugzetten van een sectie die niet in het bestand staat, zou die categorie hebben leeggemaakt.", - "restoreMissingRemedy": "Gebruik een snapshot voor noodherstel, of exporteer het account opnieuw en upload het nieuwe bestand. Een sectie die het manifest als weggelaten aanmerkt, is iets anders en wordt normaal teruggezet.", + "restoreMissingRemedy": "Gebruik een van de accountkopieën van deze server of een kopie buiten de server, of exporteer het account opnieuw en upload het nieuwe bestand. Een sectie die het manifest als weggelaten opgeeft, is iets anders en wordt gewoon teruggezet.", "restoreMissingDocuments": "Documenten", "restoreMissingWorkouts": "Trainingen", "restoreMissingMentalHealth": "Vragenlijsten mentale gezondheid", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "Het bestand in de back-up hoort bij een ander account dan de back-uprij.", "restoreFailureOwnerMissing": "Het account waar deze back-up bij hoort bestaat niet meer.", "restoreFailureSectionMissing": "Het bestand is onvolledig: het manifest noemt een sectie die er niet in staat.", - "restoreFailureDocument": "Het bestand bevat documenten zonder hun inhoud, zoals bij een overdraagbare export. Gebruik een back-up voor noodherstel.", + "restoreFailureDocument": "Het bestand bevat documenten zonder hun inhoud, zoals een draagbare export. Gebruik in plaats daarvan een van de accountkopieën van deze server of een kopie buiten de server.", "restoreFailureTimeBudget": "Deze back-up is te groot om binnen de tijdslimiet van de achtergrondtaak terug te zetten.", "restoreFailureTransaction": "De database weigerde het terugzetten halverwege, dus alles is teruggedraaid. In het serverlog staat de fout.", "restoreFailureCauseTimeout": "Het terugzetten duurde langer dan de database toestond, dus alles is teruggedraaid. Als de databasehost druk was, start het terugzetten dan opnieuw wanneer het rustiger is. In het serverlog staat de stap.", diff --git a/messages/pl.json b/messages/pl.json index 884749a07..6998eca6c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", "noRecoveryWarning": "Nie ma odzyskiwania: jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.", - "detail": "Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone; manifest pliku wskazuje, co zawiera.", + "detail": "Przenośna kopia twojej dokumentacji do przechowywania i czytania: wartości w zwykłym JSON, dokumenty tylko jako metadane. To nie jest cotygodniowa kopia konta, którą ten serwer trzyma do przywracania konta, i nie odtworzy konta na innym serwerze. Niektóre rodzaje danych nie są jeszcze uwzględnione; manifest pliku wymienia, co zawiera.", "contents": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}" }, "json": { - "title": "Plik JSON", + "title": "Pomiary i nastrój (JSON)", "description": "Wklej JSON lub prześlij plik .json z pomiarami i wpisami nastroju.", "pasteLabel": "Wklej JSON", "fileInputLabel": "Wybierz plik JSON do zaimportowania", @@ -5800,7 +5800,8 @@ "droppedLinks": "Zaimportowano bez tych tagów, bo to konto nie ma żadnego o tej nazwie: {keys}", "keptLabels": "To konto ma już tagi lub grupy z tymi kluczami pod własną nazwą, więc zostały zachowane: {keys}", "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", - "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." + "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju.", + "limitsHint": "Do {mb} MB i do {rows} wpisów na listę. Dodaje wpisy do twojej dokumentacji; nie przywraca pełnej kopii zapasowej." }, "csv": { "title": "Plik CSV", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." + "subtitle": "Eksportuj swoje dane albo importuj pomiary i wpisy z innych aplikacji i plików." }, "about": { "title": "O tej aplikacji", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Kopie zapasowe", - "subtitle": "Cotygodniowe migawki danych dla każdego użytkownika przechowywane w PostgreSQL.", - "description": "Każdy użytkownik ma jedną przesuwaną migawkę na typ kopii. Rozmiary pokazują zaszyfrowany blob. Pobieranie odszyfrowuje migawkę dla proszącego administratora; szyfrogram zostaje w bazie danych.", + "subtitle": "Kopie kont przechowywane w bazie danych tego serwera, do przywracania pojedynczego konta na tym serwerze.", + "description": "Każde konto ma jedną cotygodniową kopię, zastępowaną w każdą niedzielę, a do tego kopie przesłane tutaj i kopię wykonaną przed „Usuń wszystkie dane”. Kopia przywraca istniejące konto na tym serwerze. Nie odtworzy utraconego serwera: do tego potrzebny jest zrzut bazy danych i klucze szyfrowania. Rozmiary dotyczą zaszyfrowanej kopii. Pobieranie odszyfrowuje ją dla administratora, który o nią prosi; szyfrogram zostaje w bazie danych.", "runNow": "Utwórz kopię teraz", "runEnqueued": "Zadanie kopii zapasowej dodano do kolejki", "runFailed": "Nie udało się dodać kopii do kolejki", @@ -8171,7 +8172,7 @@ "emptyDescription": "Kopie zapasowe wykonują się automatycznie w każdą niedzielę o 03:00. Możesz też uruchomić jedną teraz, aby utworzyć pierwszą migawkę.", "colUser": "Użytkownik", "colType": "Typ", - "typeWeeklyAuto": "Cotygodniowo (auto)", + "typeWeeklyAuto": "Cotygodniowa kopia konta", "typeManual": "Ręcznie", "colSize": "Rozmiar", "colCreatedAt": "Ostatnia migawka", @@ -8194,7 +8195,7 @@ "downloadFailed": "Pobieranie nie powiodło się", "downloadStarted": "Rozpoczęto pobieranie", "uploadTitle": "Prześlij kopię zapasową", - "uploadDescription": "Sprawdź plik kopii zapasowej i zapisz go jako ręczną migawkę. Przywracanie to osobny krok.", + "uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.", "uploadButton": "Prześlij plik", "uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.", "uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Ustawienie konta", "restoreMissingTitle": "Przywracanie odrzucone: plik jest niekompletny", "restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.", - "restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.", + "restoreMissingRemedy": "Użyj jednej z kopii konta z tego serwera lub kopii spoza serwera albo wyeksportuj konto ponownie i prześlij nowy plik. Sekcja, którą manifest deklaruje jako pominiętą, to co innego i przywraca się normalnie.", "restoreMissingDocuments": "Dokumenty", "restoreMissingWorkouts": "Treningi", "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "Plik w kopii należy do innego konta niż wiersz kopii.", "restoreFailureOwnerMissing": "Konto, do którego należy ta kopia, już nie istnieje.", "restoreFailureSectionMissing": "Plik jest niekompletny: jego manifest wymienia sekcję, której nie zawiera.", - "restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak eksport przenośny. Użyj kopii do odtwarzania po awarii.", + "restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak przenośny eksport. Użyj zamiast tego jednej z kopii konta z tego serwera lub kopii spoza serwera.", "restoreFailureTimeBudget": "Ta kopia jest za duża, by przywrócić ją w limicie czasu procesu w tle.", "restoreFailureTransaction": "Baza danych odrzuciła przywracanie w trakcie, więc wszystko wycofano. Dziennik serwera podaje błąd.", "restoreFailureCauseTimeout": "Przywracanie trwało dłużej, niż pozwala baza danych, więc wszystko wycofano. Jeśli serwer bazy był obciążony, uruchom je ponownie, gdy będzie spokojniej. Dziennik serwera podaje krok.", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index ebac6679f..8da4a4bdd 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -5735,7 +5735,7 @@ "passphrasePlaceholder": "Frase-senha (mín. 12 caracteres)", "passphraseHelp": "Deriva a chave do arquivo com Argon2id e a sela com AES-256-GCM. Salve o arquivo com a extensão .hlx.", "noRecoveryWarning": "Não há recuperação. Se você perder esta frase-senha, o arquivo não poderá ser aberto. Guarde-a em um lugar seguro.", - "detail": "Mesmo formato do backup automático semanal. Alguns tipos de registro ainda não são incluídos; o manifesto do arquivo indica o que está nele.", + "detail": "Uma cópia portátil do seu registro para guardar e ler: valores em JSON simples, documentos apenas com os metadados. Não é a cópia semanal da conta que este servidor mantém para reverter uma conta, e não consegue recriar uma conta em outro servidor. Alguns tipos de dados ainda não estão incluídos; o manifesto do arquivo informa o que ele contém.", "contents": "Suas medições, medicamentos, humor, ciclo, exames, episódios de doença, alergias, histórico familiar, nutrientes, treinos, perfil de saúde e métricas personalizadas em um único arquivo JSON." } }, @@ -5781,7 +5781,7 @@ "cycleDaysFailed": "{count} dias de ciclo não puderam ser gravados: {reason}" }, "json": { - "title": "Arquivo JSON", + "title": "Medições e humor (JSON)", "description": "Cole um JSON ou envie um arquivo .json com medições e registros de humor.", "pasteLabel": "Cole o JSON", "fileInputLabel": "Escolha um arquivo JSON para importar", @@ -5800,7 +5800,8 @@ "droppedLinks": "Importado sem estas tags, porque esta conta não tem nenhuma com esse nome: {keys}", "keptLabels": "Esta conta já tem tags ou grupos com essas chaves e com nome próprio, então eles foram mantidos: {keys}", "resultNothing": "Nada foi importado. Todas as {skipped} entradas foram puladas.", - "resultEmpty": "Esse arquivo não continha medições nem registros de humor." + "resultEmpty": "Esse arquivo não continha medições nem registros de humor.", + "limitsHint": "Até {mb} MB e até {rows} entradas em cada lista. Isso adiciona entradas ao seu registro; não restaura um backup completo." }, "csv": { "title": "Arquivo CSV", @@ -5946,7 +5947,7 @@ } } }, - "subtitle": "Exporte seus dados ou importe de um backup." + "subtitle": "Exporte seus dados ou importe medições e registros de outros apps e arquivos." }, "about": { "title": "Sobre este aplicativo", @@ -8160,8 +8161,8 @@ }, "backups": { "title": "Backups", - "subtitle": "Instantâneos semanais de dados por usuário armazenados no PostgreSQL.", - "description": "Cada usuário recebe um instantâneo contínuo por tipo de backup. Os tamanhos mostram o blob criptografado. O download descriptografa o instantâneo para o administrador que o solicita; o texto cifrado permanece no banco de dados.", + "subtitle": "Cópias de contas mantidas no banco de dados deste servidor, para reverter uma conta neste servidor.", + "description": "Cada conta tem uma cópia semanal, substituída todo domingo, além das cópias enviadas aqui e da cópia feita antes de \"Excluir todos os dados\". Uma cópia reverte uma conta existente neste servidor. Ela não reconstrói um servidor perdido: para isso são necessários um dump do banco de dados e as chaves de criptografia. Os tamanhos mostram a cópia criptografada. O download a descriptografa para o administrador que pede; o texto cifrado fica no banco de dados.", "runNow": "Fazer backup agora", "runEnqueued": "Tarefa de backup enfileirada", "runFailed": "Falha ao enfileirar o backup", @@ -8171,7 +8172,7 @@ "emptyDescription": "Os backups rodam automaticamente todos os domingos às 03:00, ou acione um agora para criar o primeiro instantâneo.", "colUser": "Usuário", "colType": "Tipo", - "typeWeeklyAuto": "Semanal (automático)", + "typeWeeklyAuto": "Cópia semanal da conta", "typeManual": "Manual", "colSize": "Tamanho", "colCreatedAt": "Último instantâneo", @@ -8194,7 +8195,7 @@ "downloadFailed": "Falha no download", "downloadStarted": "Download iniciado", "uploadTitle": "Enviar backup", - "uploadDescription": "Valide um arquivo de backup e armazene-o como instantâneo manual. A restauração é uma etapa separada.", + "uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.", "uploadButton": "Enviar arquivo", "uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.", "uploadSuccess": "Backup enviado ({count} registros)", @@ -8254,7 +8255,7 @@ "restoreSkippedAccountSetting": "Configuração da conta", "restoreMissingTitle": "Restauração recusada: o arquivo está incompleto", "restoreMissingDescription": "O próprio manifesto do arquivo diz que ele carrega a seção abaixo, e não carrega. Nada foi alterado: uma restauração substitui uma classe inteira de registros, então recolocar uma seção que não está no arquivo a teria esvaziado.", - "restoreMissingRemedy": "Use um instantâneo de recuperação de desastre, ou exporte a conta novamente e envie o novo arquivo. Uma seção que o manifesto declara como omitida é uma coisa diferente e restaura normalmente.", + "restoreMissingRemedy": "Use uma das cópias de conta deste servidor ou uma cópia fora do servidor, ou exporte a conta de novo e envie o arquivo novo. Uma seção que o manifesto declara como omitida é outra coisa e é restaurada normalmente.", "restoreMissingDocuments": "Documentos", "restoreMissingWorkouts": "Treinos", "restoreMissingMentalHealth": "Triagens de saúde mental", @@ -8307,7 +8308,7 @@ "restoreFailureOwnerMismatch": "O arquivo dentro do backup pertence a uma conta diferente da linha de backup.", "restoreFailureOwnerMissing": "A conta à qual este backup pertence não existe mais.", "restoreFailureSectionMissing": "O arquivo está incompleto: seu manifesto nomeia uma seção que ele não carrega.", - "restoreFailureDocument": "O arquivo carrega documentos sem seu conteúdo, como faz uma exportação portátil. Use um backup de recuperação de desastre.", + "restoreFailureDocument": "O arquivo traz documentos sem o conteúdo, como uma exportação portátil. Use uma das cópias de conta deste servidor ou uma cópia fora do servidor.", "restoreFailureTimeBudget": "Este backup é grande demais para restaurar dentro do limite de tempo da tarefa em segundo plano.", "restoreFailureTransaction": "O banco de dados recusou a restauração no meio do caminho, então tudo foi revertido. O log do servidor nomeia o erro.", "restoreFailureCauseTimeout": "A restauração levou mais tempo do que o banco de dados permitia, então tudo foi revertido. Se o host do banco estava ocupado, inicie a restauração novamente quando estiver mais tranquilo. O log do servidor nomeia a etapa.", diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx index 1fc6089c4..1d3f116fa 100644 --- a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -64,7 +64,7 @@ describe("RestoreRefusalNotice", () => { const html = render(["documents"]); expect(html).toContain("Nothing was changed"); - expect(html).toContain("disaster-recovery snapshot"); + expect(html).toContain("account copies or an off-host copy"); }); it("renders nothing when no section was missing", () => { diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index e38c9b284..791cd76b1 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -11,6 +11,10 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { IMPORT_GUIDE_URL, MAX_PASTE_CHARS } from "./constants"; +import { + JSON_IMPORT_MAX_BYTES, + JSON_IMPORT_MAX_ROWS, +} from "@/lib/import/json/limits"; import { EXAMPLE_IMPORT_DOWNLOAD_HREF, parseImportJson, @@ -122,7 +126,7 @@ export function JsonImportResultView({ result }: { result: JsonImportResult }) { } export function JsonImportCard() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fileInputRef = useRef(null); const [text, setText] = useState(""); @@ -226,6 +230,15 @@ export function JsonImportCard() { onChange={onFileChange} /> + {/* What this import is, and its limits: it adds measurements and mood + entries; it is not a way back from a full backup. The numbers are + the ones the route enforces. */} +

+ {t("settings.sections.export.import.json.limitsHint", { + mb: JSON_IMPORT_MAX_BYTES / (1024 * 1024), + rows: JSON_IMPORT_MAX_ROWS.toLocaleString(locale), + })} +

{t("settings.sections.export.import.json.schemaHint")}{" "} {/* The import guide lives on the external docs site — the app diff --git a/src/lib/export/app-settings-backup.ts b/src/lib/export/app-settings-backup.ts index a5502c410..905dc94d4 100644 --- a/src/lib/export/app-settings-backup.ts +++ b/src/lib/export/app-settings-backup.ts @@ -9,9 +9,11 @@ * reconfigures the host for everybody on it, silently and hours or weeks out * of date: an operator putting a single record back on Wednesday has no * reason to expect Monday's registration switch and Monday's upload cap to - * come with it. Rebuilding a host from a snapshot is a real case, so the - * write stays available — as an answer the operator gives - * (`restoreInstanceSettings`), not one the account restore assumes. + * come with it. Putting the instance settings themselves back is a real + * case, so the write stays available — as an answer the operator gives + * (`restoreInstanceSettings`), not one the account restore assumes. An + * account copy does not rebuild a lost host; a database dump and the keys do + * (`docs/ops/disaster-recovery.md`). */ import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels"; import type { diff --git a/src/lib/import/json/limits.ts b/src/lib/import/json/limits.ts new file mode 100644 index 000000000..ce28cfa19 --- /dev/null +++ b/src/lib/import/json/limits.ts @@ -0,0 +1,7 @@ +/** + * Rows per section, and the file as a whole, as the JSON import route has + * always capped them. A leaf module, so the import card states the same + * numbers the route enforces without loading the schema. + */ +export const JSON_IMPORT_MAX_ROWS = 10_000; +export const JSON_IMPORT_MAX_BYTES = 16 * 1024 * 1024; diff --git a/src/lib/import/json/schema.ts b/src/lib/import/json/schema.ts index 89c3d728f..e3f74c92f 100644 --- a/src/lib/import/json/schema.ts +++ b/src/lib/import/json/schema.ts @@ -36,9 +36,8 @@ import { SLEEP_STAGE_NAMES } from "@/lib/validations/sleep-stage-entry"; import { fallbackMoodDateStart } from "./mood-instant"; -/** Rows per section, and the file as a whole, as the route has always capped them. */ -export const JSON_IMPORT_MAX_ROWS = 10_000; -export const JSON_IMPORT_MAX_BYTES = 16 * 1024 * 1024; +import { JSON_IMPORT_MAX_ROWS } from "./limits"; +export { JSON_IMPORT_MAX_BYTES, JSON_IMPORT_MAX_ROWS } from "./limits"; // The type is the canonical enum, so every type the exporters write comes // back; a hand-kept subset once dropped four of eleven. diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index a9dd6727c..f997b382a 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -43,7 +43,7 @@ const restoreRequest = z .meta({ id: "AdminBackupRestoreRequest", description: - "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when rebuilding a host from a snapshot, because those settings apply to every account on the installation.", + "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when this server's instance settings themselves need to go back to the ones in the copy, because those settings apply to every account on the installation. An account copy restores one existing account on this server; it does not rebuild a lost server, which takes a database dump and the encryption keys (`docs/ops/disaster-recovery.md`).", }); const restoreSkipEntry = z