From 1eea194953f2667ef0eb402e704f2e9ba7a77406 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 6 Aug 2026 08:19:01 +0200 Subject: [PATCH] Resolve a spool's material to a known drying preset before starting a cycle (#2774) The drying popover prefilled its material from the loaded spool without checking the preset table had that material. An AMS-HT holding Support for PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as the material, and the dropdown displays its first option when handed a value outside its list -- so it read PLA while PLA-S was sent. Same gap for every composite: PETG-CF prefilled at PLA's 45C. Resolve the tray_type to a key the table has before setting either value. Support materials and composites resolve to their base, nylon is aliased under its several spellings, and anything unrecognised falls back to PLA -- the coolest row, so an unknown material under-dries rather than deforming a PLA spool. Also record request-topic messages in the MQTT debug log. That topic carries every command a printer is given, including Bambu Studio's, and returned before the logging block -- so a capture could show only what the printer said, never what it was told. --- CHANGELOG.md | 2 + backend/app/services/bambu_mqtt.py | 19 ++ .../tests/unit/services/test_bambu_mqtt.py | 93 ++++++ .../src/__tests__/utils/dryingPresets.test.ts | 97 +++++++ frontend/src/pages/PrintersPage.tsx | 15 +- frontend/src/utils/dryingPresets.ts | 45 +++ .../{index-Cy2mY2rf.js => index-joRUZURS.js} | 270 +++++++++--------- static/index.html | 2 +- 8 files changed, 401 insertions(+), 142 deletions(-) create mode 100644 frontend/src/__tests__/utils/dryingPresets.test.ts create mode 100644 frontend/src/utils/dryingPresets.ts rename static/assets/{index-Cy2mY2rf.js => index-joRUZURS.js} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b001a8459..d7f388c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ All notable changes to Bambuddy will be documented in this file. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. ### Changed +- **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests. - **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **The drying popover no longer starts a cycle under a material you did not pick (#2774)** — An AMS-HT loaded with Support for PLA/PETG offered PLA in the drying dialog's filament list, and the cycle that started was labelled Support for PLA/PETG on the printer's own screen. Opening the dialog prefills it from the spool that is loaded, and Bambu reports that spool's material as `PLA-S` — a name Bambuddy's table of drying temperatures does not carry. The temperature and duration fell back to PLA's 45°C for twelve hours, which is what the dialog showed and what was sent, but the material did not fall back with them: it stayed `PLA-S`, and the dropdown, handed a value that is not one of its options, displays its first option without saying so. So the list read PLA while `PLA-S` was what left for the printer. Anyone who opened the dialog and pressed **Start** without touching the material was affected; picking any entry from the list, even the same one it was already showing, made the two agree again. The same gap covered every composite — a spool of PETG-CF, PLA-CF, ABS-GF or PAHT-CF prefilled the dialog at PLA's 45°C, far short of what those materials want, and sent its full name as the material. Bambuddy now resolves a spool's material to an entry the list actually has before either value is set, so what the dialog shows and what the printer is told can no longer disagree. Support materials and composites resolve to the material they are built on, so PLA-S dries as PLA and PETG-CF as PETG at 65°C, and nylon is recognised under the several spellings Bambu gives it. Anything genuinely unrecognised still falls back to PLA, deliberately the coolest setting in the table — under-drying an exotic filament costs a cycle, where defaulting to the hottest would deform a PLA spool. This does not address the other half of that report: a printer that keeps showing the material set from its own screen even when Bambuddy names a different one, which needs a capture of what Bambu Studio sends before anything can sensibly be changed. Covered by frontend tests. - **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests. - **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests. - **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 4d9d11c71..d5ba09744 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1364,6 +1364,25 @@ class BambuMQTTClient: # Intercept request-topic messages (print commands from slicer/Bambuddy) if msg.topic == self.topic_publish: + # Record it before returning. This topic carries every command + # travelling *to* the printer, including the ones Bambu Studio + # sends, and it used to be the one thing an MQTT capture could + # never show -- which is why "what does Studio put in the drying + # command?" had no answer from a user's log (#2774). Filed as + # "out" so the direction filter groups it with our own commands + # rather than with printer telemetry; anything sent through + # send_command lands twice, once on publish and once on the + # broker's echo, and the pair is itself evidence the command + # reached the broker. + if self._logging_enabled: + self._message_log.append( + MQTTLogEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + topic=msg.topic, + direction="out", + payload=payload, + ) + ) self._handle_request_message(payload) return diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index c36acf1bc..b011d1c44 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -1987,6 +1987,99 @@ class TestRequestTopicFailSafe: assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False +class TestRequestTopicIsCaptured: + """The MQTT debug log has to show commands going *to* the printer. + + Bambuddy subscribes to the request topic as well as the report topic, so + every command the printer is given crosses this client -- ours echoed back + by the broker, and whatever Bambu Studio sends. Those messages used to + return from _on_message before the logging block, which left a capture able + to prove only what the printer said and never what it was told. Answering + "what does Studio put in this field?" from a user's log depends on it + (#2774). + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + client.enable_logging(True) + return client + + @staticmethod + def _deliver(client, topic, payload): + class _Msg: + pass + + msg = _Msg() + msg.topic = topic + msg.payload = json.dumps(payload).encode() + client._on_message(None, None, msg) + + def test_a_command_on_the_request_topic_is_logged(self, mqtt_client): + payload = { + "print": { + "command": "ams_filament_drying", + "ams_id": 128, + "temp": 45, + "duration": 12, + "filament": "PLA", + } + } + self._deliver(mqtt_client, mqtt_client.topic_publish, payload) + + logs = mqtt_client.get_logs() + assert len(logs) == 1 + assert logs[0].topic == mqtt_client.topic_publish + # Filed with the commands rather than with telemetry: the direction + # filter is how someone finds what was sent to the printer. + assert logs[0].direction == "out" + assert logs[0].payload == payload + + def test_the_payload_is_kept_whole(self, mqtt_client): + """The point of the capture is the fields we don't parse.""" + payload = {"print": {"command": "ams_filament_drying", "unparsed_field": "keep me"}} + self._deliver(mqtt_client, mqtt_client.topic_publish, payload) + + assert mqtt_client.get_logs()[0].payload["print"]["unparsed_field"] == "keep me" + + def test_request_topic_messages_are_still_parsed(self, mqtt_client): + """Logging is additive -- the ams_mapping capture must survive it.""" + self._deliver( + mqtt_client, + mqtt_client.topic_publish, + {"print": {"command": "project_file", "ams_mapping": [0, 4, -1, -1]}}, + ) + + assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1] + assert len(mqtt_client.get_logs()) == 1 + + def test_nothing_is_logged_while_logging_is_off(self, mqtt_client): + mqtt_client.enable_logging(False) + + self._deliver( + mqtt_client, + mqtt_client.topic_publish, + {"print": {"command": "project_file", "ams_mapping": [0, -1, -1, -1]}}, + ) + + assert mqtt_client.get_logs() == [] + assert mqtt_client._captured_ams_mapping == [0, -1, -1, -1] + + def test_the_report_topic_is_still_logged_as_incoming(self, mqtt_client): + """Telemetry keeps its direction -- the two must stay distinguishable.""" + self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"gcode_state": "IDLE"}}) + + logs = mqtt_client.get_logs() + assert len(logs) == 1 + assert logs[0].direction == "in" + + class TestRequestTopicAmsMapping: """Tests for capturing ams_mapping from the MQTT request topic.""" diff --git a/frontend/src/__tests__/utils/dryingPresets.test.ts b/frontend/src/__tests__/utils/dryingPresets.test.ts new file mode 100644 index 000000000..43b008ec9 --- /dev/null +++ b/frontend/src/__tests__/utils/dryingPresets.test.ts @@ -0,0 +1,97 @@ +/** + * The drying popover has to name a material the printer will recognise. + * + * It seeds two things from one lookup: the temperature it prefills, and the + * filament name the start command carries. The dropdown rendering that name + * falls back to its first option when the value isn't in its list -- silently + * -- so a lookup that can return something outside the table shows one material + * and sends another. An AMS-HT holding Support for PLA/PETG displayed PLA and + * told the printer PLA-S (#2774). These tests pin the invariant that closes + * that: whatever comes back is a key the table has. + */ +import { describe, it, expect } from 'vitest'; + +import { resolveDryingPresetKey, type DryingPreset } from '../../utils/dryingPresets'; + +// The shipped table, as PrintersPage.tsx defines it. +const PRESETS: Record = { + 'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 }, + 'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 }, + 'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 }, + 'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, + 'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, + 'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 }, + 'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, + 'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 }, +}; + +describe('resolveDryingPresetKey', () => { + it('always answers with a key the table has', () => { + // The invariant behind #2774. Anything outside the table reaches the + // dropdown as a value it will not display and the printer as a material + // the user never chose. + const trayTypes = [ + 'PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', + 'PLA-S', 'PLA-CF', 'PETG-CF', 'PET-CF', 'ABS-GF', 'ASA-CF', 'PAHT-CF', + 'PA6-CF', 'PPS-CF', 'PPA-CF', 'HIPS', 'PP', 'PE', 'EVA', 'PHA', 'PCTG', + 'Nylon', 'TPU for AMS', '', ' ', 'wildly unknown', + ]; + for (const trayType of trayTypes) { + expect(PRESETS).toHaveProperty(resolveDryingPresetKey(trayType, PRESETS)); + } + }); + + it('passes a listed material straight through', () => { + expect(resolveDryingPresetKey('PETG', PRESETS)).toBe('PETG'); + expect(resolveDryingPresetKey('PVA', PRESETS)).toBe('PVA'); + }); + + it('is case-insensitive and ignores a trailing qualifier', () => { + expect(resolveDryingPresetKey('petg', PRESETS)).toBe('PETG'); + expect(resolveDryingPresetKey('TPU for AMS', PRESETS)).toBe('TPU'); + }); + + it('dries a support material as its base', () => { + // Support for PLA/PETG reports as PLA-S -- the case from the report. + expect(resolveDryingPresetKey('PLA-S', PRESETS)).toBe('PLA'); + }); + + it('dries a composite as its base rather than defaulting to PLA', () => { + // The reason the suffix is stripped instead of just falling back: PETG-CF + // wants PETG's 65 degrees, and landing on PLA's 45 would quietly waste the + // cycle. + expect(resolveDryingPresetKey('PETG-CF', PRESETS)).toBe('PETG'); + expect(resolveDryingPresetKey('PLA-CF', PRESETS)).toBe('PLA'); + expect(resolveDryingPresetKey('ABS-GF', PRESETS)).toBe('ABS'); + expect(resolveDryingPresetKey('ASA-CF', PRESETS)).toBe('ASA'); + }); + + it('recognises the polyamide family under its own spellings', () => { + expect(resolveDryingPresetKey('PA6-CF', PRESETS)).toBe('PA'); + expect(resolveDryingPresetKey('PAHT-CF', PRESETS)).toBe('PA'); + expect(resolveDryingPresetKey('Nylon', PRESETS)).toBe('PA'); + }); + + it('falls back to the coolest row for an unknown material', () => { + // Under-drying an exotic filament wastes a cycle; PA's 85 degrees would + // deform a PLA spool. So an unrecognised material must never inherit a + // hotter row than PLA's. + for (const unknown of ['PPS-CF', 'PEEK', 'wildly unknown']) { + expect(resolveDryingPresetKey(unknown, PRESETS)).toBe('PLA'); + } + }); + + it('handles an empty tray', () => { + // No spool loaded -- the popover still has to open on something. + expect(resolveDryingPresetKey(undefined, PRESETS)).toBe('PLA'); + expect(resolveDryingPresetKey(null, PRESETS)).toBe('PLA'); + expect(resolveDryingPresetKey('', PRESETS)).toBe('PLA'); + }); + + it('respects a custom preset table', () => { + // Users can override the table from settings, so the lookup answers about + // the table it was handed, not the shipped one. + const custom = { ...PRESETS, 'PLA-S': { n3f: 55, n3s: 55, n3f_hours: 8, n3s_hours: 8 } }; + expect(resolveDryingPresetKey('PLA-S', custom)).toBe('PLA-S'); + }); +}); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2b87a325a..1e0885005 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { computePopoverPosition } from '../utils/popoverPosition'; +import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets'; import { isExternalSpoolHidden, setExternalSpoolHidden as persistExternalSpoolHidden, @@ -1781,7 +1782,7 @@ export function AmsNameHoverCard({ // AMS drying presets from BambuStudio filament profiles (idle mode temps) // Format: { n3f temp, n3s temp, n3f hours, n3s hours } -const DRYING_PRESETS: Record = { +const DRYING_PRESETS: Record = { 'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 }, 'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 }, 'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 }, @@ -1882,7 +1883,7 @@ function PrinterCard({ cameraViewMode?: 'window' | 'embedded'; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; checkPrinterFirmware?: boolean; - dryingPresets?: Record; + dryingPresets?: Record; requirePlateClear?: boolean; selectionMode?: boolean; isSelected?: boolean; @@ -4879,8 +4880,9 @@ function PrinterCard({ setDryingPopoverAmsId(null); } else { const firstTray = ams.tray.find(t => t.tray_type); - const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase(); - const preset = dryingPresets[filType] || dryingPresets['PLA']; + const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets); + // Only reachable if a custom preset set dropped PLA itself. + const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA']; const moduleType = ams.module_type as 'n3f' | 'n3s'; setDryingFilament(filType); setDryingTemp(preset[moduleType] || preset.n3f); @@ -5429,8 +5431,9 @@ function PrinterCard({ setDryingPopoverAmsId(null); } else { const firstTray = ams.tray.find(t => t.tray_type); - const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase(); - const preset = dryingPresets[filType] || dryingPresets['PLA']; + const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets); + // Only reachable if a custom preset set dropped PLA itself. + const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA']; const moduleType = ams.module_type as 'n3f' | 'n3s'; setDryingFilament(filType); setDryingTemp(preset[moduleType] || preset.n3f); diff --git a/frontend/src/utils/dryingPresets.ts b/frontend/src/utils/dryingPresets.ts new file mode 100644 index 000000000..f63cbd0d6 --- /dev/null +++ b/frontend/src/utils/dryingPresets.ts @@ -0,0 +1,45 @@ +// Resolving an AMS tray's material to a row in the drying preset table. +// +// The table itself lives with the UI that renders it; only the lookup is here, +// so it can be exercised without dragging a page module into the test. + +export type DryingPreset = { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }; + +// Materials whose AMS spelling differs from the preset table's key. Bambu +// labels nylon "PA" while its own composites spell the family out, so PA6 and +// PAHT would otherwise miss a table that has a perfectly good PA row. +const DRYING_MATERIAL_ALIASES: Record = { + 'NYLON': 'PA', + 'PA6': 'PA', + 'PAHT': 'PA', +}; + +/** + * Pick the preset key for a tray's material. + * + * The answer is always a key the table actually has, which is the whole point: + * the drying popover seeds both the temperature and the filament name the start + * command carries from this, and the dropdown silently falls back to its first + * option when handed a value that isn't in its list. Seeding it with a raw + * `tray_type` therefore displayed "PLA" while sending the raw string -- an + * AMS-HT holding Support for PLA/PETG (`tray_type` "PLA-S") showed PLA in the + * dropdown and told the printer PLA-S (#2774). + * + * `tray_type` carries plenty of spellings the table doesn't list: support + * materials (PLA-S) and composites (PETG-CF, PLA-CF, ABS-GF, PAHT-CF) all dry + * as their base material, so the suffix is dropped before giving up. Anything + * still unrecognised lands on PLA, deliberately the coolest row -- under-drying + * an exotic filament wastes a cycle, where defaulting to PA's 85 degrees would + * deform a PLA spool. + */ +export function resolveDryingPresetKey( + trayType: string | null | undefined, + presets: Record, +): string { + const raw = (trayType || '').split(' ')[0].toUpperCase(); + for (const candidate of [raw, raw.split('-')[0]]) { + const key = DRYING_MATERIAL_ALIASES[candidate] ?? candidate; + if (presets[key]) return key; + } + return 'PLA'; +} diff --git a/static/assets/index-Cy2mY2rf.js b/static/assets/index-joRUZURS.js similarity index 88% rename from static/assets/index-Cy2mY2rf.js rename to static/assets/index-joRUZURS.js index 96ed4365e..aafb89130 100644 --- a/static/assets/index-Cy2mY2rf.js +++ b/static/assets/index-joRUZURS.js @@ -47,11 +47,11 @@ 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 Темп`,amsHtHumidity:`AMS-HT Вологість`,amsHtTemp:`Температура AMS-HT`,bedCooled:`Стіл охолов`,firstLayer:`Перший шар`,quiet:`Тихо`,digest:`Дайджест {{time}}`,printStarted:`Друк розпочато`,plateNotEmpty:`Друкарська пластина не порожня`,plateNotEmptyDescription:`Перед друком виявлено об’єкти`,plateClearRequired:`Потрібно очистити пластину`,plateClearRequiredDescription:`Друк завершено, черга очікує підтвердження очищення пластини`,plateClear:`Очищення пластини`,printCompleted:`Друк завершено`,bedCooledLabel:`Стіл охолов`,bedCooledDescription:`Після друку стіл охолов нижче заданого порога`,firstLayerCompleteLabel:`Перший шар завершено`,firstLayerCompleteDescription:`Сповістити зі знімком після завершення першого шару`,missingSpoolAssignmentLabel:`Відсутнє призначення котушки`,missingSpoolAssignmentDescription:`Сповіщати, коли починається друк і для необхідних лотків не призначено котушку`,printFailed:`Помилка друку`,printStopped:`Друк зупинено`,progressMilestones:`Віхи прогресу`,progressMilestonesDescription:`Сповіщати на 25%, 50%, 75%`,printerOffline:`Принтер не в мережі`,printerError:`Помилка принтера`,aiFailureDetection:`Виявлення помилок за допомогою ШІ`,aiFailureDetectionDescription:`Сповіщати, коли ШІ Obico виявляє можливу помилку друку`,lowFilamentLabel:`Закінчується філамент`,maintenanceDue:`Настав час технічного обслуговування`,maintenanceDueDescription:`Повідомити, коли потрібне обслуговування`,amsHumidityHigh:`AMS Висока вологість`,amsHumidityHighDescription:`Звичайна AMS вологість перевищує порогове значення`,amsTemperatureHigh:`AMS Висока температура`,amsTemperatureHighDescription:`Звичайна 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 потребує уваги)`,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:`Останнє використання`},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 не заблоковано.`},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 не додасть підтримку в прошивці, в архівованих друках можуть бути відсутні мініатюри та метадані слайсера.`},port_rtsps:{title:`Порт камери ({{protocol}} {{port}})`,pass:`Доступно — потік камери працюватиме.`,warn:`Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.`},network_mode:{title:`Мережевий режим Docker`,pass:`Працює в режимі хост-мережі.`,warn:`Bambuddy працює в мостовому мережевому режимі Docker. Для виявлення принтерів і роботи віртуального принтера потрібен режим мережі хоста — повторно створіть контейнер із параметром "network_mode: host".`,skip:`Не працює в Docker — не застосовується.`},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:`Не вдалося виконати TLS-узгодження із сервером передавання файлів принтера. Частою причиною є брандмауер або застаріла прошивка принтера.`,fix:`Оновіть прошивку принтера та переконайтеся, що брандмауер або проксі-сервер не перехоплюють з’єднання через порт 990.`},"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:`Переглянути випуск`,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:{back:`Назад`,backToArchives:`Назад до друку архівів`,backToFiles:`Назад до файлового менеджера`},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:`Ви не маєте дозволу змінювати налаштування прогнозу.`}}}},Tt=[`en`,`de`,`es`,`fr`,`ja`,`it`,`ko`,`pt-BR`,`ru`,`tr`,`uk`,`zh-CN`,`zh-TW`],Et=`bambuddy_appliance_locale_consumed`;Ee.use(Ct).use(Ge).init({resources:wt,fallbackLng:`en`,supportedLngs:Tt,detection:{order:[`localStorage`,`navigator`,`htmlTag`],lookupLocalStorage:`bambutrack_language`,caches:[`localStorage`]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});function Dt(){if(typeof window>`u`||!window.localStorage)return;let e=window.localStorage;typeof e.getItem!=`function`||typeof e.setItem!=`function`||e.getItem(Et)||fetch(`/api/v1/system/appliance`).then(e=>e.ok?e.json():null).then(t=>{!t||typeof t.locale!=`string`||Tt.includes(t.locale)&&(Ee.changeLanguage(t.locale),e.setItem(Et,`1`))}).catch(()=>{})}Dt();var Ot=[{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:`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:`Українська`}],kt=`modulepreload`,At=function(e){return`/`+e},jt={},Mt=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=At(t,n),t in jt)return;jt[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`:kt,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)})},Nt=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,Pt=/^[\\/]{2}/;function Ft(e,t){return t+e.replace(/\\/g,`/`)}var It=`popstate`;function Lt(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Rt(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return Ht(``,{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:Ut(t)}return Gt(t,n,null,e)}function zt(e,t){if(e===!1||e==null)throw Error(t)}function Bt(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function G(){return Math.random().toString(36).substring(2,10)}function Vt(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 Ht(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?Wt(t):t,state:n,key:t&&t.key||r||G(),mask:i}}function Ut({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 Wt(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 Gt(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=Lt(e)?e:Ht(h.location,e,t);n&&n(r,e),l=u()+1;let d=Vt(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=Lt(e)?e:Ht(h.location,e,t);n&&n(r,e),l=u();let i=Vt(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 Kt(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(It,d),c=e,()=>{i.removeEventListener(It,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 Kt(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),zt(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:Ut(t);return i=i.replace(/ $/,`%20`),!n&&Pt.test(i)&&(i=r+i),new URL(i,r)}function qt(e,t,n=`/`){return Jt(e,t,n,!1)}function Jt(e,t,n,r,i){let a=hn((typeof t==`string`?Wt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??Xt(e),s=null,c=mn(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;zt(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=Cn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(zt(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),Zt(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:cn(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=pn(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 Qt(e.path))a(e,t,!0,n)}),t}function Qt(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=Qt(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 $t(e){e.sort((e,t)=>e.score===t.score?ln(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var en=/^:[\w-]+$/,tn=3,nn=2,rn=1,an=10,on=-2,sn=e=>e===`*`;function cn(e,t){let n=e.split(`/`),r=n.length;return n.some(sn)&&(r+=on),t&&(r+=nn),n.filter(e=>!sn(e)).reduce((e,t)=>e+(en.test(t)?tn:t===``?rn:an),r)}function ln(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 un(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 pn(e,t=!1,n=!0){Bt(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 mn(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return Bt(!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 hn(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 gn(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?Wt(e):e,a;return n?(n=Sn(n),a=n.startsWith(`/`)?_n(n.substring(1),`/`):_n(n,t)):a=t,{pathname:a,search:En(r),hash:Dn(i)}}function _n(e,t){let n=wn(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function vn(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 yn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function bn(e){let t=yn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function xn(e,t,n,r=!1){let i;typeof e==`string`?i=Wt(e):(i={...e},zt(!i.pathname||!i.pathname.includes(`?`),vn(`?`,`pathname`,`search`,i)),zt(!i.pathname||!i.pathname.includes(`#`),vn(`#`,`pathname`,`hash`,i)),zt(!i.search||!i.search.includes(`#`),vn(`#`,`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=gn(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Sn=e=>e.replace(/[\\/]{2,}/g,`/`),Cn=e=>Sn(e.join(`/`)),wn=e=>e.replace(/\/+$/,``),Tn=e=>wn(e).replace(/^\/*/,`/`),En=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,Dn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,On=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 kn(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function An(e){return Cn(e.map(e=>e.route.path).filter(Boolean))||`/`}var jn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Mn(e,t){let n=e;if(typeof n!=`string`||!Nt.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(jn)try{let e=new URL(window.location.href),r=Pt.test(n)?new URL(Ft(n,e.protocol)):new URL(n),a=hn(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{Bt(!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 Nn=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Nn);var Pn=[`GET`,...Nn];new Set(Pn);var Fn=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function In(e){try{return Fn.includes(new URL(e).protocol)}catch{return!1}}var Ln=y.createContext(null);Ln.displayName=`DataRouter`;var Rn=y.createContext(null);Rn.displayName=`DataRouterState`;var zn=y.createContext(!1);function Bn(){return y.useContext(zn)}var Vn=y.createContext({isTransitioning:!1});Vn.displayName=`ViewTransition`;var Hn=y.createContext(new Map);Hn.displayName=`Fetchers`;var Un=y.createContext(null);Un.displayName=`Await`;var Wn=y.createContext(null);Wn.displayName=`Navigation`;var Gn=y.createContext(null);Gn.displayName=`Location`;var Kn=y.createContext({outlet:null,matches:[],isDataRoute:!1});Kn.displayName=`Route`;var qn=y.createContext(null);qn.displayName=`RouteError`;var Jn=`REACT_ROUTER_ERROR`,Yn=`REDIRECT`,Xn=`ROUTE_ERROR_RESPONSE`;function Zn(e){if(e.startsWith(`${Jn}:${Yn}:{`))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 Qn(e){if(e.startsWith(`${Jn}:${Xn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new On(t.status,t.statusText,t.data)}catch{}}function $n(e,{relative:t}={}){zt(er(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(Wn),{hash:i,pathname:a,search:o}=ur(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Cn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function er(){return y.useContext(Gn)!=null}function tr(){return zt(er(),`useLocation() may be used only in the context of a component.`),y.useContext(Gn).location}var nr=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function rr(e){y.useContext(Wn).static||y.useLayoutEffect(e)}function ir(){let{isDataRoute:e}=y.useContext(Kn);return e?kr():ar()}function ar(){zt(er(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(Ln),{basename:t,navigator:n}=y.useContext(Wn),{matches:r}=y.useContext(Kn),{pathname:i}=tr(),a=JSON.stringify(bn(r)),o=y.useRef(!1);return rr(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(Bt(o.current,nr),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=xn(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Cn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var or=y.createContext(null);function sr(){return y.useContext(or)}function cr(e){let t=y.useContext(Kn).outlet;return y.useMemo(()=>t&&y.createElement(or.Provider,{value:e},t),[t,e])}function lr(){let{matches:e}=y.useContext(Kn);return e[e.length-1]?.params??{}}function ur(e,{relative:t}={}){let{matches:n}=y.useContext(Kn),{pathname:r}=tr(),i=JSON.stringify(bn(n));return y.useMemo(()=>xn(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function dr(e,t){return fr(e,t)}function fr(e,t,n){zt(er(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(Wn),{matches:i}=y.useContext(Kn),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||``;jr(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=tr(),d;if(t){let e=typeof t==`string`?Wt(t):t;zt(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})):qt(e,{pathname:p});Bt(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),Bt(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=yr(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Cn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Cn([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(Gn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function pr(){let e=Or(),t=kn(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 mr=y.createElement(pr,null),hr=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=Qn(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Kn.Provider,{value:this.props.routeContext},y.createElement(qn.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(_r,{error:e},t):t}};hr.contextType=zn;var gr=new WeakMap;function _r({children:e,error:t}){let{basename:n}=y.useContext(Wn);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=Zn(t.digest);if(e){let r=gr.get(t);if(r)throw r;let i=Mn(e.location,n),a=i.absoluteURL||i.to;if(In(a))throw Error(`Invalid redirect location`);if(jn&&!gr.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 gr.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${a}`})}}return e}function vr({routeContext:e,match:t,children:n}){let r=y.useContext(Ln);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Kn.Provider,{value:e},n)}function yr(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);zt(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:An(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||mr,o&&(s<0&&c===0?(jr(`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(vr,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(hr,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function br(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function xr(e){let t=y.useContext(Ln);return zt(t,br(e)),t}function Sr(e){let t=y.useContext(Rn);return zt(t,br(e)),t}function Cr(e){let t=y.useContext(Kn);return zt(t,br(e)),t}function wr(e){let t=Cr(e),n=t.matches[t.matches.length-1];return zt(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Tr(){return wr(`useRouteId`)}function Er(){let e=Sr(`useNavigation`);return y.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function Dr(){let{matches:e,loaderData:t}=Sr(`useMatches`);return y.useMemo(()=>e.map(e=>Yt(e,t)),[e,t])}function Or(){let e=y.useContext(qn),t=Sr(`useRouteError`),n=wr(`useRouteError`);return e===void 0?t.errors?.[n]:e}function kr(){let{router:e}=xr(`useNavigate`),t=wr(`useNavigate`),n=y.useRef(!1);return rr(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{Bt(n.current,nr),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Ar={};function jr(e,t,n){!t&&!Ar[e]&&(Ar[e]=!0,Bt(!1,n))}y.memo(Mr);function Mr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return fr(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Nr({to:e,replace:t,state:n,relative:r}){zt(er(),` may be used only in the context of a component.`);let{static:i}=y.useContext(Wn);Bt(!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(Kn),{pathname:o}=tr(),s=ir(),c=xn(e,bn(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 Pr(e){return cr(e.context)}function Fr(e){zt(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Ir({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){zt(!er(),`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=Wt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=y.useMemo(()=>{let e=hn(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 Bt(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(Wn.Provider,{value:c},y.createElement(Gn.Provider,{children:t,value:h}))}function Lr({children:e,location:t}){return dr(Rr(e),t)}y.Component;function Rr(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,Rr(e.props.children,i));return}zt(e.type===Fr,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),zt(!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=Rr(e.props.children,i)),n.push(a)}),n}var zr=`get`,Br=`application/x-www-form-urlencoded`;function Vr(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Hr(e){return Vr(e)&&e.tagName.toLowerCase()===`button`}function Ur(e){return Vr(e)&&e.tagName.toLowerCase()===`form`}function Wr(e){return Vr(e)&&e.tagName.toLowerCase()===`input`}function Gr(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Kr(e,t){return e.button===0&&(!t||t===`_self`)&&!Gr(e)}function qr(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 Jr(e,t){let n=qr(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Yr=null;function Xr(){if(Yr===null)try{new FormData(document.createElement(`form`),0),Yr=!1}catch{Yr=!0}return Yr}var Zr=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function Qr(e){return e!=null&&!Zr.has(e)?(Bt(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Br}"`),null):e}function $r(e,t){let n,r,i,a,o;if(Ur(e)){let o=e.getAttribute(`action`);r=o?hn(o,t):null,n=e.getAttribute(`method`)||zr,i=Qr(e.getAttribute(`enctype`))||Br,a=new FormData(e)}else if(Hr(e)||Wr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a