diff --git a/CHANGELOG.md b/CHANGELOG.md index 4206a6195..936b501e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file. - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales. ### Changed +- **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently. - **The MakerWorld integration is now a model-provider package, so a second model site is an implementation rather than a second copy of the feature ([PR #2845](https://github.com/maziggy/bambuddy/pull/2845) by @pascalheidmann)** — `services/makerworld.py` was a single 635-line module that owned everything from URL parsing to the presigned S3 download, and the routes called it by name, so Thingiverse or Printables would have meant a second route file beside the first. It is now an interface: a `ModelProvider` descriptor carrying identity, host patterns, the credentials it needs, its library folder, its permissions and its SSRF allowlists, plus a per-request `ProviderService` that does the transport — with MakerWorld as the first implementation and a registry that hands a pasted URL to whichever provider claims it. Nothing about the MakerWorld flow changes: the endpoints and their request and response shapes are unchanged, the new `source_type` field defaults to `makerworld` so existing callers send exactly what they sent before, and the transport moved verbatim — thirteen of the eighteen functions are byte-identical by AST comparison, including the CDN allowlist, the 200 MB download cap and the certifi-pinned TLS context that keeps S3 downloads working on Windows. Four things did change. Bambu Cloud credential storage moved out of `routes/cloud.py` into `services/bambu_cloud_credentials.py`, because a feature package that has to import a route module to read a stored token is a dependency pointing the wrong way; the cloud endpoints themselves did not move. An import naming a provider that is not registered is now refused before anything happens, where it used to create the destination folder on its way to failing. "Your Bambu sign-in expired" is now an explicit flag the provider sets rather than something inferred from whether an error message happened to be present, so a network failure or a rate limit can no longer be reported to you as an expired sign-in. And each request is authorised against the permission of the provider it actually named instead of MakerWorld's — latent while MakerWorld is the only one registered, wrong on the day it is not. - **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent. - **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it. @@ -30,6 +31,7 @@ All notable changes to Bambuddy will be documented in this file. - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable. ### Fixed +- **Restoring a backup into a different version of Bambuddy failed, and the failure took the live database with it** — A backup carries the schema of the install that made it, and the PostgreSQL restore path does not use that schema: it drops every table, recreates them from the running process's ORM, and inserts the backup's columns into the result. Any NOT NULL column the running version has and the backup does not then arrives with nothing to put in it, and a model-side `default=` cannot help, because SQLAlchemy applies one to ORM and Core inserts and never to the raw `text()` SQL this import builds — `create_all` emits no DDL default for it either. A backup made after `user_wallets.currency` was dropped in #3123, restored onto an install that still had the column, died on `null value in column "currency"` with the drop already committed: the restore's first transaction wipes the database, so the install was left with an empty schema, the previous data gone, and the MFA key file already replaced by the backup's — whatever had survived would have been encrypted under a key that no longer matched. The import now fills such a column from the model's default (evaluating a callable one), leaves a column with a server default to the database rather than second-guessing a timestamp it is better placed to produce, and ignores a nullable one exactly as before. A column that genuinely cannot be filled is refused **before anything is touched** — before services are paused, before the key file is written, before the first `DROP` — and the same applies to a `bambuddy.db` that is not a readable SQLite database at all, which previously was discovered only by the import that had already dropped the tables. Backups now carry a `manifest.json` naming the version that wrote them, so the refusal reads "this backup was made by X, this install runs Y" rather than a list of column names; a backup taken before the manifest existed restores exactly as it did, with the version simply unknown. The refusal is an HTTP 400, and the frontend was dropping its body on the floor — a refused restore is an `HTTPException`, so its `{detail}` is not the `{success, message}` a successful one returns, and the modal raised an empty error toast — so the reason now reaches the operator who has to act on it. A SQLite install is unaffected throughout: it restores by copying the backup's pages, schema included, and migrates forward afterwards. - **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server. - **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it. - **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after. diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 70ec145b4..2bc9582cb 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -17,7 +17,7 @@ from backend.app.core.auth import ( require_auth_if_enabled, require_energy_cost_update, ) -from backend.app.core.config import settings as app_settings +from backend.app.core.config import APP_VERSION, settings as app_settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.settings import Settings @@ -792,6 +792,20 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str] except PermissionError as e: logger.warning("Permission denied copying %s: %s", name, e) + # Say which version made this, so a restore that cannot import it can + # name the versions rather than a list of columns. Backups from before + # this existed simply have no manifest, and restore treats the version + # as unknown. + import json as _json + + manifest = { + "format": 1, + "app_version": APP_VERSION, + "created_at": datetime.now().isoformat(timespec="seconds"), + "database": "sqlite" if is_sqlite() else "postgresql", + } + (temp_path / "manifest.json").write_text(_json.dumps(manifest, indent=2) + "\n") + # Include the MFA encryption key as a ZIP top-level entry alongside # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows # would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set. @@ -853,6 +867,119 @@ async def create_backup( ) +class BackupSchemaIncompatible(Exception): + """The backup has no value for a column this version requires. + + A backup carries the schema of the install that made it. Restoring it into + a different version means the destination can have NOT NULL columns the + backup never heard of -- either because that version is older and still has + a column since removed (``user_wallets.currency``, dropped in #3123), or + because it is newer and has added one. Most such columns have a default and + can simply be filled. The ones that cannot are what this reports, and it has + to be reported BEFORE the restore drops anything: the Postgres import wipes + every table in the first transaction, so a failure halfway leaves the + install with an empty schema and the previous data gone. + """ + + +def _missing_required_columns(pg_table, src_columns: set[str]): + """Split the destination's NOT NULL columns that the backup lacks. + + Returns ``(injectable, db_filled, unfillable)``: + + * ``injectable`` -- ``{name: value}`` from the model's Python-side default. + These are invisible to the import's raw SQL: SQLAlchemy applies a + ``default=`` on ORM and Core inserts, never on ``text()``, and + ``create_all`` emits no DDL default for one. So a column like + ``currency VARCHAR(3) NOT NULL`` with ``default="EUR"`` arrives with + nothing to put in it unless we put it there. + * ``db_filled`` -- has a server default or is the autoincrement key; the + database fills it when the column is left out of the INSERT. + * ``unfillable`` -- nothing can supply a value. The backup is incompatible. + """ + injectable: dict = {} + db_filled: list[str] = [] + unfillable: list[str] = [] + + for col in pg_table.columns: + if col.nullable or col.name in src_columns: + continue + if col.default is not None: + arg = col.default.arg + injectable[col.name] = arg(None) if callable(arg) else arg + elif col.server_default is not None or col.primary_key: + db_filled.append(col.name) + else: + unfillable.append(col.name) + + return injectable, db_filled, unfillable + + +def check_backup_schema_compatible(sqlite_path: Path, backup_version: str | None = None) -> None: + """Raise if this version cannot import that backup. Touches nothing. + + Only the cross-engine path needs this. A SQLite install restores by copying + the backup's pages, schema included, and `init_db()` migrates it forward + afterwards; the Postgres import instead recreates the schema from THIS + process's ORM and then inserts the backup's columns into it. + """ + import sqlite3 + + from backend.app.core.database import Base + + src = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) + try: + src_tables = { + row[0] + for row in src.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'" + ) + } + problems: list[str] = [] + # metadata.tables, not sorted_tables: the latter warns about the + # library_files/library_folders/print_archives cycle, and nothing here + # depends on the order. + for name, pg_table in Base.metadata.tables.items(): + if name not in src_tables: + continue + # An empty table inserts nothing, so a column it cannot supply + # cannot fail. Refusing a restore over one would be a false alarm. + if src.execute(f'SELECT 1 FROM "{name}" LIMIT 1').fetchone() is None: # noqa: S608 # nosec B608 — name comes from ORM metadata + continue + src_columns = {row[1] for row in src.execute(f'PRAGMA table_info("{name}")')} + _, _, unfillable = _missing_required_columns(pg_table, src_columns) + problems.extend(f"{name}.{col}" for col in unfillable) + finally: + src.close() + + if not problems: + return + + made_by = f"The backup was made by Bambuddy {backup_version}, " if backup_version else "The backup " + raise BackupSchemaIncompatible( + "This backup cannot be restored by this version of Bambuddy. It carries no value for " + f"{len(problems)} column(s) this version requires and cannot default: {', '.join(sorted(problems))}. " + f"{made_by}and this install runs {APP_VERSION}. Restore it on the version that made it, or " + "upgrade this install to that version. Nothing has been changed." + ) + + +def _read_backup_manifest(temp_path: Path) -> dict: + """The backup's manifest.json, or {} for a backup made before it existed.""" + import json + + path = temp_path / "manifest.json" + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text()) + except (OSError, ValueError) as exc: + logger.warning("Ignoring unreadable backup manifest: %s", exc) + return {} + return data if isinstance(data, dict) else {} + + async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): """Import data from a SQLite database file into the current PostgreSQL database. @@ -865,6 +992,11 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): from backend.app.core.database import Base, _create_engine + # Before anything is dropped. The route checks this too, earlier and with + # the backup's version in the message; this call is what makes the guarantee + # a property of the import itself rather than of one caller. + check_backup_schema_compatible(sqlite_path) + # Create a temporary engine for the import (current engine was disposed) pg_engine = _create_engine() @@ -973,8 +1105,24 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): if not columns: continue - col_list = ", ".join(columns) - param_list = ", ".join(f":{c}" for c in columns) + # Columns this schema requires that the backup does not have at + # all. The block below handles a column PRESENT in the backup + # with a NULL in it; one the backup never had is not in + # `columns` and so never reached it -- which is how a backup + # from an install without `user_wallets.currency` died on + # NotNullViolationError against a version that still had it. + injected, _db_filled, _unfillable = _missing_required_columns(pg_table, set(src_columns)) + if injected: + logger.info( + "Filling %s column(s) absent from the backup in %s: %s", + len(injected), + table_name, + ", ".join(sorted(injected)), + ) + + insert_columns = columns + list(injected) + col_list = ", ".join(insert_columns) + param_list = ", ".join(f":{c}" for c in insert_columns) # ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints) insert_sql = text(f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING") # noqa: S608 # nosec B608 @@ -1015,9 +1163,15 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): now = dt.now() def _convert_row( - row, cols=columns, bools=bool_columns, dts=datetime_columns, nn_defaults=not_null_defaults, _now=now + row, + cols=columns, + bools=bool_columns, + dts=datetime_columns, + nn_defaults=not_null_defaults, + _now=now, + inject=injected, ): - result = {} + result = dict(inject) for c in cols: val = row[c] if val is None and c in nn_defaults: @@ -1149,6 +1303,30 @@ async def restore_backup( if not backup_db.exists(): raise HTTPException(400, "Invalid backup: missing bambuddy.db") + # 2b. Can this version import this backup at all? + # + # Deliberately here: everything below has a side effect. The virtual + # printer stops, background services stop, the MFA key file is + # overwritten with the backup's -- and then the Postgres import drops + # every table in its first transaction. A backup rejected at the INSERT + # took the install's data with it and left the encrypted secrets under a + # key that no longer matches. Nothing above this line has touched + # anything. + import sqlite3 + + manifest = _read_backup_manifest(temp_path) + backup_version = manifest.get("app_version") + if backup_version: + logger.info("Backup was created by Bambuddy %s; this install runs %s", backup_version, APP_VERSION) + if not is_sqlite(): + try: + check_backup_schema_compatible(backup_db, backup_version) + except BackupSchemaIncompatible as exc: + logger.error("Refusing backup: %s", exc) + raise HTTPException(400, str(exc)) from exc + except sqlite3.DatabaseError as exc: + raise HTTPException(400, f"Invalid backup: bambuddy.db is not readable ({exc})") from exc + try: import asyncio diff --git a/backend/app/models/library.py b/backend/app/models/library.py index e7e40626c..0ceffbc22 100644 --- a/backend/app/models/library.py +++ b/backend/app/models/library.py @@ -25,7 +25,29 @@ class LibraryFolder(Base): # Link to project or archive project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) - archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True) + # use_alter breaks a dependency cycle in the schema, and is not about this + # link being special: print_archives.library_file_id -> library_files, + # library_files.folder_id -> library_folders, and this column back to + # print_archives. Each is reasonable alone and together they are a loop + # SQLAlchemy cannot topologically sort, so metadata.sorted_tables dropped + # those edges, warned on every backup and restore, and could hand back an + # order placing a child before its parent -- which once imported + # library_files ahead of library_folders and killed a restore on a foreign + # key violation. Marking ONE edge for ALTER removes it from the sort graph + # and the other two order correctly. The constraint is still created and + # still enforced: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT (as it + # already does for every constraint on these three tables), and SQLite, + # which reports no ALTER support, inlines it into CREATE TABLE as before. + # It has to be named, because an unnamed constraint cannot be ALTERed in. + archive_id: Mapped[int | None] = mapped_column( + ForeignKey( + "print_archives.id", + ondelete="SET NULL", + use_alter=True, + name="fk_library_folders_archive_id", + ), + nullable=True, + ) # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/tests/integration/test_backup_manifest.py b/backend/tests/integration/test_backup_manifest.py new file mode 100644 index 000000000..7a43f8ff9 --- /dev/null +++ b/backend/tests/integration/test_backup_manifest.py @@ -0,0 +1,89 @@ +"""The backup says which version made it, and restore refuses one it cannot import. + +Restoring a backup into a different version of Bambuddy is ordinary -- an +upgrade, a rebuild, a move to another host. What is not ordinary is the Postgres +restore path, which throws the backup's schema away and rebuilds from the +running ORM. A NOT NULL column the running version has and the backup does not +then has nothing to put in it, and the import fails on the INSERT: after the +drop, with the install's data already gone. + +So the incompatibility has to be found before any of that, and it has to say +something an operator can act on. A column name does not; a pair of version +numbers does, which is what the manifest is for. +""" + +from __future__ import annotations + +import io +import json +import sqlite3 +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from backend.app.core.config import APP_VERSION, settings as app_settings + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_the_backup_records_the_version_that_made_it(async_client, monkeypatch, tmp_path): + from backend.app.api.routes.settings import create_backup_zip + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + zip_path, _filename = await create_backup_zip(output_path=tmp_path) + try: + with zipfile.ZipFile(zip_path) as zf: + assert "manifest.json" in zf.namelist() + manifest = json.loads(zf.read("manifest.json")) + finally: + zip_path.unlink(missing_ok=True) + + assert manifest["app_version"] == APP_VERSION + assert manifest["format"] == 1 + assert manifest["database"] in ("sqlite", "postgresql") + assert manifest["created_at"] + + +def _incompatible_backup(tmp_path: Path, *, version: str) -> bytes: + """A backup whose `cost_centers` has no `name` -- NOT NULL, no default.""" + db = tmp_path / "bambuddy.db" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)") + conn.execute("INSERT INTO cost_centers (id, code) VALUES (1, 'abc')") + conn.commit() + conn.close() + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.write(db, "bambuddy.db") + zf.writestr("manifest.json", json.dumps({"format": 1, "app_version": version})) + return buffer.getvalue() + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_an_unimportable_backup_is_refused_with_both_versions(async_client, tmp_path): + """A 400 naming the two versions, and -- the point -- nothing touched. + + is_sqlite is patched false because this is the PostgreSQL path: a SQLite + install restores by copying the backup's pages, schema and all, and has + never had this problem. + """ + payload = _incompatible_backup(tmp_path, version="99.9.9") + + with patch("backend.app.core.db_dialect.is_sqlite", return_value=False): + response = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", payload, "application/zip")}, + ) + + assert response.status_code == 400, response.text + detail = response.json()["detail"] + assert "cost_centers.name" in detail + assert "99.9.9" in detail + assert APP_VERSION in detail + assert "Nothing has been changed" in detail diff --git a/backend/tests/integration/test_security.py b/backend/tests/integration/test_security.py index cba87af71..95fc81b84 100644 --- a/backend/tests/integration/test_security.py +++ b/backend/tests/integration/test_security.py @@ -1912,6 +1912,27 @@ class TestEncryptionRoundtrip: # ============================================================================ +def _minimal_sqlite_backup() -> bytes: + """A real, if empty, SQLite database to stand in for a backup's bambuddy.db. + + Restore now refuses a bambuddy.db it cannot open, and refuses it before it + stops services or overwrites the MFA key file -- a corrupt or truncated + backup used to be found only by the Postgres import, which by then had + dropped every table in the live database. These tests are about the key + handling around the swap, so they need a file that opens; what is in it does + not matter. + """ + import sqlite3 + + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)") + conn.commit() + return conn.serialize() + finally: + conn.close() + + class TestBackupKeyFiles: @pytest.mark.asyncio @pytest.mark.integration @@ -1980,7 +2001,7 @@ class TestBackupKeyFiles: # Build a minimal ZIP with a stub DB and the key file. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bambuddy.db", b"SQLite format 3") + zf.writestr("bambuddy.db", _minimal_sqlite_backup()) zf.writestr(".mfa_encryption_key", "test-restored-key") buf.seek(0) @@ -2020,7 +2041,7 @@ class TestBackupKeyFiles: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bambuddy.db", b"SQLite format 3") + zf.writestr("bambuddy.db", _minimal_sqlite_backup()) # Intentionally no .mfa_encryption_key entry. buf.seek(0) @@ -2061,7 +2082,7 @@ class TestBackupKeyFiles: # Build ZIP with a key file that we will fail to write to DATA_DIR. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bambuddy.db", b"SQLite format 3 backup data") + zf.writestr("bambuddy.db", _minimal_sqlite_backup()) zf.writestr(".mfa_encryption_key", "backup-key-content") buf.seek(0) @@ -2141,7 +2162,7 @@ class TestBackupKeyFiles: assert new_key != old_key buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bambuddy.db", b"SQLite format 3 backup data") + zf.writestr("bambuddy.db", _minimal_sqlite_backup()) zf.writestr(".mfa_encryption_key", new_key) buf.seek(0) diff --git a/backend/tests/unit/test_model_metadata.py b/backend/tests/unit/test_model_metadata.py new file mode 100644 index 000000000..d5138a8d0 --- /dev/null +++ b/backend/tests/unit/test_model_metadata.py @@ -0,0 +1,86 @@ +"""The schema has to be sortable, because backup and restore sort it. + +`metadata.sorted_tables` is asked for the order in three places: the backup +export, the restore's import loop, and the loop that puts foreign keys back +afterwards. Three nullable SET NULL links used to close a loop -- +print_archives.library_file_id -> library_files.folder_id -> +library_folders.archive_id -> print_archives -- and SQLAlchemy answered a +sort it could not make, with a warning on every backup and every restore: + + Cannot correctly sort tables; there are unresolvable cycles between + tables "library_files, library_folders, print_archives" ... this warning + may raise an error in a future release. + +Two things were wrong with living on that. The order it returns can place a +child before its parent, which is what once imported library_files ahead of +library_folders and killed a restore on a ForeignKeyViolation. And the +sentence at the end is a promise: if it ever becomes an error, backup and +restore break on the same upgrade. + +One edge of the loop is marked use_alter, which takes it out of the sort +graph without taking the constraint out of the database. +""" + +from __future__ import annotations + +import importlib +import pkgutil +import warnings + +from sqlalchemy import create_engine, inspect + +from backend.app.core.database import Base + + +def _all_models_imported() -> None: + """Base.metadata is filled by imports, so a partial import means a partial + schema -- and a cycle in a table nobody imported would not be found here.""" + import backend.app.models as models + + for module in pkgutil.iter_modules(models.__path__): + importlib.import_module(f"backend.app.models.{module.name}") + + +def test_the_schema_sorts_without_a_cycle_warning(): + _all_models_imported() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert Base.metadata.sorted_tables + + cycles = [str(w.message) for w in caught if "cycles" in str(w.message)] + assert not cycles, ( + f"a new foreign key has closed a loop in the schema; backup and restore sort these tables: {cycles}" + ) + + +def test_the_tables_that_used_to_cycle_sort_parents_first(): + """The property the warning took away. Order is what the restore's import + loop follows, and a child ahead of its parent is a FK violation.""" + _all_models_imported() + + order = [t.name for t in Base.metadata.sorted_tables] + + assert order.index("library_folders") < order.index("library_files"), ( + "library_files.folder_id points at library_folders" + ) + assert order.index("library_files") < order.index("print_archives"), ( + "print_archives.library_file_id points at library_files" + ) + + +def test_the_altered_constraint_still_exists_on_sqlite(): + """use_alter asks for ALTER TABLE ADD CONSTRAINT, and SQLite has no such + statement. It inlines the key into CREATE TABLE instead -- but if that ever + stopped being true, deleting an archive would leave a dangling + library_folders.archive_id rather than nulling it, silently.""" + _all_models_imported() + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + + keys = inspect(engine).get_foreign_keys("library_folders") + + archive_link = [k for k in keys if k["referred_table"] == "print_archives"] + assert archive_link, f"library_folders lost its archive key: {keys}" + assert archive_link[0]["constrained_columns"] == ["archive_id"] + assert archive_link[0]["options"].get("ondelete") == "SET NULL" diff --git a/backend/tests/unit/test_restore_schema_compat.py b/backend/tests/unit/test_restore_schema_compat.py new file mode 100644 index 000000000..0349409f9 --- /dev/null +++ b/backend/tests/unit/test_restore_schema_compat.py @@ -0,0 +1,306 @@ +"""Restoring a backup made by a different version of Bambuddy. + +A backup carries the schema of the install that made it, and the Postgres +restore path does NOT use that schema: it drops every table, recreates them +from the running process's ORM, and inserts the backup's columns into the +result. So any NOT NULL column the running version has and the backup does not +arrives with nothing to put in it. + +That is how a 2026-09-23 backup failed to restore on the published image: +``user_wallets.currency`` was dropped from the model in #3123, the backup +therefore had no such column, the older image still declared it NOT NULL, and +the import died on:: + + null value in column "currency" of relation "user_wallets" + +Its ``default="EUR"`` could not help -- SQLAlchemy applies a Python-side default +to ORM and Core inserts, never to the raw ``text()`` SQL this import builds, and +``create_all`` emits no DDL default for one. + +Two things have to hold, and the second is the serious one: + +1. A column with a default is filled rather than refused. +2. A column that cannot be filled is refused BEFORE the restore drops anything. + The drop is the first thing the import does, in its own transaction, so a + refusal at the INSERT means the install's data is already gone -- and the + restore has by then also overwritten the MFA key file, leaving whatever + survives encrypted under a key that no longer matches. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import Column, DateTime, Integer, MetaData, Numeric, String, Table, func + +from backend.app.api.routes.settings import ( + BackupSchemaIncompatible, + _missing_required_columns, + _read_backup_manifest, + check_backup_schema_compatible, +) + + +def _wallets_table(metadata: MetaData) -> Table: + """`user_wallets` as the published image still declares it.""" + return Table( + "user_wallets", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, nullable=False), + Column("balance", Numeric(14, 2), nullable=False, default=0.0), + Column("currency", String(3), nullable=False, default="EUR"), + Column("updated_at", DateTime, nullable=False, server_default=func.now()), + ) + + +# --------------------------------------------------------------------------- +# Which missing columns are a problem +# --------------------------------------------------------------------------- + + +def test_a_column_with_a_model_default_is_filled_not_refused(): + """The exact case from the incident, with the values it would have used.""" + table = _wallets_table(MetaData()) + + injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "updated_at"}) + + assert injectable == {"currency": "EUR"} + assert unfillable == [] + assert db_filled == [] + + +def test_a_column_with_a_server_default_is_left_to_the_database(): + """Omitting it from the INSERT is right: Postgres fills it. Sending the + ORM's idea of the default instead would overwrite a timestamp the database + is better placed to produce.""" + table = _wallets_table(MetaData()) + + injectable, db_filled, unfillable = _missing_required_columns(table, {"id", "user_id", "balance", "currency"}) + + assert db_filled == ["updated_at"] + assert injectable == {} + assert unfillable == [] + + +def test_a_callable_default_is_evaluated(): + table = Table( + "cost_centers", + MetaData(), + Column("id", Integer, primary_key=True), + Column("code", String(32), nullable=False, default=lambda: "generated"), + ) + + injectable, _, unfillable = _missing_required_columns(table, {"id"}) + + assert injectable == {"code": "generated"} + assert unfillable == [] + + +def test_a_required_column_with_no_default_is_unfillable(): + table = Table( + "cost_centers", + MetaData(), + Column("id", Integer, primary_key=True), + Column("name", String(150), nullable=False), + ) + + injectable, _, unfillable = _missing_required_columns(table, {"id"}) + + assert unfillable == ["name"] + assert injectable == {} + + +def test_a_nullable_column_the_backup_lacks_is_not_a_problem(): + """Most schema drift is this, and it has always worked: the column is + simply omitted and the row gets NULL.""" + table = Table( + "printers", + MetaData(), + Column("id", Integer, primary_key=True), + Column("nickname", String(50), nullable=True), + ) + + injectable, db_filled, unfillable = _missing_required_columns(table, {"id"}) + + assert (injectable, db_filled, unfillable) == ({}, [], []) + + +# --------------------------------------------------------------------------- +# The preflight, against a real backup file +# --------------------------------------------------------------------------- + + +def _source(tmp_path: Path, ddl: str, rows: list[str]) -> Path: + path = tmp_path / "bambuddy.db" + conn = sqlite3.connect(path) + conn.execute(ddl) + for row in rows: + conn.execute(row) + conn.commit() + conn.close() + return path + + +def test_a_backup_this_version_can_import_is_accepted(tmp_path): + """`users` as the ORM has it, minus columns that are nullable or defaulted.""" + path = _source( + tmp_path, + "CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)", + ["INSERT INTO users (id, username) VALUES (1, 'alice')"], + ) + + check_backup_schema_compatible(path) # does not raise + + +def test_a_backup_missing_a_required_column_is_refused(tmp_path): + """`cost_centers.name` is NOT NULL with no default anywhere.""" + path = _source( + tmp_path, + "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", + ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"], + ) + + with pytest.raises(BackupSchemaIncompatible) as exc: + check_backup_schema_compatible(path) + + assert "cost_centers.name" in str(exc.value) + assert "Nothing has been changed" in str(exc.value) + + +def test_the_refusal_names_the_version_that_made_the_backup(tmp_path): + """A column name tells an operator nothing about what to do. Two version + numbers tell them which install to restore on.""" + path = _source( + tmp_path, + "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", + ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"], + ) + + with pytest.raises(BackupSchemaIncompatible) as exc: + check_backup_schema_compatible(path, backup_version="1.2.7") + + assert "1.2.7" in str(exc.value) + + +def test_an_empty_table_is_not_a_reason_to_refuse(tmp_path): + """No rows, no INSERT, no violation. Refusing here would block a restore + over a feature the backup's install never used.""" + path = _source(tmp_path, "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", []) + + check_backup_schema_compatible(path) # does not raise + + +def test_a_table_the_orm_does_not_know_is_ignored(tmp_path): + """Backups carry tables from features this version has removed. The import + skips them (it only imports source ∩ ORM), so the check must not judge them + either -- a column requirement that no longer exists cannot fail an INSERT + that will never be made.""" + from backend.app.core.database import Base + + assert "legacy_removed_feature" not in Base.metadata.tables + path = _source( + tmp_path, + "CREATE TABLE legacy_removed_feature (id INTEGER PRIMARY KEY, whatever TEXT)", + ["INSERT INTO legacy_removed_feature (id, whatever) VALUES (1, 'x')"], + ) + + check_backup_schema_compatible(path) # does not raise + + +# --------------------------------------------------------------------------- +# The import itself +# --------------------------------------------------------------------------- + + +def _mock_engine(): + """An engine that records every statement and parameter set.""" + executed: list[tuple[str, object]] = [] + conn = MagicMock() + conn.execute = AsyncMock( + side_effect=lambda stmt, *a, **k: executed.append((getattr(stmt, "text", str(stmt)), a[0] if a else None)) + ) + conn.run_sync = AsyncMock() + begin_cm = MagicMock() + begin_cm.__aenter__ = AsyncMock(return_value=conn) + begin_cm.__aexit__ = AsyncMock(return_value=False) + engine = MagicMock() + engine.begin = MagicMock(return_value=begin_cm) + engine.dispose = AsyncMock() + return engine, executed + + +@pytest.mark.asyncio +async def test_the_import_refuses_before_it_drops_anything(tmp_path): + """The whole point. The drop is the import's first act and it is not + reversible: by the time an INSERT fails, the install is empty.""" + path = _source( + tmp_path, + "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT)", + ["INSERT INTO cost_centers (id, code) VALUES (1, 'abc')"], + ) + from backend.app.api.routes import settings as settings_module + + engine, executed = _mock_engine() + create_engine = MagicMock(return_value=engine) + with ( + patch("backend.app.core.database._create_engine", new=create_engine), + pytest.raises(BackupSchemaIncompatible), + ): + await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test") + + assert executed == [], f"SQL ran against the destination before the refusal: {executed}" + create_engine.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path): + """What would have rescued the failed restore: the column absent from the + backup joins the INSERT carrying the model's default.""" + path = _source( + tmp_path, + "CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, code TEXT, name TEXT, created_at TEXT, updated_at TEXT)", + [ + "INSERT INTO cost_centers (id, code, name, created_at, updated_at) " + "VALUES (1, 'abc', 'Lab', '2026-09-07 10:44:42', '2026-09-07 10:44:42')" + ], + ) + from backend.app.api.routes import settings as settings_module + + engine, executed = _mock_engine() + with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)): + await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test") + + inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")] + assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}" + sql, params = inserts[0] + # is_active is NOT NULL with default=True in the model and absent above. + assert "is_active" in sql + assert params[0]["is_active"] is True + assert params[0]["code"] == "abc", "the backup's own values must survive the injection" + + +# --------------------------------------------------------------------------- +# The manifest +# --------------------------------------------------------------------------- + + +def test_a_backup_without_a_manifest_reads_as_unknown(tmp_path): + """Every backup taken before the manifest existed. It must restore exactly + as it did, with the version simply unknown.""" + assert _read_backup_manifest(tmp_path) == {} + + +def test_an_unreadable_manifest_does_not_break_the_restore(tmp_path): + (tmp_path / "manifest.json").write_text("{ this is not json") + + assert _read_backup_manifest(tmp_path) == {} + + +def test_the_manifest_is_read(tmp_path): + (tmp_path / "manifest.json").write_text('{"format": 1, "app_version": "1.2.7"}') + + assert _read_backup_manifest(tmp_path)["app_version"] == "1.2.7" diff --git a/frontend/src/__tests__/api/importBackup.test.ts b/frontend/src/__tests__/api/importBackup.test.ts new file mode 100644 index 000000000..5c979a029 --- /dev/null +++ b/frontend/src/__tests__/api/importBackup.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for api.importBackup's handling of a refused restore. + * + * A restore the server declines is an HTTPException, so its body is + * `{detail}` — not the `{success, message}` a successful restore returns. + * importBackup used to hand that body straight back, which left `success` + * undefined (falsy, so the UI took the failure branch) and `message` + * undefined with it: the operator got an empty error toast and no idea why. + * + * It matters most for exactly the case it was found in — a backup this version + * cannot import. That refusal names the columns and both version numbers, and + * all of it was being dropped on the floor. + */ + +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { api } from '../../api/client'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const backupFile = () => new File(['(a backup)'], 'backup.zip', { type: 'application/zip' }); + +describe('api.importBackup', () => { + it('passes a successful restore through unchanged', async () => { + server.use( + http.post('*/settings/restore', () => + HttpResponse.json({ success: true, message: 'Backup restored successfully.' }) + ) + ); + + const result = await api.importBackup(backupFile()); + + expect(result.success).toBe(true); + expect(result.message).toBe('Backup restored successfully.'); + }); + + it('turns a refusal into a failure carrying the reason', async () => { + const detail = + 'This backup cannot be restored by this version of Bambuddy. It carries no value for ' + + '1 column(s) this version requires and cannot default: cost_centers.name.'; + server.use(http.post('*/settings/restore', () => HttpResponse.json({ detail }, { status: 400 }))); + + const result = await api.importBackup(backupFile()); + + expect(result.success).toBe(false); + expect(result.message).toBe(detail); + }); + + it('reports a failure even when the error body is not JSON', async () => { + server.use( + http.post('*/settings/restore', () => new HttpResponse('upstream exploded', { status: 502 })) + ); + + const result = await api.importBackup(backupFile()); + + expect(result.success).toBe(false); + expect(result.message).toBe(''); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 227d85cb1..846010868 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5759,10 +5759,23 @@ export const api = { headers, body: formData, }); - return response.json() as Promise<{ + const data = (await response.json().catch(() => null)) as { + success?: boolean; + message?: string; + detail?: string; + } | null; + // A refused restore is an HTTPException, so the body is {detail}, not + // {success, message}. Returning it unmapped made `success` undefined and + // `message` undefined too — the modal then raised an empty error toast, + // which is the one case where the reason matters most (e.g. a backup this + // version cannot import names the columns and both versions). + if (!response.ok) { + return { success: false, message: data?.detail ?? data?.message ?? '' }; + } + return (data ?? { success: false, message: '' }) as { success: boolean; message: string; - }>; + }; }, checkFfmpeg: () => request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'), diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 6dfef2f36..5eb285e18 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -1532,7 +1532,7 @@ export function GitHubBackupSettings() { if (result.success) { showToast(t('backup.backupRestoredRestart'), 'success'); } else { - showToast(result.message, 'error'); + showToast(result.message || t('backup.failedToRestore'), 'error'); } } catch (e) { const message = e instanceof Error ? e.message : t('backup.failedToRestore'); diff --git a/static/assets/index-DZtvSvOz.js b/static/assets/index-BqTQFqpO.js similarity index 96% rename from static/assets/index-DZtvSvOz.js rename to static/assets/index-BqTQFqpO.js index e8d4d79e2..eafa799a2 100644 --- a/static/assets/index-DZtvSvOz.js +++ b/static/assets/index-BqTQFqpO.js @@ -50,7 +50,7 @@ ON değeri: "ON" anlamına gelen tam string. Otomatik algılama için boş bıra Для перетворення Wh→kWh використовуйте множник 0.001, а для MWh→kWh — 1000.`,mqttStateHint:`Шлях JSON витягує значення з корисного навантаження JSON. Залиште поле порожнім для безпосередньо переданих значень. Значення «увімкнено»: точний рядок, що означає стан «увімкнено». Залиште поле порожнім для автоматичного визначення (ON, true, 1).`,restControl:`Керування`,restOnUrl:`Увімкнути URL`,restOffUrl:`Вимкнути URL`,restOnBody:`Тіло запиту ввімкнення`,restOffBody:`Тіло запиту вимкнення`,restMethod:`Метод HTTP`,restHeaders:`Спеціальні заголовки (JSON)`,restStatusUrl:`URL стану`,restStatusPath:`Шлях JSON до стану`,restStatusOnValue:`Значення «увімкнено»`,restPowerUrl:`URL даних про потужність`,restPowerPath:`Шлях JSON до потужності`,restPowerMultiplier:`Множник потужності`,restEnergyUrl:`URL даних про енергію`,restEnergyPath:`Шлях JSON до споживання за сьогодні`,restEnergyTotalPath:`Шлях JSON до загального споживання`,restEnergyTotalMultiplier:`Множник загального споживання`,restEnergyTotalPathHint:`наприклад, aenergy.total`,restEnergyTotalHint:`Багато розеток, зокрема всі моделі Shelly, повідомляють лише загальний лічильник споживання, який ніколи не скидається. Його слід указувати саме тут, а не в полі вище: якщо трактувати це значення як споживання за сьогодні, воно не скинеться опівночі, а показники «Учора» та «Усього» залишатимуться порожніми. Bambuddy сам обчислює показники за сьогодні й учора на основі цього лічильника; для їх появи потрібні один-два дні даних. Shelly передає значення у ват-годинах, тому використовуйте множник 0.001.`,restEnergyMultiplier:`Множник енергії`,restUrlRequired:`Для REST-розетки потрібно вказати принаймні один URL — для ввімкнення або вимкнення.`,restHeadersHint:`наприклад, {"Authorization": "Bearer your-token"}`,restBodyHint:`напр. ON, {"state": "on"}`,restStatusHint:`URL для опитування поточного стану`,restPathHint:`наприклад, state або data.power.status`,restPowerUrlHint:`Окремий URL для даних про потужність; якщо поле порожнє, використовується URL стану`,restEnergyUrlHint:`Окремий URL для даних про енергію; якщо поле порожнє, використовується URL стану`,restEnergyHint:`Для кожного значення можна використовувати окремий URL або URL стану як резервний. Для перетворення одиниць використовуйте множники, наприклад 0.001 для перетворення Wh у kWh.`,testConnection:`Тестове підключення`,connectionSuccess:`Підключення успішне`,noSwitchesInSwitchbar:`На панелі перемикачів немає розеток`,enableSwitchbarHint:`Увімкніть «Показувати на панелі перемикачів» у розділі «Налаштування → Розумні розетки».`},notifications:{providerTypes:{callmebot:`CallMeBot/WhatsApp`,ntfy:`ntfy`,pushover:`Pushover`,telegram:`Telegram`,email:`Електронна пошта`,discord:`Discord`,webhook:`Webhook`,homeassistant:`Home Assistant`,bark:`Bark`},providerDescriptions:{email:`Сповіщення електронною поштою SMTP`,telegram:`Сповіщення через бота Telegram.`,discord:`Надіслати на канал Discord через webhook`,ntfy:`Безкоштовні push-сповіщення з можливістю самостійного розгортання`,pushover:`Прості та надійні push-повідомлення`,callmebot:`Безкоштовні сповіщення WhatsApp через CallMeBot`,webhook:`Універсальний HTTP POST-запит на будь-який URL`,homeassistant:`Постійні сповіщення на інформаційній панелі Home Assistant.`,bark:`Push-сповіщення iOS через Bark із можливістю самостійного розгортання`},lastSuccess:`Останнє успішне надсилання: {{date}}`,error:`Помилка`,printer:`Принтер:`,allPrinters:`Всі принтери`,sendTestNotification:`Надіслати тестове сповіщення`,eventSettings:`Налаштування події`,enabled:`Увімкнено`,sendFromProvider:`Надсилати сповіщення від цього постачальника`,printEvents:`Події друку`,printerStatus:`Стан принтера`,amsAlarms:`Попередження AMS`,amsHtAlarms:`Попередження AMS-HT`,printQueue:`Черга друку`,start:`Початок`,plateCheck:`Перевірка пластини`,complete:`Завершено`,failed:`Помилка`,stopped:`Зупинено`,progress:`Прогрес`,offline:`Не в мережі`,lowFilament:`Закінчується філамент`,maintenance:`Технічне обслуговування`,amsHumidity:`AMS Вологість`,amsTemp:`AMS Темп`,amsDryingSuspended:`Сушіння призупинено`,amsHtHumidity:`AMS-HT Вологість`,amsHtTemp:`Температура AMS-HT`,bedCooled:`Стіл охолов`,firstLayer:`Перший шар`,quiet:`Тихо`,digest:`Дайджест {{time}}`,printStarted:`Друк розпочато`,plateNotEmpty:`Друкарська пластина не порожня`,plateNotEmptyDescription:`Перед друком виявлено об’єкти`,plateClearRequired:`Потрібно очистити пластину`,plateClearRequiredDescription:`Друк завершено, черга очікує підтвердження очищення пластини`,plateClear:`Очищення пластини`,printCompleted:`Друк завершено`,bedCooledLabel:`Стіл охолов`,bedCooledDescription:`Після друку стіл охолов нижче заданого порога`,firstLayerCompleteLabel:`Перший шар завершено`,firstLayerCompleteDescription:`Сповістити зі знімком після завершення першого шару`,missingSpoolAssignmentLabel:`Відсутнє призначення котушки`,billingChargeFailedLabel:`Помилка списання`,billingChargeFailedDescription:`Сповіщати, якщо не вдалося облікувати вартість друку`,missingSpoolAssignmentDescription:`Сповіщати, коли починається друк і для необхідних лотків не призначено котушку`,printFailed:`Помилка друку`,printStopped:`Друк зупинено`,progressMilestones:`Віхи прогресу`,progressMilestonesDescription:`Сповіщати на 25%, 50%, 75%`,printerOffline:`Принтер не в мережі`,printerError:`Помилка принтера`,aiFailureDetection:`Виявлення помилок за допомогою ШІ`,aiFailureDetectionDescription:`Сповіщати, коли ШІ Obico виявляє можливу помилку друку`,lowFilamentLabel:`Закінчується філамент`,maintenanceDue:`Настав час технічного обслуговування`,maintenanceDueDescription:`Повідомити, коли потрібне обслуговування`,amsHumidityHigh:`AMS Висока вологість`,amsHumidityHighDescription:`Звичайна AMS вологість перевищує порогове значення`,amsTemperatureHigh:`AMS Висока температура`,amsTemperatureHighDescription:`Звичайна AMS температура перевищує порогове значення`,amsDryingSuspendedTitle:`Автоматичне сушіння призупинено`,amsDryingSuspendedDescription:`Автоматичне сушіння блока AMS припинено: вологість так і не опустилася нижче порога`,amsHtHumidityHigh:`AMS-HT Висока вологість`,amsHtHumidityHighDescription:`AMS-HT вологість перевищує порогове значення`,amsHtTemperatureHigh:`Висока температура AMS-HT`,amsHtTemperatureHighDescription:`Температура AMS-HT перевищує порогове значення`,inventoryAlerts:`Сповіщення про запаси`,stockReorderAlert:`Сповіщення про необхідність поповнення`,stockReorderAlertDescription:`Запас SKU досяг точки повторного замовлення`,stockBreakAlert:`Сповіщення про ризик вичерпання запасу`,stockBreakAlertDescription:`Запас закінчиться до того, як надійде поповнення`,jobAdded:`Завдання додано`,jobAddedDescription:`Завдання додано до черги`,jobAssigned:`Завдання призначено`,jobAssignedDescription:`Завдання на основі моделі, призначене принтеру`,jobStarted:`Завдання розпочато`,jobStartedDescription:`Розпочато друк завдання черги`,jobWaiting:`Завдання очікує`,jobWaitingDescription:`Завдання очікує на філамент або принтер`,jobSkipped:`Завдання пропущено`,jobSkippedDescription:`Завдання пропущено (попереднє не вдалося)`,jobFailed:`Помилка завдання`,jobFailedDescription:`Не вдалося розпочати завдання`,queueComplete:`Черга завершена`,queueCompleteDescription:`Усі завдання черги завершено`,quietHours:`Тихі години`,noNotificationsDuring:`Жодних сповіщень у ці години`,editProviderToChangeQuietHours:`Змініть постачальника, щоб змінити години тиші`,dailyDigest:`Щоденний дайджест`,batchNotifications:`Групуйте сповіщення в єдиний щоденний підсумок`,sendAt:`Надсилати о {{time}}`,editProviderToChangeDigestTime:`Відредагуйте постачальника, щоб змінити час дайджесту`,edit:`Редагувати`,deleteProvider:`Видалити постачальника сповіщень`,deleteConfirm:`Видалити «{{name}}»? Цю дію неможливо скасувати.`,delete:`Видалити`,addTitle:`Додати постачальника сповіщень`,editTitle:`Редагувати постачальника сповіщень`,nameLabel:`Назва *`,namePlaceholder:`Мої сповіщення`,providerTypeLabel:`Тип постачальника *`,configuration:`Конфігурація`,testConfiguration:`Перевірити конфігурацію`,printerFilter:`Фільтр принтера`,onlyFromPrinter:`Надсилати лише сповіщення про події з цього принтера`,quietHoursDnd:`Тихі години (не турбувати)`,quietStart:`Початок`,quietEnd:`Кінець`,dailyDigestLabel:`Щоденний дайджест`,sendDigestAt:`Надсилати дайджест о`,digestCollected:`Події буде зібрано й надіслано в цей час одним зведенням`,notificationEvents:`Події сповіщень`,progressPercent:`(25%, 50%, 75%)`,bedCooledAfterPrint:`(після завершення друку)`,haSensorAlert:`Сповіщення датчика принтера`,haSensorAlertDescription:`(пов'язаний датчик Home Assistant потребує уваги)`,locationHaSensorAlert:`Сповіщення датчика місця зберігання`,locationHaSensorAlertDescription:`(датчик Home Assistant, пов'язаний з місцем зберігання, потребує уваги)`,eventPriority:{sectionTitle:`Пріоритет ntfy`,helpNtfy:`Виберіть пріоритет для кожної ввімкненої події. ntfy використовує їх для ескалації сповіщень (звук, видимість, поведінка push). Для рівнів, які тут не встановлено, використовується сервер ntfy за замовчуванням.`,min:`Мін`,low:`Низький`,default:`За замовчуванням`,high:`Високий`,urgent:`Терміново`},cancel:`Скасувати`,save:`зберегти`,add:`додати`,nameRequired:`Необхідно вказати ім’я`,fieldRequired:`{{field}} — обов’язкове поле.`,haDataInvalid:`Поле «Дані» має містити дійсний об’єкт JSON, наприклад {"priority": "high", "ttl": 0}`,phoneNumber:`Номер телефону`,apiKey:`Ключ API`,serverUrl:`Сервер URL`,topic:`Тема`,authToken:`Токен авторизації`,userKey:`Ключ користувача`,appToken:`Токен застосунку`,priority:`Пріоритет`,pushoverRetry:`Інтервал повторення екстреного сповіщення (с)`,pushoverExpire:`Термін дії екстреного сповіщення (с)`,botToken:`Токен бота`,chatId:`Ідентифікатор чату`,telegramThreadId:`ID теми форуму`,telegramThreadIdHelp:`Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.`,telegramThreadIdInvalid:`ID теми форуму має бути числом.`,smtpServer:`Сервер SMTP`,smtpPort:`Порт SMTP`,security:`Безпека`,authentication:`Автентифікація`,username:`Ім’я користувача`,password:`Пароль`,fromEmail:`Адреса відправника`,toEmail:`Адреса одержувача`,webhookUrl:`Webhook URL`,payloadFormat:`Формат корисного навантаження`,authorization:`Авторизація`,titleFieldName:`Назва поля заголовка`,messageFieldName:`Назва поля повідомлення`,editTemplate:`Редагувати шаблон: {{name}}`,titleLabel:`Заголовок`,bodyLabel:`Текст`,titlePlaceholder:`Назва сповіщення...`,bodyPlaceholder:`Текст сповіщення...`,availableVariables:`Доступні змінні`,clickToInsert:`Натисніть, щоб вставити в текст у позиції курсора`,livePreview:`Попередній перегляд`,hide:`Сховати`,show:`Показати`,loadingPreview:`Завантаження попереднього перегляду...`,enterTemplateContent:`Введіть вміст шаблону, щоб побачити попередній перегляд`,titlePreview:`Назва:`,bodyPreview:`Текст:`,resetToDefault:`Відновити типові значення`,titleRequired:`Необхідно вказати назву`,bodyRequired:`Текст є обов’язковим`,notificationLog:`Журнал повідомлень`,showFailedOnly:`Лише невдалі`,last24Hours:`Останні 24 години`,last7Days:`Останні 7 днів`,last30Days:`Останні 30 днів`,last90Days:`Останні 90 днів`,justNow:`Щойно`,noFailedNotifications:`Немає невдалих сповіщень`,noNotificationsLogged:`Сповіщення не зареєстровано`,unknownProvider:`Невідомий постачальник`,logTitle:`Назва`,logMessage:`повідомлення`,logError:`Помилка`,logProvider:`Постачальник: {{type}}`,logTime:`Час: {{time}}`,refresh:`Оновити`,clearOld:`Очистити старі`,statsSummary:`За останні {{days}} дн.:`,statsNotifications:`сповіщення`,statsSent:`{{count}} надіслано`,statsFailed:`не вдалося надіслати: {{count}}`,eventTypes:{print_start:`Друк розпочато`,print_complete:`Друк завершено`,print_failed:`Помилка друку`,print_stopped:`Друк зупинено`,print_progress:`Прогрес`,printer_offline:`Принтер не в мережі`,printer_error:`Помилка принтера`,filament_low:`Закінчується філамент`,maintenance_due:`Технічне обслуговування`,test:`Тест`},userEmail:{title:`Сповіщення`,emailNotifications:`Сповіщення електронною поштою`,emailNotificationsDesc:`Отримуйте сповіщення електронною поштою про власні завдання друку. Електронні листи надсилаються за допомогою системних параметрів SMTP, налаштованих у розширеній автентифікації.`,sendingTo:`Сповіщення будуть надіслані на адресу`,noEmailWarning:`У вашому обліковому записі немає адреси електронної пошти. Зверніться до адміністратора, щоб її додати.`,printJobNotifications:`Повідомлення про завдання друку`,printJobNotificationsDesc:`Виберіть, які події викликають сповіщення електронною поштою про завдання друку, які ви надсилаєте.`,printJobStarts:`Початок завдання друку`,printJobStartsDesc:`Отримуйте сповіщення, коли розпочнеться ваше завдання друку.`,printJobFinishes:`Завершення завдання друку`,printJobFinishesDesc:`Отримуйте сповіщення, коли ваше завдання друку буде успішно завершено.`,printErrors:`Помилки друку`,printErrorsDesc:`Отримуйте сповіщення, якщо завдання друку завершується невдало або під час нього виникає помилка.`,printJobStops:`Зупинення завдання друку`,printJobStopsDesc:`Отримуйте сповіщення, коли ваше завдання друку скасовано або зупинено.`,saveSuccess:`Параметри сповіщень збережено.`,saveError:`Не вдалося зберегти налаштування сповіщень.`}},richTextEditor:{bold:`жирний`,italic:`Курсив`,underline:`Підкреслити`,bulletList:`Список маркерів`,numberedList:`Нумерований список`,alignLeft:`Вирівняти по лівому краю`,alignCenter:`Вирівняти по центру`,alignRight:`Вирівняти праворуч`,addLink:`Додати посилання`,removeLink:`Видалити посилання`},externalLinks:{title:`Посилання на бічній панелі`,sidebarLayout:`Бічна панель`,sidebarLayoutDescription:`Показуйте або приховуйте вбудовані сторінки, додавайте зовнішні посилання та перетягуйте елементи, щоб змінити порядок навігації на бічній панелі.`,systemPages:`Bambuddy сторінок`,externalLinks:`Зовнішні посилання`,visibleInSidebar:`Видно на бічній панелі`,hiddenFromSidebar:`Приховано від бічної панелі`,requiredInSidebar:`Обов’язково на бічній панелі`,hidePage:`Приховати сторінку`,showPage:`Показати сторінку`,settingsCannotBeHidden:`Налаштування не можна приховати`,noLinksConfigured:`Зовнішні посилання не налаштовано`,deleteLink:`Видалити посилання`,removeCustomIcon:`Видалити спеціальний значок`,openInNewTab:`Відкрити в новій вкладці`,placeholders:{linkName:`Моє посилання`}},keyboardShortcuts:{title:`Комбінації клавіш`,navigation:`Навігація`,archivesSection:`Архіви`,kProfilesSection:`K-профілі`,generalSection:`Загальний`,shortcuts:{goToPrinters:`Перейдіть до Принтери`,goToArchives:`Перейдіть до архіву`,goToQueue:`Перейдіть до черги`,goToStats:`Перейти до статистики`,goToProfiles:`Перейдіть до Cloud Profiles`,goToSettings:`Перейдіть до Налаштувань`,focusSearch:`Пошук по фокусу`,openUploadModal:`Відкрити вікно вивантаження`,clearSelection:`Очистити виділення / розмити введення`,contextMenu:`Контекстне меню на картках`,refreshProfiles:`Оновити профілі`,newProfile:`Новий профіль`,exitSelectionMode:`Вийти з режиму вибору`,showHelp:`Покажіть цю довідку`},footer:`Натисніть Esc або клацніть зовні, щоб закрити`},notificationLog:{title:`Журнал повідомлень`,events:{printStarted:`Друк розпочато`,printComplete:`Друк завершено`,printFailed:`Помилка друку`,printStopped:`Друк зупинено`,progress:`Прогрес`,printerOffline:`Принтер не в мережі`,printerError:`Помилка принтера`,lowFilament:`Закінчується філамент`,maintenanceDue:`Технічне обслуговування`,test:`Тест`},timeAgo:{justNow:`Просто зараз`,minutesAgo:`{{minutes}}хв тому`,hoursAgo:`{{hours}} год тому`}},restoreBackup:{title:`Відновити резервну копію`,restoring:`Відновлення...`,restoreComplete:`Відновлення завершено`,restoreFailed:`Не вдалося відновити`,importSettings:`Імпорт налаштувань із файлу резервної копії`,pleaseWait:`Зачекайте, поки ваші дані відновляться`,clickToSelect:`Натисніть, щоб вибрати файл резервної копії (.json або .zip)`,howDuplicateHandling:`Як працює обробка дублікатів:`,categories:{printers:`Принтери`,smartPlugs:`Розумні розетки`,notificationProviders:`Постачальники сповіщень`,filaments:`філаменти`,archives:`Архіви`,pendingUploads:`Вивантаження, що очікують`,settingsTemplates:`Налаштування та шаблони`},matchingInfo:{printers:`відповідає серійному номеру`,smartPlugs:`збігається з адресою IP.`,notificationProviders:`відповідність за назвою`,filaments:`відповідність за назвою + типом + брендом`,archives:`відповідає хешу вмісту`,pendingUploads:`відповідає імені файлу`,settingsTemplates:`завжди перезаписується`},replaceExisting:`Замінити існуючі дані`,keepExisting:`Зберегти наявні дані`,replaceDescription:`Замінити елементи, які вже існують, резервними копіями`,keepDescription:`Відновлюйте лише елементи, яких ще не існує`,caution:`Увага:`,cautionText:`Під час перезапису поточні конфігурації буде замінено даними з резервної копії. З міркувань безпеки коди доступу до принтерів ніколи не перезаписуються.`,itemsRestored:`Елементів відновлено`,itemsSkipped:`Елементи пропущені`,restored:`Відновлено`,skipped:`Пропущено (вже існує)`,filesLabel:`Файли (3MF, мініатюри тощо)`,newApiKeysGenerated:`Створено нові API ключі`,newApiKeysWarning:`Ці ключі буде показано лише один раз. Скопіюйте їх зараз!`,processingBackup:`Обробка файлу резервної копії...`,noDataFound:`У файлі резервної копії не знайдено даних для відновлення.`,failedToRestore:`Не вдалося відновити резервну копію. Будь ласка, перевірте формат файлу.`},backupExport:{title:`Експорт резервної копії`,selectData:`Вибрати дані для включення`,selectAll:`Вибрати усі`,selectNone:`Вибрати Немає`,categoryDescriptions:{settings:`Мова, тема, параметри оновлення`,notifications:`ntfy, Pushover, Discord тощо.`,templates:`Користувацькі шаблони повідомлень`,smartPlugs:`Конфігурації розетки Tasmota.`,externalLinks:`Посилання на бічній панелі на зовнішні служби`,printers:`Інформація про принтер (без кодів доступу)`,plateDetection:`Еталонні зображення порожньої пластини`,filaments:`Види та вартість філаменти`,maintenance:`Спеціальні графіки технічного обслуговування`,archives:`Усі дані для друку + файли (3MF, мініатюри, фотографії)`,projects:`Проєкти, елементи специфікації та вкладення`,pendingUploads:`Вивантаження на віртуальний принтер очікують на розгляд`,apiKeys:`Ключі Webhook API (нові ключі створюються під час імпорту)`},requiresPrinters:`Необхідно вибрати принтери`,zipFileWarning:`Буде створено файл ZIP.`,zipFileDescription:`До архіву ввійдуть усі файли 3MF, мініатюри, таймлапси та фотографії. Створення може тривати певний час, а отриманий файл може мати великий розмір.`,includeAccessCodes:`Додати коди доступу`,includeAccessCodesDescription:`Для перенесення на іншу машину`,includeAccessCodesWarning:`Коди доступу буде збережено у відкритому вигляді. Надійно захистіть файл резервної копії!`,categoriesSelected:`Вибрано категорії {{selectedCount}}.`},pendingUploads:{placeholders:{notes:`Додайте примітки щодо цього друку...`},discardUpload:`Відхилити вивантаження`,archiveAllUploads:`Архівувати всі вивантаження`,discardAllUploads:`Відхилити всі вивантаження`,archive:`Архів`,timeAgo:{justNow:`Просто зараз`,minutesAgo:`{{minutes}}хв тому`,hoursAgo:`{{hours}} год тому`,daysAgo:`{{days}} дн. тому`}},apiBrowser:{placeholders:{requestBody:`JSON тіло запиту...`,searchEndpoints:`Пошук кінцевих точок...`}},configureAmsSlot:{title:`Налаштувати слот AMS.`,slotConfigured:`Слот налаштовано!`,configuringSlot:`Налаштування слота:`,slotLabel:`{{ams}} Слот {{slot}}`,searchPresets:`Шукати профілі...`,colorPlaceholder:`Назва або шістнадцяткова назва кольору (наприклад, коричневий, FF8800)`,clearCustomColor:`Очистити спеціальний колір`,noCloudPresets:`Хмарних профілів немає. Увійдіть у Bambu Cloud для синхронізації.`,noPresetsAvailable:`Профілів немає. Увійдіть у Bambu Cloud або імпортуйте локальні профілі.`,noMatchingPresets:`Відповідних стилів не знайдено.`,custom:`Власний`,builtin:`Вбудований`,orcaCloud:`Orca Cloud`,bambuCloud:`Бамбукова хмара`,settingsSentToPrinter:`Налаштування надіслано на принтер`,filamentProfile:`Профіль філаменту`,kProfileLabel:`Профіль K (випередження тиску)`,filteringFor:`Фільтрування за: {{material}}`,noKProfile:`Немає профілю K (використовуйте значення за замовчуванням 0,020)`,noMatchingKProfiles:`Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.`,otherKProfiles:`Інші K-профілі на цьому принтері`,selectFilamentFirst:`Спочатку виберіть профіль філаменту`,kFromCalibration:`K={{value}} від калібрування принтера`,customColorLabel:`Власний колір (необов’язково)`,presetColors:`{{name}} кольори:`,showLessColors:`Показувати менше кольорів`,showMoreColors:`Показати більше кольорів`,clear:`Очистити`,hexLabel:`Шістнадцятковий: #{{hex}}`,resetting:`Скидання...`,resetSlot:`Скинути слот`,cancel:`Скасувати`,configuring:`Налаштування...`,configureSlot:`Налаштувати слот`},githubBackup:{title:`Резервне копіювання Git`,history:`історія`,downloadBackup:`Завантажити резервну копію`,restoreBackup:`Відновити резервну копію`,noBackupsYet:`Резервних копій ще немає`},emailSettings:{placeholders:{fromName:`BamBuddy`}},tagManagement:{searchTags:`Пошук тегів...`,renameTag:`Перейменувати тег`,deleteTag:`Видалити тег`},notificationTemplates:{placeholders:{title:`Назва сповіщення...`,body:`Текст сповіщення...`}},batchTag:{placeholders:{newTag:`Введіть новий тег...`}},photoGallery:{deletePhoto:`Видалити фото`},filamentHoverCard:{copySpoolUuid:`Копіювати UUID буфера`},kProfilesView:{hasNote:`Має примітку`,copyProfile:`Копіювати профіль`},layout:{openMenu:`Відкрити меню`,noPermissionSystemInfo:`У вас немає дозволу на перегляд системної інформації`},dashboard:{dragToReorder:`Перетягніть, щоб змінити порядок`,hideWidget:`Сховати віджет`},notificationProviderCard:{deleteNotificationProvider:`Видалити постачальника сповіщень`},fileManagerModal:{closeFileManager:`Закрити файловий менеджер`,sortFiles:`Сортування файлів`,goToParentFolder:`Перейти до батьківської папки`,threeView:`3D перегляд`},embeddedCameraViewer:{refreshStream:`Оновити потік`,close:`Закрити`,zoomOut:`Зменшити масштаб`,resetZoom:`Скинути масштаб`,zoomIn:`Збільшити`,dragToResize:`Перетягніть, щоб змінити розмір`},timelapseViewer:{skipBack5s:`Перейти на 5 секунд назад`,skipForward5s:`Перейти вперед на 5 с`},notificationProviders:{descriptions:{email:`Сповіщення електронною поштою SMTP`,telegram:`Сповіщення через бота Telegram.`,discord:`Надіслати на канал Discord через webhook`,ntfy:`Безкоштовні push-сповіщення з можливістю самостійного розгортання`,pushover:`Прості та надійні push-повідомлення`,callmebot:`Безкоштовні сповіщення WhatsApp через CallMeBot`,webhook:`Універсальний HTTP POST-запит на будь-який URL`}},logViewer:{searchPlaceholder:`Шукати повідомлення або назву реєстратора...`,noLogEntries:`Записів у журналі не знайдено`},switchbarPopover:{noSwitchesInSwitchbar:`На панелі перемикачів немає`},projectPageModal:{placeholders:{title:`Назва`,designer:`Дизайнер`,license:`Ліцензія`,description:`Введіть опис...`,profileTitle:`Назва профілю`,profileDescription:`Опис профілю...`}},spoolmanSettings:{},time:{unknown:`-`,waiting:`Очікування`,justNow:`Просто зараз`,now:`Зараз`,minsAgo:`{{count}}хв тому`,inMins:`в {{count}}м`,hoursAgo:`{{count}} год тому`,inHours:`в {{count}} год`,daysAgo:`{{count}} дн. тому`,inDays:`в {{count}}d`},spoolbuddy:{nav:{dashboard:`Приладова панель`,ams:`AMS`,inventory:`Інвентар`,writeTag:`Записати`,settings:`Налаштування`},status:{nfcReady:`NFC Готовий`,nfcOff:`NFC Вимк`,offline:`Не в мережі`,online:`Онлайн`,noPrinters:`Немає принтерів`,deviceOffline:`Пристрій не в мережі`,waitingConnection:`Очікування підключення пристрою...`,systemReady:`Система готова`,status:`Статус`},dashboard:{readyToScan:`Готовий до сканування`,idleMessage:`Покладіть котушку на ваги, щоб ідентифікувати її`,nfcHint:`Тег NFC буде прочитано автоматично`,device:`пристрій`,syncWeight:`Синхронізувати вагу`,weightSynced:`Синхронізовано!`,unknownTag:`Невідомий тег`,newTag:`Виявлено новий тег`,onScale:`на вагах`,linkSpool:`Прив’язати до котушки`,linkTagTitle:`Прив’язати тег до котушки`,linkTag:`Прив’язати тег`,selectSpool:`Виберіть котушку, до якої потрібно прив’язати цей тег:`,noUntagged:`Не знайдено котушок без тегів`,tagDetected:`Тег виявлено`,noTag:`Без тегу`,tagId:`Тег`,grossWeight:`Вага брутто`,spoolSize:`Розмір котушки`,close:`Закрити`,currentSpool:`Поточна котушка`,plateReady:`Пластина готова: {{name}}`,plateReadyLabel:`Пластини готові до очищення`,plateClearAction:`Очистити`,plateClearedToast:`Друкарська пластина позначена як очищена`,plateClearFailed:`Не вдалося позначити пластину як очищену`},modal:{spoolDetected:`Виявлено котушку`,assignToAms:`Призначити AMS`,syncWeight:`Синхронізувати вагу`,weightSynced:`Синхронізовано!`,syncing:`Синхронізація...`,newTagDetected:`Виявлено новий тег`,addToInventory:`Додати в інвентар`,assignToAmsTitle:`Призначити AMS`,selectSlot:`Вибрати слот`,assign:`Призначити`,assigning:`Призначення...`,assignSuccess:`Призначено!`,assignPendingInsert:`Призначений. Слот буде налаштовано, коли ви вставите котушку.`,assignError:`Не вдалося призначити котушку. Спробуйте ще раз.`,noPrinterSelected:`Виберіть принтер...`,noAmsDetected:`AMS не виявлено на цьому принтері`,slot:`Слот`},weight:{noReading:`Немає показань`,stable:`Стабільно`,measuring:`Вимірювання…`,tare:`Тарувати`,calibrate:`Відкалібрувати`},spool:{remaining:`Залишилося`,material:`матеріал`,brand:`Бренд`,color:`Колір`,coreWeight:`Сердечник`,labelWeight:`Етикетка`,scaleWeight:`Показання ваг`,netWeight:`Нетто`,lastUsed:`Останнє використання`,unknownColor:`Невідомий колір`},ams:{noData:`AMS не виявлено`,connectAms:`Підключіть AMS, щоб побачити слоти для філаментів`,noPrinter:`Принтер не вибрано`,selectPrinter:`Виберіть принтер у верхній панелі`,printerDisconnected:`Принтер відключений`,humidity:`Вологість`,level:`Рівень`,active:`Активний`,slot:`Слот`,empty:`Порожній`},inventory:{search:`Пошук котушок...`,empty:`Немає котушок в інвентарі`,noResults:`Немає відповідних котушок`,spools:`котушки`,addSpool:`Додати котушку`},settings:{tabDevice:`пристрій`,tabDisplay:`Дисплей`,tabScale:`Ваги`,tabUpdates:`Оновлення`,nfcReader:`NFC Читач`,type:`Тип`,connection:`Підключення`,notConnected:`Н/Д`,deviceInfo:`Інформація про пристрій`,hostname:`Хост`,uptime:`Час роботи`,systemConfig:`Серверна частина та автентифікація`,backendUrl:`Bambuddy Сервер URL`,apiToken:`API Токен`,apiTokenPlaceholder:`Введіть API-токен.`,saveConfig:`Зберегти конфігурацію`,systemQueued:`Конфігурацію в черзі.`,nfcDiagnostic:`NFC Діагностика`,scaleDiagnostic:`Діагностика ваг`,readTagDiagnostic:`Прочитайте діагностику тегів`,testNfc:`Тестовий читач`,testScale:`Перевірити точність`,testReadTag:`Прочитати тег`,systemFieldsRequired:`Потрібен бекенд URL.`,brightness:`Яскравість`,saved:`Збережено`,noBacklight:`Підсвічування DSI не виявлено. Для керування яскравістю потрібен дисплей DSI.`,screenBlank:`Час очікування порожнього екрана`,screenBlankDesc:`Екран вимикається після бездіяльності. Торкніться, щоб розбудити.`,displayNote:`Яскравість використовується як програмний фільтр.`,scaleCalibration:`Калібрування ваг`,currentWeight:`Поточна вага`,tareOffset:`Зміщення тари`,calFactor:`Коефіцієнт калібрування`,knownWeight:`Відома маса`,calStep1:`Приберіть усі предмети з ваг і натисніть «Установити нуль».`,calStep2:`Покладіть на ваги предмет відомої маси.`,setZero:`Встановити нуль`,calibrateNow:`Відкалібрувати`,calibrated:`Відкалібровано`,tareSet:`Надіслано команду тарування. Очікування пристрою...`,tareComplete:`Тарування завершено!`,tareTimedOut:`Час очікування тарування минув — чи запущено демон SpoolBuddy?`,tareFailed:`Не вдалося надіслати команду тарування`,zeroSet:`Нульову точку встановлено. Покладіть на ваги предмет відомої маси.`,calibrationDone:`Калібрування завершено!`,calibrationFailed:`Помилка калібрування`,lastCalibrated:`Останнє калібрування`,stable:`Стабільно`,settling:`Стабілізація…`,firmware:`Прошивка`,scale:`Ваги`,noDevice:`Пристрій SpoolBuddy не знайдено`,daemonVersion:`Демон версія`,currentVersion:`поточний`,versionPending:`В очікуванні демона...`,checking:`Перевірка...`,checkUpdates:`Перевірте наявність оновлень`,updateAvailable:`Доступне оновлення`,updateInstructions:`Оновлення через SSH: запустіть сценарій встановлення SpoolBuddy для оновлення.`,upToDate:`В актуальному стані`,includeBeta:`Включати бета-версії`},writeTag:{tabExisting:`Наявна котушка`,tabNew:`Нова котушка`,tabReplace:`Замінити тег`,searchPlaceholder:`Пошук за матеріалом, кольором, брендом...`,noUntaggedSpools:`Немає котушок без тегів`,noTaggedSpools:`Немає котушок із тегами`,selectSpool:`Виберіть котушку, а потім помістіть порожній NTAG на зчитувач`,placeTag:`Помістіть NTAG на зчитувач`,tagReady:`Тег виявлено — готовий до запису`,writeTag:`Записати тег`,replaceTag:`Замінити тег`,writing:`Записування тегу…`,waiting:`Очікування SpoolBuddy...`,writeSuccess:`Тег успішно записано!`,writeFailed:`Помилка запису`,queueFailed:`Не вдалося поставити команду запису в чергу`,tryAgain:`Спробуйте знову`,cancel:`Скасувати`,replaceWarning:`Старий тег буде від’єднано від котушки, а замість нього буде прив’язано новий.`,deviceOffline:`SpoolBuddy не в мережі`,material:`матеріал`,colorName:`Назва кольору`,color:`Колір`,brand:`Бренд`,weight:`Вага (г)`,createSpool:`Створити котушку`,creating:`Створення...`,spoolCreated:`Котушку створено! Можна записувати тег.`,createFailed:`Не вдалося створити котушку`,incompleteDataWarning:`Тег записано з неповними даними Spoolman.`},quickMenu:{printerPower:`Живлення принтера`,systemControls:`система`,restartDaemon:`Перезапустити демон`,restartBrowser:`Перезапустити браузер`,reboot:`Перезавантажити`,shutdown:`Вимкнути`,swipeToClose:`Проведіть пальцем вниз, щоб закрити`,confirmTitle:`Підтвердити`,confirmShutdown:`Вимкнути SpoolBuddy? Щоб увімкнути його знову, знадобиться фізичний доступ до пристрою.`,confirmReboot:`Перезавантажити SpoolBuddy?`,confirmRestartDaemon:`Перезапустити демон SpoolBuddy? NFC і ваги будуть тимчасово недоступні.`,confirmRestartBrowser:`Перезапустити веб-переглядач кіоску? Дисплей на короткий час погасне.`,confirm:`Підтвердити`,confirmPlugOn:`Увімкнути {{name}}?`,confirmPlugOff:`Вимкнути {{name}}?`,turnOn:`Увімкнути`,turnOff:`Вимкнути`}},diagnostic:{modalTitle:`Діагностика підключення — {{name}}`,running:`Виконується діагностика...`,runningElapsed:`Виконується діагностика… ({{elapsed}} с)`,waitingForReportHint:`Очікуємо, доки принтер опублікує звіт про стан — це може тривати до {{max}} с.`,runFailed:`Не вдалося запустити діагностику: {{error}}`,retry:`Запустити знову`,runButton:`Запустити діагностику`,sectionTitle:`Діагностика підключення`,sectionDescription:`Перевірте, чому принтер не підключається або не друкує — доступність порту, режим розробника LAN, режим мережі Docker та облікові дані.`,noPrinters:`Принтери не налаштовані.`,overall:{ok:`Проблем не виявлено — з’єднання принтера справне.`,warnings:`Принтер має працювати, але деякі параметри потребують уваги.`,problems:`Знайдено проблеми, які пояснюють, чому принтер не підключається або не друкує.`},check:{port_mqtt:{title:`Порт керування (MQTT 8883)`,pass:`Доступний — принтер приймає керівні з’єднання.`,fail:`Порт 8883 недоступний. Принтер може бути вимкнений, мати іншу IP-адресу або бути заблокований брандмауером. Перевірте IP-адресу принтера й переконайтеся, що порт 8883 не заблоковано.`},port_ftps:{title:`Порт передачі файлів (FTPS 990)`,pass:`Доступний — файли друку можна буде надсилати.`,warn:`Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.`,warn_no_tls:`Порт 990 відкритий, але файлова служба принтера не завершує рукостискання TLS. Файли друку, обкладинки та таймлапси отримати неможливо, тому архіви залишаються порожніми. Перезавантажте принтер — розблокування порту не допоможе.`},external_storage:{title:`Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)`,pass:`Принтер повідомляє, що цю опцію ввімкнено — надіслані файли зберігатимуться на картці SD, а архіви матимуть мініатюри та метадані слайсера.`,fail:`Принтер повідомляє, що цей параметр вимкнено. Увімкніть «Зберігати надіслані файли в зовнішньому сховищі». У новіших версіях прошивки (P2S 01.02 / Bambu Studio 2.6+) перемикач розташований у налаштуваннях друку на принтері; у старіших — на вкладці «Пристрій» у Bambu Studio або OrcaSlicer. Без цього параметра в кожному архівованому друці будуть відсутні мініатюра та метадані слайсера.`,skip:`Не перевірено — потрібне активне MQTT-з’єднання. У старіших слайсерах, де цей параметр існує лише в самому слайсері, принтер його не повідомляє. Тому перевірка може бути успішною навіть за вимкненого параметра — перевірте крок установлення 4 вручну.`,skip_unsupported_model:`Ця модель має слот для SD-картки, але не дає змоги ввімкнути цей параметр: поточна прошивка принтерів серії P1 не показує перемикач у Bambu Studio, а сам принтер не має екрана. Виправляти нічого не потрібно; доки Bambu Lab не додасть підтримку в прошивці, в архівованих друках можуть бути відсутні мініатюри та метадані слайсера.`,fail_no_media:`Параметр увімкнено, але принтер не бачить ані картки, ані флешки у слоті, тож надісланим файлам немає куди подітися. Вставте носій і надрукуйте ще раз — доти кожен заархівований друк буде без мініатюри та метаданих слайсера.`,warn_internal_storage:`Параметр увімкнено і носій на місці, але останній друк усе одно потрапив у внутрішню пам'ять принтера, яку Bambuddy не може прочитати. На серії H2, P2S та X2D кнопка «Друк» у Bambu Studio завжди надсилає туди, незалежно від цього параметра. Друки архівуються з назвою та часом, але без мініатюри та метаданих слайсера. Щоб архіви були повними, запускайте друк із Bambuddy, нарізайте в OrcaSlicer або в Bambu Studio скористайтеся «Надіслати» із «Зовнішнім носієм», а потім запускайте друк.`,warn_internal_history:`Опція увімкнена й носій на місці, але останній друк ішов із файлу, який уже був на принтері, — повторний друк з його екрана, запуск із Handy або файл, надісланий раніше. Ця бібліотека лежить у внутрішній пам'яті, яку Bambuddy не читає, і жодні налаштування цього не змінюють: для того друку нічого не надсилалося. Він і далі архівується з назвою та часом, але без мініатюри та метаданих слайсера. Щоб архіви були повними, запускайте друк із Bambuddy або надсилайте його зі слайсера.`},port_rtsps:{title:`Порт камери ({{protocol}} {{port}})`,pass:`Доступно — потік камери працюватиме.`,warn:`Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.`},macos_local_network:{title:`Дозвіл macOS на доступ до локальної мережі`,pass:`macOS дозволяє Bambuddy доступ до локальної мережі.`,warn_unsigned:`Python, під яким працює Bambuddy, не має підпису коду, тож macOS нема до чого прив’язати дозвіл на локальну мережу і мовчки відкидає всі з’єднання з принтером — без помилки та без запиту. Запустіть сценарій оновлення Bambuddy (install/update_macos.sh), який його підписує, і перезапустіть Bambuddy. Інтерпретатор: {{executable}}`,warn_permission:`Якщо принтер увімкнений і доступний за цією адресою, відкрийте Системні параметри > Конфіденційність і безпека > Локальна мережа та переконайтеся, що Python від Bambuddy увімкнено. Інакше macOS мовчки відкидає локальні з’єднання, а оновлення Python може залишити старий дозвіл позаду.`},network_mode:{title:`Мережевий режим контейнера`,genericRuntime:`контейнері`,pass:`Працює в {{runtime}} із мережею хоста.`,warn:`Bambuddy працює в {{runtime}} із мостовою мережею. Для виявлення принтерів і роботи віртуального принтера потрібна мережа хоста — повторно створіть контейнер із мережею хоста ("network_mode: host" у docker-compose, "--network=host" для Podman).`,skip:`Не працює в контейнері — не застосовується.`,skip_unknown:`Bambuddy працює в {{runtime}}, але визначити мережевий режим не вдалося. Якщо виявлення принтерів або віртуальний принтер не працюють, повторно створіть контейнер із мережею хоста.`,skip_system_container:`Bambuddy працює в системному контейнері {{runtime}}, який перебуває в локальній мережі як віртуальна машина — не застосовується.`},subnet:{title:`Мережева підмережа`,pass:`Принтер і Bambuddy знаходяться в одній підмережі.`,warn:`Принтер ({{printer_ip}}) і Bambuddy ({{host_ip}}) перебувають у різних підмережах. Вони можуть не мати зв’язку між собою, якщо маршрутизацію між підмережами не налаштовано.`,skip:`Не вдалося визначити підмережу — пропущено.`},mqtt_auth:{title:`Облікові дані принтера`,pass:`Принтер прийняв підключення.`,fail:`Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.`,fail_auth_rejected:`Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.`,skip:`Не позначено — принтер недоступний.`},developer_mode:{title:`Режим розробника LAN`,pass:`Режим розробника ввімкнено.`,fail:`Режим розробника на принтері ВИМКНЕНО. Увімкніть його в налаштуваннях LAN принтера й підтвердьте вибір кнопкою OK. Без цього друк не запускатиметься.`,skip:`Неможливо перевірити — потрібне живе підключення до принтера.`},printer_publishing:{title:`Принтер публікує дані про стан`,pass:`Принтер публікує оновлення статусу — AMS, філаменти та K-профілі правильно відображатимуться на слайсері.`,fail:`MQTT-брокер прийняв з’єднання, але принтер не опублікував жодного звіту про стан. Майже завжди причиною є неправильний серійний номер або неправильний регістр символів: тема device//report чутлива до регістру. Звірте серійний номер у налаштуваннях Bambuddy із номером на екрані принтера.`,skip:`Неможливо перевірити — потрібне живе підключення до принтера.`}}},systemHealth:{sectionTitle:`Стан системи`,sectionDescription:`Сканує останні журнали на наявність відомих проблем, які зазвичай можна вирішити самостійно, перш ніж вони перетворяться на запит у службу підтримки.`,rescan:`Пересканувати`,clean:`В останніх записах журналу {{times}} не знайдено відомих проблем.`,logUnavailable:`Журналування файлів вимкнено, тому журнали неможливо сканувати. Увімкніть журналювання файлів, щоб використовувати цю перевірку.`,learnMore:`Як виправити`,fixLabel:`Виправити:`,occurrences:`Виявлено {{times}} разів — востаннє о {{lastSeen}}`,category:{layer8:`Ви можете це виправити`,environment:`Середовище виконання`,bug:`Будь ласка, повідомте про це`},signature:{"ftp-auth-rejected":{name:`Принтер відхилив код доступу`,cause:`Принтер відмовився від входу для передавання файлів. Код доступу неправильний або він змінився після перемикання режиму розробника.`,fix:`Повторно скопіюйте код доступу з екрана принтера (налаштування LAN) і оновіть його в налаштуваннях принтера в Bambuddy.`},"ftp-connection-timeout":{name:`Час очікування з’єднання для передачі файлів минув`,cause:`Bambuddy не вдалося отримати доступ до порту передачі файлів принтера (FTPS 990). Порт заблоковано, або принтер вимкнено або знаходиться в іншій підмережі.`,fix:`Переконайтеся, що ніщо не блокує порт 990 між Bambuddy і принтером, і що обидва знаходяться в одній мережі.`},"ftp-ssl-error":{name:`Помилка захищеного з’єднання для передавання файлів`,cause:`Файлова служба принтера відповіла на порту 990 без TLS. Її файловий сервер завис — це несправність самого принтера, а не брандмауера чи прошивки.`,fix:`Перезапустіть принтер. До того часу файли друку, обкладинки та таймлапси отримати неможливо, але на сам друк це не впливає.`},"bambu-cloud-captcha":{name:`Bambu Cloud вимагає пройти CAPTCHA`,cause:`Захист Bambu від зловживань перевіряє цю мережу, тож завершити вхід у Bambu Cloud неможливо. Це пов'язано з публічною IP-адресою, а не з вашим обліковим записом чи цією інсталяцією.`,fix:`Зачекайте: зазвичай перевірка зникає за кілька годин, а повторні спроби входу її подовжують. Тим часом увійдіть за допомогою токена доступу із сеансу браузера.`},"mqtt-connection-flapping":{name:`Підключення до принтера постійно падає`,cause:`Керуюче з’єднання (MQTT 8883) неодноразово роз’єднується та знову під’єднується — зазвичай через слабкий мережевий шлях або частково заблокований порт.`,fix:`Перевірте сигнал Wi-Fi на принтері, віддайте перевагу дротовому підключенню та переконайтеся, що порт 8883 надійно доступний.`},"camera-connection-refused":{name:`Потік з камери недоступний`,cause:`Не вдається отримати доступ до камери в прямому ефірі через порт RTSPS 322. Порт заблоковано, або на принтері вимкнено камеру або LAN liveview.`,fix:`Увімкніть камеру та LAN liveview на принтері та переконайтеся, що порт 322 не заблоковано. Це не впливає на друк.`},"database-locked":{name:`Конфлікт запису до бази даних`,cause:`Під навантаженням SQLite повертає помилки "database is locked". Це часто трапляється, коли одночасно працюють кілька принтерів.`,fix:`Переключіть Bambuddy на зовнішню базу даних PostgreSQL. Див. посібник PostgreSQL у документації.`}}},vpDiagnostic:{title:`Перевірка налаштувань — {{name}}`,runButton:`Запустити перевірку налаштувань`,running:`Виконується перевірка налаштувань...`,runFailed:`Не вдалося виконати перевірку налаштувань: {{error}}`,retry:`Запустити знову`,overall:{ok:`Усі перевірки пройшли — цей віртуальний принтер налаштовано правильно.`,warnings:`Віртуальний принтер має працювати, але деякі параметри потребують уваги.`,problems:`Знайдено проблеми, які пояснюють, чому слайсер не бачить або не використовує цей віртуальний принтер.`},check:{enabled:{title:`Віртуальний принтер увімкнено`,fail:`Цей віртуальний принтер вимкнено. Увімкніть його, щоб зробити його видимим.`},running:{title:`Служби запущені`,fail:`Віртуальний принтер увімкнено, але його служби не працюють. Перевірте журнал Bambuddy — зазвичай запуску заважає конфлікт IP-адреси прив’язки або помилка дозволів.`},bind_interface:{title:`Прив’язка мережевого інтерфейсу`,fail:`Інтерфейс прив’язки не встановлено або більше не існує на цьому хості. Виберіть поточний інтерфейс у спадному списку «Інтерфейс прив’язки».`},access_code:{title:`Код доступу встановлено`,fail:`Код доступу не встановлено. У слайсері потрібно вказати той самий 8-символьний код доступу, який задано тут.`},target_printer:{title:`Цільовий принтер`,fail:`Цільовий принтер не вибрано. У режимі проксі потрібен фізичний принтер, якому пересилатимуться завдання.`,warn:`Цільовий принтер наразі не в мережі — проксі-сервер відновиться після повторного підключення.`},port_ftps:{title:`Служба вивантаження файлів (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може вивантажити файли. Зазвичай причиною є конфлікт порту на цьому інтерфейсі.`},port_mqtt:{title:`Служба керування (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може підключитися або показати стан.`},port_bind:{title:`Служба виявлення (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може завершити процедуру виявлення.`},privileged_ports:{title:`Прив’язка до привілейованих портів`,fail:`Порт {{port}} нижче 1024, і цій службі не дозволено його займати — тому вище ніщо не слухає. Додайте AmbientCapabilities=CAP_NET_BIND_SERVICE до /etc/systemd/system/bambuddy.service і перезапустіть або виконайте "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". У Docker додайте cap_add: [NET_BIND_SERVICE].`},certificate:{title:`Сертифікат TLS`,pass:`Сертифікат готовий. Переконайтеся, що наведений вище сертифікат центру сертифікації Bambuddy імпортовано до сховища довірених сертифікатів слайсера.`,fail:`Сертифікат TLS для цього віртуального принтера відсутній. Перевірте, чи доступний для запису каталог даних Bambuddy.`}}},bugReport:{title:`Повідомити про помилку`,description:`опис`,descriptionPlaceholder:`Що пішло не так? Будь ласка, опишіть проблему...`,email:`Електронна адреса (необов’язково)`,emailPlaceholder:`your@email.com`,emailPrivacy:`Якщо надано, вашу електронну адресу буде включено до згорнутого розділу проблеми GitHub, щоб спеціаліст із супроводу міг відслідковувати.`,screenshot:`Скріншот`,uploadOrPaste:`Вивантажити, вставити або перетягнути зображення`,dataCollectedSummary:`Які дані містяться у звіті?`,dataIncluded:`У комплекті:`,dataIncludedList:`Версія додатка, OS, архітектура, версія Python, статистика бази даних (лише для підрахунку), моделі принтерів, кількість сопел, версії прошивки, статус підключення, статус інтеграції (Spoolman, MQTT, HA), неконфіденційні налаштування, кількість мережевих інтерфейсів, Docker деталі, версії залежностей.`,dataNeverIncluded:`Ніколи не додаються:`,dataNeverIncludedList:`Назви принтерів, серійні номери, коди доступу, паролі, IP-адреси, адреси електронної пошти, ключі API, токени, URL вебхуків, імена хостів або користувачів.`,submit:`Надіслати`,startLogging:`Розпочати журнал налагодження`,stepEnableLogging:`Журнал налагодження ввімкнено`,stepReproduce:`Відтворіть проблему зараз`,stepStopLogging:`Зупинити та подати звіт`,stopAndSubmit:`Зупинити та надіслати`,maxDuration:`Автоматична зупинка через {{minutes}} хв`,stoppingLogs:`Збір журналів і надсилання...`,submitting:`Надсилання звіту про помилку...`,submittingStepConnection:`Запуск перевірки підключення принтера`,submittingStepVirtualPrinters:`Виконання перевірок налаштування віртуального принтера`,submittingStepLogScan:`Сканування останніх журналів на наявність відомих проблем`,submittingStepSubmit:`Надсилання звіту GitHub`,submitSuccess:`Звіт про помилку успішно надіслано!`,submitFailed:`Не вдалося надіслати звіт про помилку`,diagnosticChecking:`Перевірка підключень принтера...`,diagnosticHealthy:`Перевірку підключення пройдено — проблем із принтерами не виявлено.`,diagnosticSummary:`Проблеми з підключенням мають {{problems}} із {{total}} принтерів`,diagnosticIntro:`Один або кілька принтерів мають проблему з підключенням, яка може спричиняти описану несправність. Розгорніть запис принтера нижче, щоб переглянути спосіб виправлення. Усунення цієї проблеми може допомогти без створення звіту про помилку, але за потреби звіт усе одно можна надіслати нижче.`,logHealthSummary:`У ваших журналах знайдено відомі проблеми`,logHealthIntro:`Останні журнали відповідають відомим проблемам. Перегляньте наведені нижче виправлення — їх вирішення може вирішити вашу проблему без повідомлення про помилку. Ви все ще можете надіслати звіт нижче.`,thankYou:`дякую!`,submitted:`Ваш звіт про помилку надіслано.`,viewIssue:`Переглянути випуск`,closeKeepsRecording:`Ви можете закрити цю панель, поки відтворюєте проблему: запис триває, і після повторного відкриття ви повернетеся сюди.`,resumeRecording:`Триває запис звіту про помилку — {{elapsed}}. Натисніть, щоб завершити.`,resumeReport:`Продовжити звіт`,unexpectedError:`Сталася неочікувана помилка`},failureDetection:{title:`Виявлення помилок за допомогою ШІ`,description:`Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.`,mlUrl:`URL Obico ML API`,mlUrlHint:`База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).`,mlToken:`Токен ML API (необов'язково)`,mlTokenPlaceholder:`Залиште порожнім, якщо сервер працює без ML_API_TOKEN`,mlTokenHint:`Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.`,test:`Тест`,testSuccess:`ML API доступний і працює належним чином.`,testSuccessTokenUnknown:`ML API доступний і працює належним чином. Не вдалося перевірити токен.`,testFailed:`Не вдалося підключитися до ML API.`,sensitivity:`Чутливість`,sensitivityLow:`Низький (менше помилкових спрацьовувань)`,sensitivityMedium:`Середній (збалансований)`,sensitivityHigh:`Високий (виявляти рано, більше помилкових спрацьовувань)`,sensitivityHint:`Налаштовує пороги впевненості, за яких формуються попередження та фіксуються помилки.`,action:`Дія в разі виявлення помилки`,actionNotify:`Лише сповіщати`,actionPause:`Призупинити друк`,actionPauseOff:`Призупинити друк і вимкнути живлення`,pollInterval:`Інтервал опитування (секунди)`,pollIntervalHint:`Як часто перевіряти кожен принтер під час друку. Мінімум 5 с, максимум 120 с.`,externalUrlMissing:`Зовнішній URL не встановлено.`,externalUrlHint:`ML API отримує знімок із камери за URL. Укажіть зовнішній URL у загальних налаштуваннях, щоб контейнер ML API міг підключитися до Bambuddy.`,perPrinterTitle:`Принтери під спостереженням`,perPrinterHint:`Виберіть, які принтери перевіряє служба виявлення.`,monitorAll:`Відстежувати всі підключені принтери`,statusTitle:`Статус`,serviceRunning:`Служба працює`,thresholds:`Нижній / верхній поріг`,activePrinters:`Активні завдання друку`,noActivePrints:`Зараз немає роздруківок.`,historyTitle:`Останні виявлення`,noHistory:`Виявлень ще немає.`},makerworld:{title:`MakerWorld`,description:`Вставте модель MakerWorld URL, щоб імпортувати та роздрукувати її безпосередньо з Bambuddy — не виходячи з програми Bambu Handy.`,pasteUrlHeader:`Імпортувати з MakerWorld`,pasteUrlPlaceholder:`https://makerworld.com/en/models/… або вставте будь-яке посилання MakerWorld.`,resolveButton:`Розв’язати`,signInRequiredTitle:`Для завантаження потрібно увійти в Bambu Cloud`,signInRequiredBody:`Ви можете переглядати деталі моделі анонімно, але для MakerWorld потрібен обліковий запис Bambu Cloud для завантаження файлів 3MF.`,signInExpiredTitle:`Термін входу в Bambu Cloud минув`,signInExpiredBody:`Ви все ще ввійшли в Bambuddy, але Bambu Lab більше не приймає збережений токен, тому завантаження не вдасться. Знову ввійдіть у Bambu Cloud.`,openCloudSettings:`Відкрити налаштування Cloud`,untitledModel:`Модель без назви`,byCreator:`від {{name}}`,downloadsCount:`{{count}} завантажень`,licensePrefix:`Ліцензія`,alreadyImported:`Вже в бібліотеці`,openOnMakerworld:`Відкрити MakerWorld`,alreadyInLibrary:`Ця модель уже є у вашій бібліотеці — знайдіть її в Менеджері файлів → MakerWorld`,importSuccess:`Імпортовано {{filename}} — збережено в менеджері файлів → MakerWorld`,platesHeader:`Пластини ({{count}})`,plateDefaultName:`Пластина {{n}}`,materialCount:`Філаментів: {{count}}`,amsRequired:`Потрібен AMS`,slicedFor:`Нарізано для {{printer}}`,alsoCompatible:`Також позначено як сумісний: {{printers}}`,importToLibrary:`Зберегти`,sliceIn:`Зберегти й нарізати в {{slicer}}`,disclaimer:`Інтеграція MakerWorld використовує кінцеві точки API, задокументовані спільнотою. Bambuddy не є філією та не підтримується MakerWorld або Bambu Lab.`,lastImportSuccess:`Імпортовано до вашої бібліотеки`,lastImportAlreadyInLibrary:`Уже у вашій бібліотеці`,viewInLibrary:`Переглянути в менеджері файлів`,openInBambuStudio:`Відкрити в Bambu Studio`,openInOrcaSlicer:`Відкрити в OrcaSlicer`,importTo:`Імпорт до файлового менеджера`,recentImportsHeader:`Недавній імпорт`,phaseResolving:`Розв’язування`,phaseDownloading:`Завантаження`,folderAuto:`MakerWorld (за замовчуванням)`,importAll:`Імпортувати все`,importAllProgress:`Імпорт {{current}}/{{total}}`,openGallery:`Відкрити галерею зображень`,galleryPrev:`Попереднє зображення`,galleryNext:`Наступне зображення`,deleteImport:`Видалити з бібліотеки`,importDeleting:`Видалення…`,importDeleted:`Видалено з бібліотеки`,confirmDelete:`Видалити {{filename}} з бібліотеки? Локальний файл буде видалено, але цю друкарську пластину можна буде повторно імпортувати з MakerWorld.`,errors:{resolveFailed:`Не вдалося вирішити MakerWorld URL.`,downloadFailed:`Помилка завантаження. Спробуйте ще раз.`,deleteFailed:`Не вдалося видалити файл із бібліотеки.`}},gcodeViewer:{filamentSlot:`Філамент {{n}}`,loading:`Читання траєкторії…`,loadFailed:`Не вдалося завантажити G-код цього файлу.`,showTravel:`Холості переміщення`,topLayer:`Верхній шар`,bottomLayer:`Нижній шар`,noSource:`Файл для перегляду не вказано.`,view:{filament:`Філамент`,feature:`Тип`,height:`Висота`,width:`Ширина`},feature:{wall:`Стінки`,sparseInfill:`Розріджене заповнення`,solidInfill:`Суцільне заповнення`,bridge:`Міст / навис`,support:`Підтримка`,skirt:`Спідниця / облямівка`,gapFill:`Заповнення проміжків`,ironing:`Розгладжування`,primeTower:`Вежа очищення`},back:`Назад`,backToArchives:`Назад до друку архівів`,backToFiles:`Назад до файлового менеджера`,plates:`Пластини`,plateN:`Пластина {{n}}`},libraryTrash:{title:`Кошик`,headerButton:`Кошик`,headerTooltip:`Переглянути файли, переміщені в кошик`,backToFiles:`Назад до файлового менеджера`,subtitleAdmin:`Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються. У цьому режимі перегляду відображаються файли, видалені в кошик для всіх користувачів.`,subtitleUser:`Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються.`,loading:`Завантаження кошика…`,loadError:`Не вдалося завантажити кошик.`,empty:`Кошик порожній.`,summary:`Файлів: {{count}} · {{size}}`,emptyTrash:`Очистити кошик`,restore:`Відновити`,purgeNow:`Видалити зараз`,autoPurgeIn:`Автоматичне видалення через {{when}}`,days:`днів`,retentionLabel:`Автоматичне видалення після`,selectAll:`Вибрати все`,selectOne:`Вибрати {{filename}}`,selectionCount:`Вибрано {{count}}.`,bulkRestore:`Відновити вибране`,bulkPurge:`Видалити вибране`,col:{filename:`Файл`,folder:`Папка`,size:`Розмір`,deleted:`Переміщено в кошик`,autoPurge:`Автоматичне видалення`,owner:`Власник`,actions:`Дії`},confirm:{purgeTitle:`Видалити остаточно?`,purgeBody:`{{filename}} буде видалено з диска та не підлягає відновленню.`,emptyTitle:`Очистити кошик?`,emptyBody:`З диска буде остаточно видалено всі файли ({{count}}). Цю дію неможливо скасувати.`,bulkPurgeTitle:`Видалити вибрані файли назавжди?`,bulkPurgeBody:`Вибрані файли ({{count}}) буде видалено з диска без можливості відновлення.`,cta:`Видалити остаточно`},toast:{restored:`Файл відновлено.`,restoreFailed:`Не вдалося відновити файл.`,purged:`Файл видалено остаточно.`,purgeFailed:`Не вдалося видалити файл.`,emptied:`Файлів остаточно видалено з кошика: {{count}}.`,emptyFailed:`Не вдалося очистити кошик.`,retentionSaved:`Для автоматичного видалення встановлено {{days}} днів.`,retentionFailed:`Не вдалося зберегти налаштування збереження.`,bulkRestored:`Відновлено файлів: {{count}}`,bulkPurged:`Файлів остаточно видалено: {{count}}.`}},libraryPurge:{title:`Очистити старі файли`,headerButton:`Очищення старого`,headerTooltip:`Масове переміщення старих файлів у кошик`,description:`Однією дією перемістіть старі файли з бібліотеки до кошика. Вік файлів з історією друку визначається за датою останнього друку, а файлів, які ще не друкувалися, — за датою вивантаження.`,ageLabel:`Перемістити файли, старші за`,days:`днів`,includeNeverPrinted:`Включати файли, які ніколи не друкувались`,effectsTitle:`Що відбудеться після натискання «Очистити»`,effect1:`Відповідні файли переміщуються в кошик — вони ще не видалені з диска.`,effect2:`Ви можете будь-коли відновити їх із кошика, доки не закінчиться період зберігання.`,effect3:`Після завершення строку зберігання служба очищення кошика остаточно видалить файли з диска.`,effect4:`Файли у зовнішніх (зв’язаних) папках пропускаються — Bambuddy ніколи не видаляє байти, які йому не належать.`,previewLoading:`Перевірка кількості файлів…`,previewFailed:`Не вдалося попередньо переглянути очищення.`,previewSummary:`Файли {{count}} · {{size}} буде переміщено до кошика`,andMore:`…і {{count}} більше`,warning:`Файли в кошику й надалі займають місце, доки не завершиться строк зберігання. Щоб звільнити місце на диску негайно, після цього очистьте кошик.`,confirmCta:`Перемістити до кошика ({{count}})`,purging:`Переміщення в кошик…`,toast:{success:`Переміщено до кошика файлів: {{count}}`,failed:`Не вдалося очистити файли.`}},libraryAutoPurge:{enableLabel:`Автоматичне очищення старих файлів`,enableDescription:`Запускає очищення адміністратора один раз на день. Файли спочатку потрапляють у кошик — вони не видаляються одразу.`,ageLabel:`Автоматичне очищення файлів, старших за`,ageDescription:`Мінімум 7 днів, максимум 10 років. Використовує те саме вікове правило, що й ручна кнопка очищення.`,days:`днів`,includeNeverPrinted:`Включати файли, які ніколи не друкувались`,saveFailed:`Не вдалося зберегти налаштування автоматичного очищення.`},archivePurge:{headerButton:`Очищення старого`,headerTooltip:`Масове видалення старих архівів`,title:`Очистити старі архіви`,description:`Очистити стару історію друку. Кожен архів старіє відповідно до останнього завершення друку — повторний друк архіву оновлює його вік, тому активна робота ніколи не очищається.`,ageLabel:`Видалити архіви, не надруковані в останньому`,days:`днів`,effectsTitle:`Що відбудеться після натискання «Очистити»`,effect1:`Кожен відповідний архів приховано зі списків, а його файли видалено з диска (3MF, ескіз, покадрова зйомка, вихідний 3MF, F3D файл дизайну, фотографії).`,effect2:`Запис архіву залишиться в базі даних, тому «Швидка статистика» й надалі враховуватиме філамент, час, вартість та енергію — як і за типового видалення окремого архіву.`,effect3:`Позначте нижче «Також видалити зі статистики», щоб вилучити внесок цих архівів зі «Швидкої статистики» — так само, як у відповідній опції видалення окремого архіву. Цю дію неможливо скасувати.`,effect4:`Повторний друк архіву оновлює його віковий годинник, тому архіви, якими ви все ще користуєтеся, залишаються в безпеці.`,purgeStatsLabel:`Також видалити зі статистики`,purgeStatsHint:`Вилучає внесок відповідних архівів зі «Швидкої статистики» — філамент, час, вартість та енергію. Якщо параметр вимкнено, статистичні дані зберігаються, а з диска видаляються лише файли.`,previewLoading:`Перевірка кількості архівів, що збігаються…`,previewFailed:`Не вдалося попередньо переглянути очищення.`,previewSummary:`Архіви {{count}} · {{size}} буде видалено`,andMore:`…і {{count}} більше`,warning:`Файли видаляються з диска і не можуть бути відновлені. Перш ніж продовжити, завантажте або додайте до вибраного все, що хочете зберегти.`,confirmCta:`Видалити архіви ({{count}})`,purging:`Видалення…`,toast:{success:`Видалено архівів: {{count}}`,failed:`Не вдалося очистити архіви.`}},archiveAutoPurge:{enableLabel:`Автоматичне очищення старих архівів`,enableDescription:`Один раз на день приховує архіви зі списків і видаляє їх файли з диска, якщо вони не були надруковані в межах порогу. Повторний друк архіву скидає годинник.`,ageLabel:`Автоматичне видалення архівів, не надрукованих в останньому`,ageDescription:`Мінімум 7 днів, максимум 10 років. На основі останнього завершення друку — повторний друк архіву оновлює його вік. Видаляє 3MF, мініатюру, покадрову зйомку, джерело 3MF, F3D і фотографії.`,days:`днів`,purgeStatsLabel:`Також видалити зі статистики`,purgeStatsDescription:`Якщо параметр увімкнено, щоденне очищення також вилучатиме внесок кожного видаленого архіву зі «Швидкої статистики» — філамент, час, вартість та енергію. Типово параметр вимкнено: статистичні дані зберігаються, а з диска видаляються лише файли.`,runNow:`Очистити архіви зараз`,saveFailed:`Не вдалося зберегти налаштування автоматичного очищення.`},cameraTokens:{scope:{camera_stream:`Потік камери`,camwall:`Панель камер`,overlay:`Потокове накладання`},title:`API-токени камери`,navTitle:`API-токени камери`,description:`Довгострокові токени для вбудовування потоку камери в Home Assistant, Frigate, кіоски або інші засоби, яким потрібен постійний URL. Під час створення виберіть область доступу; токен можна відкликати будь-коли.`,loading:`Завантаження…`,confirmRevoke:{title:`Відкликати цей токен?`,body:`Усі пристрої, що використовують «{{name}}», негайно втратять доступ. Цю дію неможливо скасувати.`,cancel:`Скасувати`,confirm:`Відкликати`},create:{scopeLabel:`Область застосування`,hintCameraStream:`Токен потоку камери дає змогу отримувати лише потоки з камер і знімки. Використовуйте його для Home Assistant, Frigate або іншого засобу, що вбудовує одну камеру.`,hintCamWall:`Токен Cam Wall відкриває /camwall на екрані без входу. Він може бачити назву та стан кожного принтера, а також потоки з камери. Він не може бачити імена файлів, адреси чи коди доступу.`,hintOverlay:`Токен потокового накладання відкриває /overlay/{printerId} на екрані без входу — для OBS або будь-якої прямої трансляції. Він дає змогу переглядати потік із камери одного принтера та стан друку в реальному часі, зокрема назву файлу, показану на екрані. Він не надає доступу до адрес чи кодів доступу.`,title:`Створити новий токен`,nameLabel:`Назва токена`,namePlaceholder:`напр. Home Assistant`,daysLabel:`Днів до закінчення терміну дії`,submit:`Створити`,hint:`Максимальний строк дії — 365 днів. Значення токена буде показано лише один раз під час створення — скопіюйте його зараз.`},created:{camWallUrlTitle:`Cam Wall URL для цього дисплея`,camWallUrlHint:`Відкрийте цей URL на потрібному екрані. Будь-хто, хто отримає URL, зможе переглядати панель камер, тому захищайте його як ключ доступу. Щоб негайно припинити доступ із цього екрана, відкличте токен.`,overlayUrlTitle:`Накладання URL для OBS`,overlayUrlHint:`Додайте цей URL в OBS як джерело браузера. Замініть число в /overlay/1 номером принтера з його URL на сторінці «Принтери». Будь-хто, хто отримає URL, зможе переглядати трансляцію, тому захищайте його як ключ доступу. Щоб негайно припинити доступ, відкличте токен.`,title:`Токен створено — скопіюйте його зараз`,warning:`Це єдиний раз, коли токен буде показано. Після закриття цього діалогового вікна переглянути його знову буде неможливо.`,copy:`Копіювати`,dismiss:`Я зберіг токен`},list:{scope:`Область застосування`,myTitle:`Мої токени`,allTitle:`Усі користувачі (перегляд адміністратора)`,empty:`Токенів ще немає.`,name:`Назва`,owner:`Власник`,prefix:`Префікс`,created:`Створено`,expires:`Термін дії закінчується`,lastUsed:`Останнє використання`,revoke:`Відкликати`,expired:`Термін дії минув`},toast:{created:`Токен створено`,createFailed:`Не вдалося створити токен`,revoked:`Токен відкликано`,revokeFailed:`Не вдалося відкликати токен`,loadFailed:`Не вдалося завантажити токени`,copied:`Скопійовано в буфер обміну`,copyFailed:`Не вдалося копіювати — виберіть і скопіюйте вручну`}},forecast:{title:`Прогноз`,noSpools:`Не знайдено активних котушок. Додайте котушки до свого інвентарю, щоб переглянути прогнозні дані.`,noUsageData:`Немає даних про використання — неможливо спроєктувати хронологію запасів.`,sku:`SKU`,material:`матеріал`,stock:`Запас`,dailyRate:`Добове споживання`,daysLeft:`Залишилося днів`,emptyBy:`Вичерпається до`,reorderBy:`Замовити до`,actions:`Дії`,trend:`Тренд`,estimated:`Приблизно`,noData:`Немає даних`,timeframe:`Період`,chartTitle:`Прогнозований запас — топ-5 матеріалів`,dashedLinesROP:`Пунктирні лінії — точки повторного замовлення`,stockLevel:`Рівень запасів`,reorderPoint:`Точка повторного замовлення`,safetyMargin:`Запас безпеки`,trendLegend:`Тенденція (на основі історії, рівень обслуговування 95%)`,estimatedLegend:`Розрахункова (дельта ваги)`,noDataLegend:`Немає даних`,ropLabel:`ROP`,ssLabel:`SS`,safetyStockLegend:`Страховий запас`,stockArrivalLegend:`Надходження запасів`,stockoutLegend:`Дефіцит`,alertCount_one:`Сповіщень: {{count}}`,alertCount_other:`Сповіщень: {{count}}`,order:`Замовлення`,save:`Зберегти`,cancel:`Скасувати`,settingsSaved:`Налаштування збережено`,failedSaveSettings:`Не вдалося зберегти налаштування`,globalLeadTimeSaved:`Загальний строк постачання збережено`,globalLeadTime:`Загальний строк постачання`,globalLeadTimeHint:`Мінімальний строк постачання для розрахунку точки повторного замовлення всіх SKU`,skuLeadTimeOverride:`Інший строк постачання`,skuLeadTimeHint:`0 — використовувати загальний строк. Установіть значення понад 0, щоб перевизначити його для цього SKU.`,safetyMarginLabel:`Запас безпеки`,effectiveLeadTime:`Розрахунковий строк постачання`,effectiveLeadTimeHint:`max(глобальний {{global}}d, SKU {{sku}}d)`,reorderPointHint:`d̄ × LT + страховий запас — замовляйте, коли запас досягне цього рівня`,safetyMarginHint:`Статистичний страховий запас (z=1,65 × σ × √LT) + визначений користувачем буфер`,safetyMarginHintDays:`Буфер додано поверх статистичного страхового запасу.{{approx}}`,safetyMarginHintDaysApprox:` ≈ {{g}} г за поточного рівня споживання.`,safetyMarginHintG:`Буфер фіксованої ваги, доданий поверх статистичного страхового запасу.{{approx}}`,safetyMarginHintGApprox:` ≈ {{days}} дн. за поточного рівня споживання.`,individualSpools:`Окремі котушки`,labelWeight:`Вага за етикеткою`,spoolCount_one:`Котушок: {{count}}`,spoolCount_other:`Котушок: {{count}}`,stockBreakRisk:`Ризик вичерпання запасу`,stockBreakDetail:`Залишилося {{days}} дн.; строк постачання — {{lt}} дн.`,stockBreakBefore:`Запас вичерпається до поповнення`,reorderNow:`Замовити зараз`,reorderTriggerPassed:`Дата замовлення {{date}} минула.`,shoppingList:`Список покупок`,shoppingListItems_one:`(позицій: {{count}})`,shoppingListItems_other:`(позицій: {{count}})`,shoppingListEmpty:`Список покупок порожній. Натисніть значок кошика в будь-якому рядку, щоб додати позиції.`,addToCart:`Додати до списку покупок`,alertsSnoozed:`Вимкнути сповіщення для цього SKU`,alertsEnabled:`Увімкнути сповіщення для цього SKU`,addedToCart:`Додано до списку покупок`,failedAddItem:`Не вдалося додати елемент`,listView:`Список`,logisticsView:`Логістика`,qty:`Кількість`,weight:`Вага`,leadTime:`Строк постачання`,expectedRestock:`Очікуване поповнення запасів`,status:`Статус`,note:`Примітка`,pending:`В очікуванні`,purchased:`Придбано`,received:`Отримано`,markPurchased:`Позначити як придбане`,markReceived:`Позначити як отримане — додає котушки до запасу`,resetToPending:`Скинути до очікування`,remove:`Видалити`,clearAll:`Очистити все`,downloadCsv:`CSV`,addToCartTitle:`Додати до списку покупок`,byQuantity:`За кількістю`,byDuration:`За тривалістю`,numberOfSpools:`Кількість котушок`,lastHowManyDays:`Скільки днів має тривати?`,noUsageQty:`Немає даних про використання — кількість встановлено на 1.`,noteOptional:`Примітка (необов’язково)`,notePlaceholder:`напр. для проєкту X, терміново…`,addNSpools_one:`Додати котушки ({{count}})`,addNSpools_other:`Додати котушки ({{count}})`,onArrival:`Після прибуття`,stockBreakIn:`Запас вичерпається через {{days}} дн.`,stockRunsOutBefore:`Запас закінчиться раніше, ніж мине строк постачання {{lt}} дн.`,atRate:`За споживання {{rate}} г/день потрібно`,moreSpools_one:`Додаткових котушок: {{count}}`,moreSpools_other:`Додаткових котушок: {{count}}`,bridgeGap:`щоб покрити нестачу.`,noReadAccess:`Ви не маєте дозволу переглядати прогнози запасів.`,noWriteAccess:`Ви не маєте дозволу змінювати налаштування прогнозу.`}}}},Et=[`en`,`de`,`es`,`fr`,`ja`,`it`,`ko`,`nl`,`pt-BR`,`ru`,`tr`,`uk`,`zh-CN`,`zh-TW`],Dt=`bambuddy_appliance_locale_consumed`;Oe.use(wt).use(qe).init({resources:Tt,fallbackLng:`en`,supportedLngs:Et,detection:{order:[`localStorage`,`navigator`,`htmlTag`],lookupLocalStorage:`bambutrack_language`,caches:[`localStorage`]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});function Ot(){if(typeof window>`u`||!window.localStorage)return;let e=window.localStorage;typeof e.getItem!=`function`||typeof e.setItem!=`function`||e.getItem(Dt)||fetch(`/api/v1/system/appliance`).then(e=>e.ok?e.json():null).then(t=>{!t||typeof t.locale!=`string`||Et.includes(t.locale)&&(Oe.changeLanguage(t.locale),e.setItem(Dt,`1`))}).catch(()=>{})}Ot();var kt=[{code:`en`,name:`English`,nativeName:`English`},{code:`de`,name:`German`,nativeName:`Deutsch`},{code:`es`,name:`Spanish`,nativeName:`Español`},{code:`fr`,name:`French`,nativeName:`Français`},{code:`ja`,name:`Japanese`,nativeName:`日本語`},{code:`it`,name:`Italian`,nativeName:`Italiano`},{code:`ko`,name:`Korean`,nativeName:`한국어`},{code:`nl`,name:`Dutch`,nativeName:`Nederlands`},{code:`pt-BR`,name:`Portuguese (Brazil)`,nativeName:`Português (Brasil)`},{code:`zh-CN`,name:`Chinese (Simplified)`,nativeName:`简体中文`},{code:`zh-TW`,name:`Chinese (Traditional)`,nativeName:`繁體中文`},{code:`tr`,name:`Turkish`,nativeName:`Türkçe`},{code:`ru`,name:`Russian`,nativeName:`Русский`},{code:`uk`,name:`Ukrainian`,nativeName:`Українська`}],At=l(g()),jt=`modulepreload`,Mt=function(e){return`/`+e},Nt={},Pt=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=Mt(t,n),t in Nt)return;Nt[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:jt,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Ft=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,It=/^[\\/]{2}/;function Lt(e,t){return t+e.replace(/\\/g,`/`)}var Rt=`popstate`;function zt(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Bt(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return Gt(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:Kt(t)}return Jt(t,n,null,e)}function Vt(e,t){if(e===!1||e==null)throw Error(t)}function Ht(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function Ut(){return Math.random().toString(36).substring(2,10)}function Wt(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function Gt(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?qt(t):t,state:n,key:t&&t.key||r||Ut(),mask:i}}function Kt({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function qt(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function Jt(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=zt(e)?e:Gt(h.location,e,t);n&&n(r,e),l=u()+1;let d=Wt(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=zt(e)?e:Gt(h.location,e,t);n&&n(r,e),l=u();let i=Wt(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return Yt(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(Rt,d),c=e,()=>{i.removeEventListener(Rt,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function Yt(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),Vt(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:Kt(t);return i=i.replace(/ $/,`%20`),!n&&It.test(i)&&(i=r+i),new URL(i,r)}function Xt(e,t,n=`/`){return Zt(e,t,n,!1)}function Zt(e,t,n,r,i){let a=_n((typeof t==`string`?qt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??$t(e),s=null,c=gn(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;Vt(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=Tn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(Vt(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),en(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:un(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=hn(e.relativePath,e.caseSensitive,t===u.length-1);return{...e,matcher:n,compiledParams:r}})})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of tn(e.path))a(e,t,!0,n)}),t}function tn(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=tn(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function W(e){e.sort((e,t)=>e.score===t.score?dn(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var nn=/^:[\w-]+$/,rn=3,an=2,on=1,sn=10,cn=-2,ln=e=>e===`*`;function un(e,t){let n=e.split(`/`),r=n.length;return n.some(ln)&&(r+=cn),t&&(r+=an),n.filter(e=>!ln(e)).reduce((e,t)=>e+(nn.test(t)?rn:t===``?on:sn),r)}function dn(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function fn(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function hn(e,t=!1,n=!0){Ht(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function gn(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return Ht(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function _n(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}function vn(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?qt(e):e,a;return n?(n=wn(n),a=n.startsWith(`/`)?yn(n.substring(1),`/`):yn(n,t)):a=t,{pathname:a,search:On(r),hash:kn(i)}}function yn(e,t){let n=En(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function bn(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function xn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Sn(e){let t=xn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function Cn(e,t,n,r=!1){let i;typeof e==`string`?i=qt(e):(i={...e},Vt(!i.pathname||!i.pathname.includes(`?`),bn(`?`,`pathname`,`search`,i)),Vt(!i.pathname||!i.pathname.includes(`#`),bn(`#`,`pathname`,`hash`,i)),Vt(!i.search||!i.search.includes(`#`),bn(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=vn(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var wn=e=>e.replace(/[\\/]{2,}/g,`/`),Tn=e=>wn(e.join(`/`)),En=e=>e.replace(/\/+$/,``),Dn=e=>En(e).replace(/^\/*/,`/`),On=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,kn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,An=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function jn(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Mn(e){return Tn(e.map(e=>e.route.path).filter(Boolean))||`/`}var Nn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Pn(e,t){let n=e;if(typeof n!=`string`||!Ft.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Nn)try{let e=new URL(window.location.href),r=It.test(n)?new URL(Lt(n,e.protocol)):new URL(n),a=_n(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{Ht(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Fn=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Fn);var In=[`GET`,...Fn];new Set(In);var Ln=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function Rn(e){try{return Ln.includes(new URL(e).protocol)}catch{return!1}}var zn=y.createContext(null);zn.displayName=`DataRouter`;var Bn=y.createContext(null);Bn.displayName=`DataRouterState`;var Vn=y.createContext(!1);function Hn(){return y.useContext(Vn)}var Un=y.createContext({isTransitioning:!1});Un.displayName=`ViewTransition`;var Wn=y.createContext(new Map);Wn.displayName=`Fetchers`;var Gn=y.createContext(null);Gn.displayName=`Await`;var Kn=y.createContext(null);Kn.displayName=`Navigation`;var qn=y.createContext(null);qn.displayName=`Location`;var Jn=y.createContext({outlet:null,matches:[],isDataRoute:!1});Jn.displayName=`Route`;var Yn=y.createContext(null);Yn.displayName=`RouteError`;var Xn=`REACT_ROUTER_ERROR`,Zn=`REDIRECT`,Qn=`ROUTE_ERROR_RESPONSE`;function $n(e){if(e.startsWith(`${Xn}:${Zn}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function er(e){if(e.startsWith(`${Xn}:${Qn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new An(t.status,t.statusText,t.data)}catch{}}function tr(e,{relative:t}={}){Vt(nr(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(Kn),{hash:i,pathname:a,search:o}=fr(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Tn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function nr(){return y.useContext(qn)!=null}function rr(){return Vt(nr(),`useLocation() may be used only in the context of a component.`),y.useContext(qn).location}var ir=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function ar(e){y.useContext(Kn).static||y.useLayoutEffect(e)}function or(){let{isDataRoute:e}=y.useContext(Jn);return e?jr():sr()}function sr(){Vt(nr(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(zn),{basename:t,navigator:n}=y.useContext(Kn),{matches:r}=y.useContext(Jn),{pathname:i}=rr(),a=JSON.stringify(Sn(r)),o=y.useRef(!1);return ar(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(Ht(o.current,ir),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=Cn(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Tn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var cr=y.createContext(null);function lr(){return y.useContext(cr)}function ur(e){let t=y.useContext(Jn).outlet;return y.useMemo(()=>t&&y.createElement(cr.Provider,{value:e},t),[t,e])}function dr(){let{matches:e}=y.useContext(Jn);return e[e.length-1]?.params??{}}function fr(e,{relative:t}={}){let{matches:n}=y.useContext(Jn),{pathname:r}=rr(),i=JSON.stringify(Sn(n));return y.useMemo(()=>Cn(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function pr(e,t){return mr(e,t)}function mr(e,t,n){Vt(nr(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(Kn),{matches:i}=y.useContext(Jn),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Nr(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let u=rr(),d;if(t){let e=typeof t==`string`?qt(t):t;Vt(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):Xt(e,{pathname:p});Ht(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),Ht(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=xr(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Tn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Tn([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?y.createElement(qn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function hr(){let e=Ar(),t=jn(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=y.createElement(y.Fragment,null,y.createElement(`p`,null,`💿 Hey developer 👋`),y.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,y.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,y.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),y.createElement(y.Fragment,null,y.createElement(`h2`,null,`Unexpected Application Error!`),y.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?y.createElement(`pre`,{style:i},n):null,o)}var gr=y.createElement(hr,null),_r=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=er(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Jn.Provider,{value:this.props.routeContext},y.createElement(Yn.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(yr,{error:e},t):t}};_r.contextType=Vn;var vr=new WeakMap;function yr({children:e,error:t}){let{basename:n}=y.useContext(Kn);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=$n(t.digest);if(e){let r=vr.get(t);if(r)throw r;let i=Pn(e.location,n),a=i.absoluteURL||i.to;if(Rn(a))throw Error(`Invalid redirect location`);if(Nn&&!vr.get(t))if(i.isExternal||e.reloadDocument)window.location.href=a;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw vr.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${a}`})}}return e}function br({routeContext:e,match:t,children:n}){let r=y.useContext(zn);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Jn.Provider,{value:e},n)}function xr(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);Vt(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Mn(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||gr,o&&(s<0&&c===0?(Nr(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?y.createElement(n.route.Component,null):n.route.element?n.route.element:e,y.createElement(br,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(_r,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function Sr(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Cr(e){let t=y.useContext(zn);return Vt(t,Sr(e)),t}function wr(e){let t=y.useContext(Bn);return Vt(t,Sr(e)),t}function Tr(e){let t=y.useContext(Jn);return Vt(t,Sr(e)),t}function Er(e){let t=Tr(e),n=t.matches[t.matches.length-1];return Vt(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Dr(){return Er(`useRouteId`)}function Or(){let e=wr(`useNavigation`);return y.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function kr(){let{matches:e,loaderData:t}=wr(`useMatches`);return y.useMemo(()=>e.map(e=>Qt(e,t)),[e,t])}function Ar(){let e=y.useContext(Yn),t=wr(`useRouteError`),n=Er(`useRouteError`);return e===void 0?t.errors?.[n]:e}function jr(){let{router:e}=Cr(`useNavigate`),t=Er(`useNavigate`),n=y.useRef(!1);return ar(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{Ht(n.current,ir),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Mr={};function Nr(e,t,n){!t&&!Mr[e]&&(Mr[e]=!0,Ht(!1,n))}y.memo(Pr);function Pr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return mr(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Fr({to:e,replace:t,state:n,relative:r}){Vt(nr(),` may be used only in the context of a component.`);let{static:i}=y.useContext(Kn);Ht(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=y.useContext(Jn),{pathname:o}=rr(),s=or(),c=Cn(e,Sn(a),o,r===`path`),l=JSON.stringify(c);return y.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function Ir(e){return ur(e.context)}function Lr(e){Vt(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Rr({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){Vt(!nr(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=y.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=qt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=y.useMemo(()=>{let e=_n(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return Ht(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:y.createElement(Kn.Provider,{value:c},y.createElement(qn.Provider,{children:t,value:h}))}function zr({children:e,location:t}){return pr(Br(e),t)}y.Component;function Br(e,t=[]){let n=[];return y.Children.forEach(e,(e,r)=>{if(!y.isValidElement(e))return;let i=[...t,r];if(e.type===y.Fragment){n.push.apply(n,Br(e.props.children,i));return}Vt(e.type===Lr,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),Vt(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Br(e.props.children,i)),n.push(a)}),n}var Vr=`get`,Hr=`application/x-www-form-urlencoded`;function Ur(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Wr(e){return Ur(e)&&e.tagName.toLowerCase()===`button`}function Gr(e){return Ur(e)&&e.tagName.toLowerCase()===`form`}function Kr(e){return Ur(e)&&e.tagName.toLowerCase()===`input`}function qr(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Jr(e,t){return e.button===0&&(!t||t===`_self`)&&!qr(e)}function Yr(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Xr(e,t){let n=Yr(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Zr=null;function Qr(){if(Zr===null)try{new FormData(document.createElement(`form`),0),Zr=!1}catch{Zr=!0}return Zr}var $r=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function ei(e){return e!=null&&!$r.has(e)?(Ht(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Hr}"`),null):e}function ti(e,t){let n,r,i,a,o;if(Gr(e)){let o=e.getAttribute(`action`);r=o?_n(o,t):null,n=e.getAttribute(`method`)||Vr,i=ei(e.getAttribute(`enctype`))||Hr,a=new FormData(e)}else if(Wr(e)||Kr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a