mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Security hardening (maziggy/bambuddy-security #18)
This commit is contained in:
@@ -1495,7 +1495,7 @@ async def _sum_live_plug_totals(db: AsyncSession) -> float:
|
||||
available so this can't be date-filtered — use `_sum_snapshot_deltas` for
|
||||
that case.
|
||||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.api.routes.settings import get_homeassistant_settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
@@ -1505,9 +1505,8 @@ async def _sum_live_plug_totals(db: AsyncSession) -> float:
|
||||
plugs_result = await db.execute(select(SmartPlug))
|
||||
plugs = list(plugs_result.scalars().all())
|
||||
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
homeassistant_service.configure(ha_url, ha_token)
|
||||
ha_settings = await get_homeassistant_settings(db)
|
||||
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
|
||||
|
||||
total = 0.0
|
||||
for plug in plugs:
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import desc, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.printer import Printer
|
||||
@@ -21,6 +22,7 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.secret_fields import SECRET_PLACEHOLDER, is_secret_setting_key, mask_preset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +45,8 @@ def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -
|
||||
|
||||
``user_id`` from the listing is deliberately dropped. It identifies the
|
||||
account and adds nothing to a rebuild, and backup repositories can be
|
||||
public.
|
||||
public. A printer connection's credentials in ``setting`` are shown as
|
||||
the placeholder.
|
||||
"""
|
||||
return {
|
||||
"setting_id": str(setting_id),
|
||||
@@ -53,7 +56,7 @@ def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -
|
||||
"updated_time": entry.get("updated_time"),
|
||||
"base_id": detail.get("base_id"),
|
||||
"filament_id": detail.get("filament_id"),
|
||||
"setting": detail.get("setting") or {},
|
||||
"setting": mask_preset(detail.get("setting") or {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -62,14 +65,15 @@ def _orca_profile_record(entry: dict) -> dict:
|
||||
|
||||
``content`` is kept whole rather than picked apart: it is the profile, the
|
||||
sync API hands it over inline, and Orca owns its shape. Narrowing it here
|
||||
would mean guessing which keys a future restore needs.
|
||||
would mean guessing which keys a future restore needs. A printer
|
||||
connection's credentials are the exception: shown as the placeholder.
|
||||
"""
|
||||
return {
|
||||
"id": str(entry.get("id")) if entry.get("id") is not None else None,
|
||||
"name": entry.get("name"),
|
||||
"updated_time": entry.get("updated_time"),
|
||||
"created_time": entry.get("created_time"),
|
||||
"content": entry.get("content"),
|
||||
"content": mask_preset(entry.get("content")),
|
||||
}
|
||||
|
||||
|
||||
@@ -742,9 +746,16 @@ class GitHubBackupService:
|
||||
result = await db.execute(select(Settings))
|
||||
settings = result.scalars().all()
|
||||
|
||||
# Filter out sensitive settings
|
||||
sensitive_keys = {"bambu_cloud_token", "auth_secret_key", "manyfold_client_secret"}
|
||||
settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
|
||||
# A credential is written as the placeholder when set (blank counts as
|
||||
# unset, as it does for the restore), so the restore can still tell
|
||||
# whether a switch had one. A URL with a login in it is
|
||||
# left out: masked, it would be restored over a working one.
|
||||
settings_data = {}
|
||||
for s in settings:
|
||||
if is_secret_setting_key(s.key):
|
||||
settings_data[s.key] = SECRET_PLACEHOLDER if (s.value or "").strip() else s.value
|
||||
elif not URL_CREDENTIALS_PATTERN.search(s.value or ""):
|
||||
settings_data[s.key] = s.value
|
||||
|
||||
files["settings/app_settings.json"] = {
|
||||
"version": "1.0",
|
||||
|
||||
@@ -54,8 +54,10 @@ from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.github_backup import RestoreCategory
|
||||
from backend.app.schemas.settings import AppSettings
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.secret_fields import SETTINGS_SECRETS, SMTP_DESTINATIONS, is_secret_setting_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,19 +70,13 @@ ARCHIVES_PATH = "archives/print_history.json"
|
||||
# kprofiles/{printer_serial}/{nozzle_diameter}.json
|
||||
_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
|
||||
|
||||
# Settings keys the backup collector already refuses to write. Applied again on
|
||||
# the read side because a backup taken before that denylist existed can still
|
||||
# contain them, and a restore must not resurrect a stale credential.
|
||||
_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
|
||||
|
||||
# The primary refusal, not a backstop for the set above. The collector filters
|
||||
# exactly bambu_cloud_token and auth_secret_key, so every other credential —
|
||||
# mqtt_password, ldap_bind_password, ha_token, prometheus_token — is present in
|
||||
# a current backup and is skipped only because its key matches a hint here.
|
||||
# _COMPANION_CREDENTIALS sits downstream of that: it withholds a toggle when the
|
||||
# credential it needs was refused, so shortening this tuple would both write a
|
||||
# stale credential and quietly make that rule inert.
|
||||
_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
|
||||
# Settings keys holding a credential are refused by name (is_secret_setting_key,
|
||||
# shared with the backup collector). A current backup carries them only as the
|
||||
# placeholder; one taken before that can still hold the values, and a restore
|
||||
# must not resurrect a stale credential. _COMPANION_CREDENTIALS sits downstream
|
||||
# of that: it withholds a toggle when the credential it needs was refused, so
|
||||
# narrowing the predicate would both write a stale credential and quietly make
|
||||
# that rule inert.
|
||||
|
||||
# Settings the MQTT relay reads only when it is (re)configured, so restoring the
|
||||
# rows is not enough on its own. Mirrors the set the settings PUT handler
|
||||
@@ -191,8 +187,7 @@ def _created_at_matches(row, created_at: datetime | None) -> bool:
|
||||
|
||||
|
||||
def _is_blocked_setting_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
|
||||
return is_secret_setting_key(key)
|
||||
|
||||
|
||||
def _is_protected_setting_key(key: str) -> bool:
|
||||
@@ -267,6 +262,17 @@ _COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
|
||||
# skips this condition and is judged on local state alone.
|
||||
_COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
|
||||
|
||||
# Setting -> the credentials it decides the destination of. smtp_use_tls
|
||||
# decides the SMTP security mode when smtp_security is not set.
|
||||
_DESTINATION_SECRETS: dict[str, tuple[str, ...]] = {}
|
||||
for _secret, _fields in {
|
||||
**SETTINGS_SECRETS,
|
||||
"smtp_password": (*SMTP_DESTINATIONS, "smtp_use_tls"),
|
||||
"manyfold_client_secret": ("manyfold_url",),
|
||||
}.items():
|
||||
for _field in _fields:
|
||||
_DESTINATION_SECRETS[_field] = (*_DESTINATION_SECRETS.get(_field, ()), _secret)
|
||||
|
||||
|
||||
def _setting_value_is_true(value: object) -> bool:
|
||||
"""True if a settings *payload* value would be stored as "on".
|
||||
@@ -283,6 +289,15 @@ def _setting_value_is_true(value: object) -> bool:
|
||||
return str(value).strip().lower() == "true"
|
||||
|
||||
|
||||
def _as_setting_text(value: object) -> str:
|
||||
"""A setting as its row stores it, so a backup value compares with a local one."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool) or str(value).lower() in ("true", "false"):
|
||||
return str(value).lower()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_usable_credential(value: object) -> bool:
|
||||
"""True if a credential value is present and not blank.
|
||||
|
||||
@@ -302,20 +317,27 @@ class _SettingsPlan:
|
||||
change. The companion bucket is why this needs a session at all: unlike the
|
||||
two name-based buckets it depends on local database state.
|
||||
|
||||
The three buckets are disjoint — a key is classified once, in order.
|
||||
The buckets are disjoint — a key is classified once, in order. The
|
||||
destination bucket depends on local state as well.
|
||||
"""
|
||||
|
||||
blocked: tuple[str, ...] = ()
|
||||
protected: tuple[str, ...] = ()
|
||||
companion: tuple[str, ...] = ()
|
||||
destination: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def refused(self) -> frozenset[str]:
|
||||
return frozenset(self.blocked) | frozenset(self.protected) | frozenset(self.companion)
|
||||
return (
|
||||
frozenset(self.blocked)
|
||||
| frozenset(self.protected)
|
||||
| frozenset(self.companion)
|
||||
| frozenset(self.destination)
|
||||
)
|
||||
|
||||
@property
|
||||
def refused_count(self) -> int:
|
||||
return len(self.blocked) + len(self.protected) + len(self.companion)
|
||||
return len(self.blocked) + len(self.protected) + len(self.companion) + len(self.destination)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -506,7 +528,7 @@ class GitHubRestoreService:
|
||||
if not _setting_value_is_true(value):
|
||||
continue
|
||||
# Expressed as the predicate rather than assumed, so the map cannot
|
||||
# go quietly inert if _SECRET_KEY_HINTS is ever edited: a credential
|
||||
# go quietly inert if the secret hints are ever edited: a credential
|
||||
# the restore is willing to write travels with its toggle.
|
||||
if not _is_blocked_setting_key(credential):
|
||||
continue
|
||||
@@ -521,11 +543,20 @@ class GitHubRestoreService:
|
||||
continue
|
||||
candidates[key] = credential
|
||||
|
||||
if not candidates:
|
||||
# Settings that decide where a credential is sent, which the payload
|
||||
# sets. Judged against local state below.
|
||||
destinations = {
|
||||
key: secrets
|
||||
for key, value in values.items()
|
||||
if value is not None and (secrets := _DESTINATION_SECRETS.get(key)) and key not in protected
|
||||
}
|
||||
|
||||
if not candidates and not destinations:
|
||||
return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
|
||||
|
||||
# One SELECT covering both halves of every candidate pair.
|
||||
wanted = set(candidates) | set(candidates.values())
|
||||
wanted = set(candidates) | set(candidates.values()) | set(destinations)
|
||||
wanted |= {secret for secrets in destinations.values() for secret in secrets}
|
||||
rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
|
||||
local = {row.key: row.value for row in rows.scalars().all()}
|
||||
|
||||
@@ -543,10 +574,40 @@ class GitHubRestoreService:
|
||||
continue
|
||||
companion.append(toggle)
|
||||
|
||||
# A credential kept here does not follow a destination the backup
|
||||
# changes, as the settings editor refuses the same change without the
|
||||
# credential entered again. A token from the environment is the
|
||||
# deployment's own and may follow, as it may there.
|
||||
destination: list[str] = []
|
||||
for key, secrets in destinations.items():
|
||||
kept = [
|
||||
secret
|
||||
for secret in secrets
|
||||
if _is_usable_credential(local.get(secret))
|
||||
and not (
|
||||
(env_name := _COMPANION_CREDENTIAL_ENV.get(secret))
|
||||
and _is_usable_credential(os.environ.get(env_name))
|
||||
)
|
||||
]
|
||||
if not kept:
|
||||
continue
|
||||
if key in local:
|
||||
current = local[key]
|
||||
elif key in AppSettings.model_fields:
|
||||
current = AppSettings.model_fields[key].get_default(call_default_factory=True)
|
||||
else:
|
||||
# No row and no schema default: nothing to compare with, so
|
||||
# counted as a change.
|
||||
destination.append(key)
|
||||
continue
|
||||
if _as_setting_text(values[key]) != _as_setting_text(current):
|
||||
destination.append(key)
|
||||
|
||||
return _SettingsPlan(
|
||||
blocked=tuple(blocked),
|
||||
protected=tuple(protected),
|
||||
companion=tuple(companion),
|
||||
destination=tuple(destination),
|
||||
)
|
||||
|
||||
async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
|
||||
@@ -658,7 +719,14 @@ class GitHubRestoreService:
|
||||
# policy keys stay unmentioned on purpose.
|
||||
plan = await self._plan_settings(db, values)
|
||||
detail = None
|
||||
if plan.companion and not plan.blocked:
|
||||
if plan.destination:
|
||||
detail = _Detail(
|
||||
"settingsDestinationWillKeep",
|
||||
f"{len(plan.destination)} setting(s) will be left as they are - they would send a credential "
|
||||
"stored here somewhere else; credentials are never restored",
|
||||
{"destination": len(plan.destination)},
|
||||
)
|
||||
elif plan.companion and not plan.blocked:
|
||||
# An exposure toggle becomes a candidate whether or not the
|
||||
# backup carried its credential, so this commit can refuse a
|
||||
# switch without having a single credential-like key to skip —
|
||||
@@ -1637,6 +1705,15 @@ class GitHubRestoreService:
|
||||
keys=keys,
|
||||
count=len(plan.companion),
|
||||
)
|
||||
if plan.destination:
|
||||
keys = ", ".join(sorted(plan.destination))
|
||||
tally.note(
|
||||
"settingsDestinationKept",
|
||||
f"{keys} left as they are - this instance has a stored credential that would otherwise be sent "
|
||||
"to the address in the backup. Change them in Settings and enter the credential again.",
|
||||
keys=keys,
|
||||
count=len(plan.destination),
|
||||
)
|
||||
|
||||
async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
|
||||
"""Push restored mqtt_* settings into the live relay.
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.app.core.logging_filters import redact_url_credentials
|
||||
|
||||
SECRET_PLACEHOLDER = "********"
|
||||
|
||||
# The config fields that hold a credential, per provider type. For Discord and
|
||||
@@ -123,8 +125,40 @@ SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
|
||||
"ldap_bind_password": ("ldap_server_url", "ldap_security"),
|
||||
}
|
||||
|
||||
# Settings rows that hold a credential, by name: a Git backup writes them as
|
||||
# the placeholder and a restore never writes them. By hint rather than by list,
|
||||
# so a credential setting added later is covered by default.
|
||||
SECRET_SETTING_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase", "verifier")
|
||||
|
||||
|
||||
def is_secret_setting_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return any(hint in lowered for hint in SECRET_SETTING_KEY_HINTS)
|
||||
|
||||
|
||||
REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
|
||||
|
||||
# Slicer preset keys holding a printer connection's credentials (for a LAN
|
||||
# printer, its access code). Slicer cloud sync carries them.
|
||||
PRESET_SECRET_KEYS = ("printhost_apikey", "printhost_password", "printhost_user")
|
||||
|
||||
|
||||
def _mask_url_logins(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return redact_url_credentials(value)
|
||||
if isinstance(value, list):
|
||||
return [_mask_url_logins(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def mask_preset(content: Any) -> Any:
|
||||
"""A slicer preset with its connection credentials shown as the placeholder
|
||||
and the login in any of its URLs masked."""
|
||||
if not isinstance(content, dict):
|
||||
return content
|
||||
return {key: _mask_url_logins(value) for key, value in mask_fields(content, PRESET_SECRET_KEYS).items()}
|
||||
|
||||
|
||||
# Smart plug secrets and the fields that decide where each is sent.
|
||||
SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
|
||||
"password": ("plug_type", "ip_address"),
|
||||
|
||||
@@ -238,3 +238,22 @@ class TestSumLivePlugTotalsRest:
|
||||
total = await _sum_live_plug_totals(db_session)
|
||||
|
||||
assert total == pytest.approx(0.4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_totals_use_the_home_assistant_token_from_the_environment(db_session, monkeypatch):
|
||||
"""The environment's token wins over a stored one, as everywhere else."""
|
||||
from backend.app.api.routes.archives import _sum_live_plug_totals
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
|
||||
monkeypatch.setenv("HA_TOKEN", "env-value")
|
||||
monkeypatch.delenv("HA_URL", raising=False)
|
||||
db_session.add_all([Settings(key="ha_url", value="http://ha.local"), Settings(key="ha_token", value="row-value")])
|
||||
await db_session.commit()
|
||||
configured = []
|
||||
monkeypatch.setattr(homeassistant_service, "configure", lambda url, token: configured.append((url, token)))
|
||||
|
||||
await _sum_live_plug_totals(db_session)
|
||||
|
||||
assert configured == [("http://ha.local", "env-value")]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""A printer preset's connection secrets never leave in a Git backup.
|
||||
|
||||
A printer connected in the slicer keeps its host's key (for a LAN printer, its
|
||||
access code) in the preset, and cloud sync carries it. The backup writes those
|
||||
as the placeholder; cloud profiles are never restored, so nothing reads them
|
||||
back.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.github_backup import GitHubBackupService
|
||||
from backend.app.services.secret_fields import SECRET_PLACEHOLDER
|
||||
|
||||
CONNECTION = {
|
||||
"print_host": "192.168.1.50",
|
||||
"printhost_apikey": "value-one",
|
||||
"printhost_password": "value-two",
|
||||
"printhost_user": "value-three",
|
||||
"printer_model": "X1C",
|
||||
}
|
||||
MASKED = {
|
||||
"print_host": "192.168.1.50",
|
||||
"printhost_apikey": SECRET_PLACEHOLDER,
|
||||
"printhost_password": SECRET_PLACEHOLDER,
|
||||
"printhost_user": SECRET_PLACEHOLDER,
|
||||
"printer_model": "X1C",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_orca_printer_preset_keeps_its_connection_secrets_out(db_session):
|
||||
svc = MagicMock()
|
||||
svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": {"type": "machine", **CONNECTION}}])
|
||||
svc.close = AsyncMock()
|
||||
files: dict = {}
|
||||
with patch(
|
||||
"backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
|
||||
):
|
||||
await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
|
||||
|
||||
(path,) = files
|
||||
assert files[path]["profiles"][0]["content"] == {"type": "machine", **MASKED}
|
||||
assert "value-" not in json.dumps(files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bambu_printer_preset_keeps_its_connection_secrets_out(db_session):
|
||||
cloud = MagicMock()
|
||||
cloud.is_authenticated = True
|
||||
cloud.get_slicer_settings = AsyncMock(
|
||||
return_value={"printer": {"private": [{"setting_id": "PMUS1", "name": "p", "version": "1"}], "public": []}}
|
||||
)
|
||||
cloud.get_setting_detail = AsyncMock(
|
||||
return_value={"setting_id": "PMUS1", "name": "p", "base_id": "GM001", "setting": dict(CONNECTION)}
|
||||
)
|
||||
cloud.close = AsyncMock()
|
||||
files: dict = {}
|
||||
with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new_callable=AsyncMock, return_value=cloud):
|
||||
await GitHubBackupService()._collect_bambu_profiles(db_session, files, "global", None)
|
||||
|
||||
assert files["cloud_profiles/bambu/global/printer.json"]["profiles"][0]["setting"] == MASKED
|
||||
assert "value-" not in json.dumps(files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_connection_secret_stays_empty(db_session):
|
||||
svc = MagicMock()
|
||||
svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": {"printhost_apikey": ""}}])
|
||||
svc.close = AsyncMock()
|
||||
files: dict = {}
|
||||
with patch(
|
||||
"backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
|
||||
):
|
||||
await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
|
||||
|
||||
(path,) = files
|
||||
assert files[path]["profiles"][0]["content"] == {"printhost_apikey": ""}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_login_in_a_preset_url_is_masked(db_session):
|
||||
content = {
|
||||
"print_host": "http://pi:value-four@octopi.local",
|
||||
"print_host_webui": "https://value-five@octopi.local/ui",
|
||||
"printer_settings_id": "http://octopi.local",
|
||||
"post_process": ["http://u:value-six@octopi.local/hook", "plain"],
|
||||
}
|
||||
cloud = MagicMock()
|
||||
cloud.is_authenticated = True
|
||||
cloud.get_slicer_settings = AsyncMock(
|
||||
return_value={"printer": {"private": [{"setting_id": "PMUS1", "name": "p", "version": "1"}], "public": []}}
|
||||
)
|
||||
cloud.get_setting_detail = AsyncMock(
|
||||
return_value={"setting_id": "PMUS1", "name": "p", "base_id": "GM001", "setting": dict(content)}
|
||||
)
|
||||
cloud.close = AsyncMock()
|
||||
svc = MagicMock()
|
||||
svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": dict(content)}])
|
||||
svc.close = AsyncMock()
|
||||
files: dict = {}
|
||||
with (
|
||||
patch("backend.app.api.routes.cloud.build_authenticated_cloud", new_callable=AsyncMock, return_value=cloud),
|
||||
patch(
|
||||
"backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
|
||||
),
|
||||
):
|
||||
await GitHubBackupService()._collect_bambu_profiles(db_session, files, "global", None)
|
||||
await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
|
||||
|
||||
masked = {
|
||||
"print_host": "http://pi:[REDACTED]@octopi.local",
|
||||
"print_host_webui": "https://[REDACTED]@octopi.local/ui",
|
||||
"printer_settings_id": "http://octopi.local",
|
||||
"post_process": ["http://u:[REDACTED]@octopi.local/hook", "plain"],
|
||||
}
|
||||
bambu = files["cloud_profiles/bambu/global/printer.json"]["profiles"][0]["setting"]
|
||||
(orca_path,) = [path for path in files if path.startswith("cloud_profiles/orca/")]
|
||||
assert bambu == masked
|
||||
assert files[orca_path]["profiles"][0]["content"] == masked
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Settings secrets never leave the instance in a Git backup.
|
||||
|
||||
A restore refuses every credential-shaped key, so the backup has no use for
|
||||
their values. It still records whether each one was set (the placeholder) so
|
||||
the restore can tell a switch whose credential stayed behind from one that
|
||||
never had one. A URL setting with a login in it is left out: a masked URL
|
||||
restored over a working one would break it.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.github_backup import GitHubBackupService
|
||||
from backend.app.services.github_restore import GitHubRestoreService, _is_blocked_setting_key
|
||||
from backend.app.services.secret_fields import SECRET_PLACEHOLDER
|
||||
|
||||
SECRETS = {
|
||||
"mqtt_password": "value-one",
|
||||
"ha_token": "value-two",
|
||||
"prometheus_token": "value-three",
|
||||
"obico_ml_token": "value-four",
|
||||
"ldap_bind_password": "value-five",
|
||||
"smtp_password": "value-six",
|
||||
"virtual_printer_access_code": "value-seven",
|
||||
"bambu_cloud_token": "value-eight",
|
||||
"auth_secret_key": "value-nine",
|
||||
"manyfold_client_secret": "value-ten",
|
||||
"MQTT_SECRET": "value-eleven",
|
||||
"orca_cloud_pending_verifier": "value-twelve",
|
||||
}
|
||||
|
||||
|
||||
async def _collect(db_session, rows: dict[str, str]) -> dict:
|
||||
db_session.add_all(Settings(key=key, value=value) for key, value in rows.items())
|
||||
await db_session.commit()
|
||||
files: dict = {}
|
||||
await GitHubBackupService()._collect_settings(db_session, files)
|
||||
return files["settings/app_settings.json"]["settings"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_set_secret_is_written_as_the_placeholder(db_session):
|
||||
collected = await _collect(db_session, {**SECRETS, "currency": "EUR"})
|
||||
|
||||
assert {key: collected.get(key) for key in SECRETS} == dict.fromkeys(SECRETS, SECRET_PLACEHOLDER)
|
||||
assert collected["currency"] == "EUR"
|
||||
dumped = json.dumps(collected)
|
||||
assert not [value for value in SECRETS.values() if value in dumped]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_secret_stays_empty(db_session):
|
||||
"""Blank counts as empty, as it does for the restore."""
|
||||
collected = await _collect(db_session, {"prometheus_token": "", "mqtt_password": " "})
|
||||
|
||||
assert collected == {"prometheus_token": "", "mqtt_password": " "}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_backed_up_secret_is_refused_on_restore(db_session):
|
||||
plan = await GitHubRestoreService()._plan_settings(db_session, dict(SECRETS))
|
||||
|
||||
assert sorted(plan.blocked) == sorted(SECRETS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_backup_hides_exactly_what_the_restore_refuses(db_session):
|
||||
rows = {**SECRETS, "currency": "EUR", "mqtt_enabled": "true", "theme_api": "x", "ldap_server_url": "ldap://h"}
|
||||
collected = await _collect(db_session, rows)
|
||||
|
||||
for key in rows:
|
||||
assert (collected[key] == SECRET_PLACEHOLDER) is _is_blocked_setting_key(key), key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://user:value-url@spoolman.local:7912/",
|
||||
"https://value-url@spoolman.local",
|
||||
"http://:value-url@spoolman.local",
|
||||
"http://user:value-url@spoolman.local?x=1",
|
||||
],
|
||||
)
|
||||
async def test_a_url_setting_with_a_login_is_left_out(db_session, url):
|
||||
collected = await _collect(db_session, {"spoolman_url": url, "ha_url": "http://ha.local:8123"})
|
||||
|
||||
assert "spoolman_url" not in collected
|
||||
assert collected["ha_url"] == "http://ha.local:8123"
|
||||
assert "value-url" not in json.dumps(collected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_switch_whose_credential_stayed_behind_is_still_left_off_on_restore(db_session):
|
||||
"""Restored onto an instance without the password, MQTT must not come on."""
|
||||
collected = await _collect(db_session, {"mqtt_enabled": "true", "mqtt_password": "value-one"})
|
||||
await db_session.execute(delete(Settings))
|
||||
await db_session.commit()
|
||||
|
||||
plan = await GitHubRestoreService()._plan_settings(db_session, collected)
|
||||
|
||||
assert plan.companion == ("mqtt_enabled",)
|
||||
assert "mqtt_password" in plan.blocked
|
||||
@@ -0,0 +1,172 @@
|
||||
"""A restore never sends a credential kept here to a place the backup names.
|
||||
|
||||
Credentials are never restored, so the local one stays. The settings that say
|
||||
where it is sent (broker, port, TLS, URL) are left as they are when the backup
|
||||
would change them, as the settings editor refuses the same change without the
|
||||
credential being entered again.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.schemas.github_backup import RestoreCategory
|
||||
from backend.app.services.github_restore import SETTINGS_PATH, GitHubRestoreService, _CategoryTally
|
||||
|
||||
|
||||
async def _local(db_session, rows: dict[str, str]) -> None:
|
||||
db_session.add_all(Settings(key=key, value=value) for key, value in rows.items())
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _restore(db_session, values: dict, overwrite: bool = True) -> tuple[_CategoryTally, set[str]]:
|
||||
tally, written = _CategoryTally(), set()
|
||||
await GitHubRestoreService()._restore_settings(
|
||||
db_session, {"settings": values}, overwrite=overwrite, tally=tally, keys_written=written
|
||||
)
|
||||
await db_session.commit()
|
||||
return tally, written
|
||||
|
||||
|
||||
async def _value(db_session, key: str) -> str | None:
|
||||
row = (await db_session.execute(select(Settings).where(Settings.key == key))).scalar_one_or_none()
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_changed_broker_is_left_as_it_is_while_the_password_is_kept(db_session):
|
||||
await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local"})
|
||||
|
||||
tally, written = await _restore(
|
||||
db_session, {"mqtt_broker": "elsewhere.example", "mqtt_password": "********", "currency": "EUR"}
|
||||
)
|
||||
|
||||
assert await _value(db_session, "mqtt_broker") == "here.local"
|
||||
assert await _value(db_session, "mqtt_password") == "local-value"
|
||||
assert written == {"currency"}
|
||||
kept = [note for note in tally.notes if note["code"] == "settingsDestinationKept"]
|
||||
assert kept and kept[0]["params"]["keys"] == "mqtt_broker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_stored_credential_the_destination_is_restored(db_session):
|
||||
await _local(db_session, {"mqtt_broker": "here.local", "mqtt_password": " "})
|
||||
|
||||
_, written = await _restore(db_session, {"mqtt_broker": "elsewhere.example"})
|
||||
|
||||
assert await _value(db_session, "mqtt_broker") == "elsewhere.example"
|
||||
assert written == {"mqtt_broker"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unchanged_destination_is_restored_as_usual(db_session):
|
||||
await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local", "mqtt_use_tls": "false"})
|
||||
|
||||
tally, written = await _restore(
|
||||
db_session, {"mqtt_broker": "here.local", "mqtt_port": "1883", "mqtt_use_tls": False}
|
||||
)
|
||||
|
||||
assert written == {"mqtt_broker", "mqtt_port", "mqtt_use_tls"}
|
||||
assert "settingsDestinationKept" not in [note["code"] for note in tally.notes]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[("mqtt_port", "8883"), ("mqtt_use_tls", "true"), ("mqtt_use_tls", True)],
|
||||
)
|
||||
async def test_a_destination_never_saved_here_counts_from_its_default(db_session, key, value):
|
||||
await _local(db_session, {"mqtt_password": "local-value"})
|
||||
|
||||
_, written = await _restore(db_session, {key: value})
|
||||
|
||||
assert await _value(db_session, key) is None
|
||||
assert written == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("secret", "destination"),
|
||||
[("ha_token", "ha_url"), ("obico_ml_token", "obico_ml_url")],
|
||||
)
|
||||
async def test_a_token_does_not_follow_a_changed_url(db_session, monkeypatch, secret, destination):
|
||||
monkeypatch.delenv("HA_TOKEN", raising=False)
|
||||
await _local(db_session, {secret: "local-value", destination: "http://here.local"})
|
||||
|
||||
_, written = await _restore(db_session, {destination: "http://elsewhere.example"})
|
||||
|
||||
assert await _value(db_session, destination) == "http://here.local"
|
||||
assert written == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_deployment_token_may_follow_a_changed_url(db_session, monkeypatch):
|
||||
"""Set in the environment, the token is the deployment's own: as in the editor."""
|
||||
monkeypatch.setenv("HA_TOKEN", "env-value")
|
||||
await _local(db_session, {"ha_token": "local-value", "ha_url": "http://here.local"})
|
||||
|
||||
await _restore(db_session, {"ha_url": "http://elsewhere.example"})
|
||||
|
||||
assert await _value(db_session, "ha_url") == "http://elsewhere.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_preview_counts_and_names_what_is_left_as_it_is(db_session):
|
||||
await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local"})
|
||||
parsed = {SETTINGS_PATH: {"settings": {"mqtt_broker": "elsewhere.example", "currency": "EUR"}}}
|
||||
|
||||
count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
|
||||
|
||||
assert count == 1
|
||||
assert detail is not None and detail.code == "settingsDestinationWillKeep"
|
||||
assert detail.params == {"destination": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("key", "local", "restored"),
|
||||
[
|
||||
("smtp_host", "mail.here.local", "mail.elsewhere.example"),
|
||||
("smtp_port", "587", "25"),
|
||||
("smtp_security", "starttls", "none"),
|
||||
("smtp_use_tls", "true", "false"),
|
||||
],
|
||||
)
|
||||
async def test_the_smtp_password_does_not_follow_a_changed_server(db_session, key, local, restored):
|
||||
await _local(db_session, {"smtp_password": "local-value", key: local})
|
||||
|
||||
_, written = await _restore(db_session, {key: restored})
|
||||
|
||||
assert await _value(db_session, key) == local
|
||||
assert written == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_destination_without_a_row_here_is_left_alone_while_a_credential_is_kept(db_session):
|
||||
"""Without a row there is no value to compare with: counted as a change."""
|
||||
await _local(db_session, {"smtp_password": "local-value", "smtp_host": "mail.here.local"})
|
||||
|
||||
_, written = await _restore(db_session, {"smtp_security": "none"})
|
||||
|
||||
assert await _value(db_session, "smtp_security") is None
|
||||
assert written == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_manyfold_secret_does_not_follow_a_changed_url(db_session):
|
||||
await _local(db_session, {"manyfold_client_secret": "local-value", "manyfold_url": "http://here.local"})
|
||||
|
||||
_, written = await _restore(db_session, {"manyfold_url": "http://elsewhere.example", "manyfold_client_id": "id"})
|
||||
|
||||
assert await _value(db_session, "manyfold_url") == "http://here.local"
|
||||
assert written == {"manyfold_client_id"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_stored_smtp_password_the_server_is_restored(db_session):
|
||||
await _local(db_session, {"smtp_host": "mail.here.local"})
|
||||
|
||||
_, written = await _restore(db_session, {"smtp_host": "mail.elsewhere.example", "smtp_security": "ssl"})
|
||||
|
||||
assert await _value(db_session, "smtp_host") == "mail.elsewhere.example"
|
||||
assert written == {"smtp_host", "smtp_security"}
|
||||
@@ -5675,6 +5675,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
|
||||
settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
|
||||
settingsDestinationWillKeep: '{{destination}} Einstellung(en) bleiben unverändert - sie würden hier gespeicherte Zugangsdaten an ein anderes Ziel senden; Zugangsdaten werden nie wiederhergestellt',
|
||||
spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
|
||||
archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
|
||||
kprofilesPrinterCount: 'über {{count}} Drucker',
|
||||
@@ -5694,6 +5695,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben',
|
||||
settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen',
|
||||
settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen',
|
||||
settingsDestinationKept: '{{keys}} unverändert gelassen - diese Instanz hat gespeicherte Zugangsdaten, die sonst an die Adresse aus dem Backup gesendet würden. Ändere sie in den Einstellungen und gib die Zugangsdaten erneut ein.',
|
||||
settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten',
|
||||
kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker',
|
||||
kprofilesAckUnreliable: 'Ein Drucker, der nicht antwortet, zählt weiterhin als wiederhergestellt - überprüfen Sie die Profile am Drucker',
|
||||
|
||||
@@ -5724,6 +5724,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
|
||||
settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
|
||||
settingsDestinationWillKeep: '{{destination}} setting(s) will be left as they are - they would send a credential stored here somewhere else; credentials are never restored',
|
||||
spoolsUsageCount: 'including {{count}} usage record(s)',
|
||||
archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
|
||||
kprofilesPrinterCount: 'across {{count}} printer(s)',
|
||||
@@ -5746,6 +5747,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually',
|
||||
settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run',
|
||||
settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated',
|
||||
settingsDestinationKept: '{{keys}} left as they are - this instance has a stored credential that would otherwise be sent to the address in the backup. Change them in Settings and enter the credential again.',
|
||||
settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer',
|
||||
kprofilesAckUnreliable: 'A printer that does not answer still counts as restored - verify the profiles on the printer',
|
||||
|
||||
@@ -5682,6 +5682,7 @@ export default {
|
||||
settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
|
||||
settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
|
||||
settingsDestinationWillKeep: '{{destination}} ajuste(s) se dejarán como están - enviarían una credencial guardada aquí a otro destino; las credenciales nunca se restauran',
|
||||
spoolsUsageCount: 'incluidos {{count}} registros de consumo',
|
||||
archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
|
||||
kprofilesPrinterCount: 'en {{count}} impresoras',
|
||||
@@ -5701,6 +5702,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente',
|
||||
settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo',
|
||||
settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación',
|
||||
settingsDestinationKept: '{{keys}} se han dejado como están - esta instancia tiene una credencial guardada que, de lo contrario, se enviaría a la dirección de la copia de seguridad. Cámbialos en Ajustes e introduce la credencial de nuevo.',
|
||||
settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora',
|
||||
kprofilesAckUnreliable: 'Una impresora que no responde sigue contando como restaurada - verifica los perfiles en la impresora',
|
||||
|
||||
@@ -5662,6 +5662,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
|
||||
settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
|
||||
settingsDestinationWillKeep: '{{destination}} paramètre(s) resteront inchangés - ils enverraient un identifiant enregistré ici vers une autre destination ; les identifiants ne sont jamais restaurés',
|
||||
spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
|
||||
archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
|
||||
kprofilesPrinterCount: 'sur {{count}} imprimantes',
|
||||
@@ -5681,6 +5682,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement',
|
||||
settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent',
|
||||
settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification',
|
||||
settingsDestinationKept: '{{keys}} laissés inchangés - cette instance possède un identifiant enregistré qui serait sinon envoyé à l\'adresse de la sauvegarde. Modifiez-les dans les Paramètres et saisissez à nouveau l\'identifiant.',
|
||||
settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante',
|
||||
kprofilesAckUnreliable: 'Une imprimante qui ne répond pas compte quand même comme restaurée - vérifiez les profils sur l\'imprimante',
|
||||
|
||||
@@ -5661,6 +5661,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
|
||||
settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
|
||||
settingsDestinationWillKeep: "{{destination}} impostazione/i resteranno invariate - invierebbero una credenziale salvata qui a un'altra destinazione; le credenziali non vengono mai ripristinate",
|
||||
spoolsUsageCount: 'inclusi {{count}} record di consumo',
|
||||
archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
|
||||
kprofilesPrinterCount: 'su {{count}} stampanti',
|
||||
@@ -5680,6 +5681,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente',
|
||||
settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi',
|
||||
settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione',
|
||||
settingsDestinationKept: "{{keys}} lasciati invariati - questa istanza ha una credenziale salvata che altrimenti verrebbe inviata all'indirizzo del backup. Modificali nelle Impostazioni e inserisci di nuovo la credenziale.",
|
||||
settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante',
|
||||
kprofilesAckUnreliable: 'Una stampante che non risponde conta comunque come ripristinata - verifica i profili sulla stampante',
|
||||
|
||||
@@ -5675,6 +5675,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
|
||||
settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
|
||||
settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
|
||||
settingsDestinationWillKeep: '{{destination}} 件の設定はそのままになります - ここに保存された認証情報を別の宛先に送ってしまうためです。認証情報は復元されません',
|
||||
spoolsUsageCount: '使用履歴 {{count}} 件を含む',
|
||||
archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
|
||||
kprofilesPrinterCount: 'プリンター {{count}} 台分',
|
||||
@@ -5694,6 +5695,7 @@ export default {
|
||||
settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください',
|
||||
settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
|
||||
settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
|
||||
settingsDestinationKept: '{{keys}} はそのままにしました - このインスタンスには保存済みの認証情報があり、変更するとバックアップのアドレスに送られてしまいます。設定で変更し、認証情報をもう一度入力してください。',
|
||||
settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
|
||||
kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
|
||||
kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',
|
||||
|
||||
@@ -5437,6 +5437,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
|
||||
settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
|
||||
settingsCompanionOnlyWillSkip: '스위치 {{companion}}개는 꺼진 상태로 둡니다 - 각각에 필요한 자격 증명은 백업에서 복원할 수 없습니다',
|
||||
settingsDestinationWillKeep: '설정 {{destination}}개는 그대로 둡니다 - 여기에 저장된 자격 증명을 다른 곳으로 보내게 되기 때문입니다. 자격 증명은 복원되지 않습니다',
|
||||
spoolsUsageCount: '사용 기록 {{count}}건 포함',
|
||||
archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
|
||||
kprofilesPrinterCount: '프린터 {{count}}대 분량',
|
||||
@@ -5456,6 +5457,7 @@ export default {
|
||||
settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요',
|
||||
settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요',
|
||||
settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다',
|
||||
settingsDestinationKept: '{{keys}}을(를) 그대로 두었습니다 - 이 인스턴스에 저장된 자격 증명이 백업의 주소로 전송될 수 있기 때문입니다. 설정에서 변경하고 자격 증명을 다시 입력하세요.',
|
||||
settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요',
|
||||
kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다',
|
||||
kprofilesAckUnreliable: '응답하지 않는 프린터도 복원됨으로 집계됩니다 - 프린터에서 프로파일을 확인하세요',
|
||||
|
||||
@@ -5722,6 +5722,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} sleutel(s) die op inloggegevens lijken worden overgeslagen',
|
||||
settingsCompanionWillSkip: '{{count}} sleutel(s) die op inloggegevens lijken worden overgeslagen, en {{companion}} schakelaar(s) die daarvan afhankelijk zijn blijven uitgeschakeld',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} schakelaar(s) blijven uitgeschakeld - de benodigde inloggegevens kunnen niet uit een back-up worden hersteld',
|
||||
settingsDestinationWillKeep: '{{destination}} instelling(en) blijven ongewijzigd - ze zouden hier opgeslagen inloggegevens naar een andere bestemming sturen; inloggegevens worden nooit hersteld',
|
||||
spoolsUsageCount: 'inclusief {{count}} verbruiksrecord(s)',
|
||||
archivesMetadataOnly: 'Alleen metadata - 3MF-bestanden en miniaturen zitten niet in een Git-back-up',
|
||||
kprofilesPrinterCount: 'over {{count}} printer(s)',
|
||||
@@ -5744,6 +5745,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} sleutel(s) die op inloggegevens lijken overgeslagen - voer geheimen handmatig opnieuw in',
|
||||
settingsAuthSkipped: '{{count}} authenticatie-instelling(en) overgeslagen - wijzig deze onder Instellingen > Authenticatie zodat vergrendelingscontroles blijven werken',
|
||||
settingsCompanionSkipped: '{{keys}} blijven uitgeschakeld - de benodigde inloggegevens kunnen niet uit een back-up worden hersteld en deze instantie heeft er geen opgeslagen, dus inschakelen zou de integratie zonder authenticatie achterlaten',
|
||||
settingsDestinationKept: '{{keys}} ongewijzigd gelaten - deze instantie heeft opgeslagen inloggegevens die anders naar het adres uit de back-up zouden worden gestuurd. Wijzig ze in Instellingen en voer de inloggegevens opnieuw in.',
|
||||
settingsMqttRelayFailed: 'MQTT-instellingen hersteld, maar de relay kon niet opnieuw verbinden - start Bambuddy opnieuw',
|
||||
kprofilesAlwaysOverwrite: 'K-profielen overschrijven altijd de overeenkomende sleuf op de printer',
|
||||
kprofilesAckUnreliable: 'Een printer die niet antwoordt telt nog steeds als hersteld - controleer de profielen op de printer',
|
||||
|
||||
@@ -5661,6 +5661,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
|
||||
settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
|
||||
settingsDestinationWillKeep: '{{destination}} configuração(ões) ficarão como estão - enviariam uma credencial salva aqui para outro destino; credenciais nunca são restauradas',
|
||||
spoolsUsageCount: 'incluindo {{count}} registros de consumo',
|
||||
archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
|
||||
kprofilesPrinterCount: 'em {{count}} impressoras',
|
||||
@@ -5680,6 +5681,7 @@ export default {
|
||||
settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão',
|
||||
settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo',
|
||||
settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação',
|
||||
settingsDestinationKept: '{{keys}} ficaram como estão - esta instância tem uma credencial salva que, caso contrário, seria enviada ao endereço do backup. Altere-os em Configurações e informe a credencial novamente.',
|
||||
settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora',
|
||||
kprofilesAckUnreliable: 'Uma impressora que não responde ainda conta como restaurada - verifique os perfis na impressora',
|
||||
|
||||
@@ -5429,6 +5429,7 @@ export default {
|
||||
settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
|
||||
settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
|
||||
settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
|
||||
settingsDestinationWillKeep: 'Настройки ({{destination}}) останутся без изменений - они отправили бы сохранённые здесь учётные данные в другое место; учётные данные никогда не восстанавливаются',
|
||||
spoolsUsageCount: 'включая записей расхода: {{count}}',
|
||||
archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
|
||||
kprofilesPrinterCount: 'по {{count}} принтерам',
|
||||
@@ -5448,6 +5449,7 @@ export default {
|
||||
settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную',
|
||||
settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки',
|
||||
settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации',
|
||||
settingsDestinationKept: '{{keys}} оставлены без изменений - у этого экземпляра есть сохранённые учётные данные, которые иначе были бы отправлены на адрес из резервной копии. Измените их в настройках и введите учётные данные заново.',
|
||||
settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере',
|
||||
kprofilesAckUnreliable: 'Принтер, который не отвечает, всё равно считается восстановленным - проверьте профили на принтере',
|
||||
|
||||
@@ -5720,6 +5720,7 @@ errors: {
|
||||
settingsCredentialsWillSkip: '{{count}} uppgift(er) av typen lösenord kommer att hoppas över',
|
||||
settingsCompanionWillSkip: '{{count}} uppgift(er) av typen lösenord kommer att hoppas över, och {{companion}} växlar som beror på dem kommer att lämnas av',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} växlar kommer att lämnas av — den uppgift som var och en behöver kan inte återställas från en säkerhetskopia',
|
||||
settingsDestinationWillKeep: '{{destination}} inställning(ar) lämnas oförändrade - de skulle skicka en uppgift som sparats här någon annanstans; inloggningsuppgifter återställs aldrig',
|
||||
spoolsUsageCount: 'inklusive {{count}} användningspost(er)',
|
||||
archivesMetadataOnly: 'Endast metadata — 3MF-filer och minibilder finns inte i en Git-säkerhetskopia',
|
||||
kprofilesPrinterCount: 'över {{count}} skrivare',
|
||||
@@ -5742,6 +5743,7 @@ errors: {
|
||||
settingsCredentialsSkipped: '{{count}} uppgift(er) av typen lösenord hoppade över — ange hemligheter manuellt igen',
|
||||
settingsAuthSkipped: '{{count}} autentiseringsinställning(ar) hoppade över — ändra dem i Inställningar > Autentisering så att uteslutningskontrollerna fortfarande körs',
|
||||
settingsCompanionSkipped: '{{keys}} lämnade avstängda — den uppgift som var och en behöver kan inte återställas från en säkerhetskopia och denna instans har ingen lagrad, så att slå på dem skulle lämna integrationen oautentiserad',
|
||||
settingsDestinationKept: '{{keys}} lämnade oförändrade - den här instansen har en sparad inloggningsuppgift som annars skulle skickas till adressen i säkerhetskopian. Ändra dem i Inställningar och ange uppgiften igen.',
|
||||
settingsMqttRelayFailed: 'MQTT-inställningar återställda, men reläet kunde inte återanslutas — starta om Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K-profiler skriver alltid över det matchande facket på skrivaren',
|
||||
kprofilesAckUnreliable: 'En skrivare som inte svarar räknas ändå som återställd — verifiera profilerna på skrivaren',
|
||||
|
||||
@@ -5650,6 +5650,7 @@ export default {
|
||||
settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
|
||||
settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
|
||||
settingsDestinationWillKeep: '{{destination}} ayar olduğu gibi bırakılacak - burada kayıtlı bir kimlik bilgisini başka bir yere gönderirlerdi; kimlik bilgileri asla geri yüklenmez',
|
||||
spoolsUsageCount: '{{count}} kullanım kaydı dahil',
|
||||
archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
|
||||
kprofilesPrinterCount: '{{count}} yazıcı genelinde',
|
||||
@@ -5669,6 +5670,7 @@ export default {
|
||||
settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin',
|
||||
settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin',
|
||||
settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı',
|
||||
settingsDestinationKept: "{{keys}} olduğu gibi bırakıldı - bu örnekte kayıtlı bir kimlik bilgisi var ve aksi halde yedekteki adrese gönderilirdi. Bunları Ayarlar'dan değiştirin ve kimlik bilgisini yeniden girin.",
|
||||
settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın',
|
||||
kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar',
|
||||
kprofilesAckUnreliable: 'Yanıt vermeyen bir yazıcı yine de geri yüklendi sayılır - profilleri yazıcıda doğrulayın',
|
||||
|
||||
@@ -5715,6 +5715,7 @@ export default {
|
||||
settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
|
||||
settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
|
||||
settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
|
||||
settingsDestinationWillKeep: 'Налаштування ({{destination}}) залишаться без змін - вони надіслали б збережені тут облікові дані в інше місце; облікові дані ніколи не відновлюються',
|
||||
spoolsUsageCount: "включно із записами використання: {{count}}",
|
||||
archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
|
||||
kprofilesPrinterCount: "по {{count}} принтерах",
|
||||
@@ -5734,6 +5735,7 @@ export default {
|
||||
settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну",
|
||||
settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали",
|
||||
settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації",
|
||||
settingsDestinationKept: '{{keys}} залишено без змін - цей екземпляр має збережені облікові дані, які інакше були б надіслані на адресу з резервної копії. Змініть їх у налаштуваннях і введіть облікові дані знову.',
|
||||
settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy",
|
||||
kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері",
|
||||
kprofilesAckUnreliable: "Принтер, який не відповідає, усе одно вважається відновленим - перевірте профілі на принтері",
|
||||
|
||||
@@ -5661,6 +5661,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
|
||||
settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
|
||||
settingsDestinationWillKeep: '{{destination}} 项设置将保持不变 - 它们会把此处存储的凭据发送到别处;凭据永远不会被恢复',
|
||||
spoolsUsageCount: '其中含 {{count}} 条使用记录',
|
||||
archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
|
||||
kprofilesPrinterCount: '涉及 {{count}} 台打印机',
|
||||
@@ -5680,6 +5681,7 @@ export default {
|
||||
settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥',
|
||||
settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效',
|
||||
settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态',
|
||||
settingsDestinationKept: '{{keys}} 保持不变 - 本实例存储了凭据,否则会被发送到备份中的地址。请在设置中修改它们并重新输入凭据。',
|
||||
settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位',
|
||||
kprofilesAckUnreliable: '打印机不回应时仍计为已恢复 - 请在打印机上核对配置',
|
||||
|
||||
@@ -5661,6 +5661,7 @@ export default {
|
||||
settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
|
||||
settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
|
||||
settingsDestinationWillKeep: '{{destination}} 項設定會維持不變 - 它們會把此處儲存的憑證傳送到別處;憑證永遠不會被還原',
|
||||
spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
|
||||
archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
|
||||
kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
|
||||
@@ -5680,6 +5681,7 @@ export default {
|
||||
settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰',
|
||||
settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效',
|
||||
settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態',
|
||||
settingsDestinationKept: '{{keys}} 維持不變 - 本執行個體儲存了憑證,否則會被傳送到備份中的位址。請在設定中修改它們並重新輸入憑證。',
|
||||
settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽',
|
||||
kprofilesAckUnreliable: '印表機未回應時仍計為已還原 - 請在印表機上核對設定檔',
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,g as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./useQuery-BSYE6Prb.js";import{a as s,t as c}from"./CameraWall-Cg-tcG8a.js";import{Bn as l,Wn as u,yt as d,zn as f}from"./index-DEbGNNhA.js";var p=e(n(),1),m=t(),h=5e3,g=4,_=8,v=1,y=16,b=2,x=60;function S(e,t,n,r){let i=parseInt(e??``,10);return Number.isFinite(i)?Math.min(r,Math.max(n,i)):t}function C(e,t,n,r,i,a){return e.has(t)?S(e.get(t),r,i,a):S(localStorage.getItem(n),r,i,a)}function w(e){let t=e.get(`size`)?.toLowerCase(),n=s.indexOf(t);if(n>=0)return n+1;let r=parseInt(localStorage.getItem(`camWallTileSize`)??``,10);return r>=1&&r<=4?r:2}function T(){let{t:e}=a(),t=l(),{authEnabled:n,loading:s,user:S}=d(),[T]=u(),E=T.get(`token`),D=E!=null&&E!==``,[O,k]=(0,p.useState)(()=>C(T,`maxLive`,`camWallMaxLive`,g,v,y)),[A,j]=(0,p.useState)(()=>C(T,`interval`,`camWallSnapshotSec`,_,b,x)),[M]=(0,p.useState)(()=>w(T)),[N,P]=(0,p.useState)(()=>{let e=T.get(`status`)??localStorage.getItem(`camWallStatusMode`);return(D?[`off`,`compact`]:[`off`,`compact`,`full`]).includes(e)?e:`compact`});(0,p.useEffect)(()=>{if(D)return i(E),()=>i(null)},[D,E]);let F=o({queryKey:[`camwall-printers`,E],queryFn:()=>r.getCamWallPrinters(E??void 0),enabled:D,refetchInterval:h}),I=o({queryKey:[`printers`],queryFn:()=>r.getPrinters(),enabled:!D&&!s&&(!n||S!==null),refetchInterval:h}),L=(0,p.useMemo)(()=>{let e=new Map;for(let t of F.data??[])e.set(t.id,{connected:t.connected,state:t.state,progress:t.progress,remaining_time:t.remaining_time,layer_num:t.layer_num,total_layers:t.total_layers,hms_errors:t.hms_errors});return e},[F.data]);if(!D&&s)return(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray`,children:e(`common.loading`)});if(!D&&n&&!S)return(0,m.jsx)(f,{to:`/login`,replace:!0,state:{from:t}});let R=D?F:I,z=D?F.data??[]:I.data??[];return R.isError?(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center`,children:(0,m.jsx)(`p`,{className:`max-w-md text-sm text-red-400`,children:e(D?`printers.camWall.page.tokenRejected`:`printers.camWall.page.loadFailed`)})}):(0,m.jsx)(`div`,{className:`min-h-screen bg-bambu-dark p-4`,children:(0,m.jsx)(c,{printers:z,maxLive:O,snapshotIntervalSec:A,statusMode:N,tileSize:M,statuses:D?L:void 0,showSettings:!D,onTileClick:D?void 0:e=>window.open(`/camera/${e}`,`camera-${e}`),onChangeMaxLive:e=>{k(e),localStorage.setItem(`camWallMaxLive`,String(e))},onChangeSnapshotIntervalSec:e=>{j(e),localStorage.setItem(`camWallSnapshotSec`,String(e))},onChangeStatusMode:e=>{P(e),localStorage.setItem(`camWallStatusMode`,e)}})})}export{T as CamWallPage,T as default};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,g as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./useQuery-BSYE6Prb.js";import{a as s,t as c}from"./CameraWall-DxmgQGu_.js";import{Bn as l,Wn as u,yt as d,zn as f}from"./index-D4l8mBj7.js";var p=e(n(),1),m=t(),h=5e3,g=4,_=8,v=1,y=16,b=2,x=60;function S(e,t,n,r){let i=parseInt(e??``,10);return Number.isFinite(i)?Math.min(r,Math.max(n,i)):t}function C(e,t,n,r,i,a){return e.has(t)?S(e.get(t),r,i,a):S(localStorage.getItem(n),r,i,a)}function w(e){let t=e.get(`size`)?.toLowerCase(),n=s.indexOf(t);if(n>=0)return n+1;let r=parseInt(localStorage.getItem(`camWallTileSize`)??``,10);return r>=1&&r<=4?r:2}function T(){let{t:e}=a(),t=l(),{authEnabled:n,loading:s,user:S}=d(),[T]=u(),E=T.get(`token`),D=E!=null&&E!==``,[O,k]=(0,p.useState)(()=>C(T,`maxLive`,`camWallMaxLive`,g,v,y)),[A,j]=(0,p.useState)(()=>C(T,`interval`,`camWallSnapshotSec`,_,b,x)),[M]=(0,p.useState)(()=>w(T)),[N,P]=(0,p.useState)(()=>{let e=T.get(`status`)??localStorage.getItem(`camWallStatusMode`);return(D?[`off`,`compact`]:[`off`,`compact`,`full`]).includes(e)?e:`compact`});(0,p.useEffect)(()=>{if(D)return i(E),()=>i(null)},[D,E]);let F=o({queryKey:[`camwall-printers`,E],queryFn:()=>r.getCamWallPrinters(E??void 0),enabled:D,refetchInterval:h}),I=o({queryKey:[`printers`],queryFn:()=>r.getPrinters(),enabled:!D&&!s&&(!n||S!==null),refetchInterval:h}),L=(0,p.useMemo)(()=>{let e=new Map;for(let t of F.data??[])e.set(t.id,{connected:t.connected,state:t.state,progress:t.progress,remaining_time:t.remaining_time,layer_num:t.layer_num,total_layers:t.total_layers,hms_errors:t.hms_errors});return e},[F.data]);if(!D&&s)return(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray`,children:e(`common.loading`)});if(!D&&n&&!S)return(0,m.jsx)(f,{to:`/login`,replace:!0,state:{from:t}});let R=D?F:I,z=D?F.data??[]:I.data??[];return R.isError?(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center`,children:(0,m.jsx)(`p`,{className:`max-w-md text-sm text-red-400`,children:e(D?`printers.camWall.page.tokenRejected`:`printers.camWall.page.loadFailed`)})}):(0,m.jsx)(`div`,{className:`min-h-screen bg-bambu-dark p-4`,children:(0,m.jsx)(c,{printers:z,maxLive:O,snapshotIntervalSec:A,statusMode:N,tileSize:M,statuses:D?L:void 0,showSettings:!D,onTileClick:D?void 0:e=>window.open(`/camera/${e}`,`camera-${e}`),onChangeMaxLive:e=>{k(e),localStorage.setItem(`camWallMaxLive`,String(e))},onChangeSnapshotIntervalSec:e=>{j(e),localStorage.setItem(`camWallSnapshotSec`,String(e))},onChangeStatusMode:e=>{P(e),localStorage.setItem(`camWallStatusMode`,e)}})})}export{T as CamWallPage,T as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,w as r}from"./client-C3LdD0iY.js";import{a as i,i as a}from"./info-jpcgNqAh.js";import{t as o}from"./eye-off-BKHC5oyQ.js";import{t as s}from"./grip-vertical-eoFY62AH.js";import{n as c,t as l}from"./Card-BvtE18YT.js";import{t as u}from"./Button-B2stjvVl.js";import{Gt as d,Sn as f}from"./index-DEbGNNhA.js";var p=e(n(),1),m=t();function h({isOpen:e,onClose:t,columns:n,defaultColumns:h,onSave:g}){let{t:_}=r(),[v,y]=(0,p.useState)(n),[b,x]=(0,p.useState)(null),S=(0,p.useRef)(null);if((0,p.useEffect)(()=>{e&&y(n.map(e=>({...e})))},[e,n]),(0,p.useEffect)(()=>{if(!e)return;let n=e=>{e.key===`Escape`&&t()};return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let C=e=>{y(t=>t.map((t,n)=>n===e?{...t,visible:!t.visible}:t))},w=(e,t)=>{t<0||t>=v.length||y(n=>{let r=[...n],[i]=r.splice(e,1);return r.splice(t,0,i),r})},T=(e,t)=>{S.current=t,x(t),e.dataTransfer.effectAllowed=`move`},E=(e,t)=>{e.preventDefault(),e.dataTransfer.dropEffect=`move`;let n=S.current;n!==null&&n!==t&&(w(n,t),S.current=t,x(t))},D=e=>{e.preventDefault()},O=()=>{S.current=null,x(null)},k=()=>{y(h.map(e=>({...e})))},A=()=>{g(v),t()},j=v.filter(e=>e.visible).length;return(0,m.jsx)(`div`,{className:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`,onClick:t,children:(0,m.jsx)(l,{className:`w-full max-w-md max-h-[80vh] flex flex-col`,onClick:e=>e.stopPropagation(),children:(0,m.jsxs)(c,{className:`p-6 flex flex-col min-h-0`,children:[(0,m.jsx)(`h3`,{className:`text-lg font-semibold text-white mb-2`,children:_(`inventory.configureColumns`)}),(0,m.jsxs)(`p`,{className:`text-sm text-bambu-gray mb-4`,children:[_(`inventory.configureColumnsDesc`),(0,m.jsxs)(`span`,{className:`ml-2 text-bambu-gray/60`,children:[`(`,j,` `,_(`inventory.of`),` `,v.length,` `,_(`inventory.visible`),`)`]})]}),(0,m.jsx)(`div`,{className:`space-y-1 overflow-y-auto flex-1 min-h-0 pr-1`,children:v.map((e,t)=>(0,m.jsxs)(`div`,{className:`flex items-center gap-2 p-2 rounded-lg border transition-colors ${b===t?`border-bambu-green bg-bambu-green/10`:`border-bambu-dark-tertiary bg-bambu-dark-tertiary/50`} ${e.visible?``:`opacity-50`}`,draggable:!0,onDragStart:e=>T(e,t),onDragOver:e=>E(e,t),onDrop:D,onDragEnd:O,children:[(0,m.jsx)(`div`,{className:`cursor-grab text-bambu-gray/50 hover:text-bambu-gray`,children:(0,m.jsx)(s,{className:`w-4 h-4`})}),(0,m.jsx)(`span`,{className:`flex-1 font-medium text-sm text-white`,children:e.label}),(0,m.jsxs)(`div`,{className:`flex items-center gap-0.5`,children:[(0,m.jsx)(`button`,{onClick:()=>w(t,t-1),disabled:t===0,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveUp`),children:(0,m.jsx)(a,{className:`w-4 h-4`})}),(0,m.jsx)(`button`,{onClick:()=>w(t,t+1),disabled:t===v.length-1,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveDown`),children:(0,m.jsx)(i,{className:`w-4 h-4`})})]}),(0,m.jsx)(`button`,{onClick:()=>C(t),className:`p-1.5 rounded transition-colors ${e.visible?`text-bambu-green hover:bg-bambu-green/10`:`text-bambu-gray/50 hover:bg-bambu-dark-secondary`}`,title:e.visible?_(`inventory.hideColumn`):_(`inventory.showColumn`),children:e.visible?(0,m.jsx)(f,{className:`w-4 h-4`}):(0,m.jsx)(o,{className:`w-4 h-4`})})]},e.id))}),(0,m.jsxs)(`div`,{className:`flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary`,children:[(0,m.jsxs)(u,{variant:`secondary`,onClick:k,className:`mr-auto`,children:[(0,m.jsx)(d,{className:`w-4 h-4`}),_(`inventory.reset`)]}),(0,m.jsx)(u,{variant:`secondary`,onClick:t,children:_(`inventory.cancel`)}),(0,m.jsx)(u,{onClick:A,children:_(`inventory.applyChanges`)})]})]})})})}export{h as t};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,w as r}from"./client-C3LdD0iY.js";import{a as i,i as a}from"./info-jpcgNqAh.js";import{t as o}from"./eye-off-BKHC5oyQ.js";import{t as s}from"./grip-vertical-eoFY62AH.js";import{n as c,t as l}from"./Card-BvtE18YT.js";import{t as u}from"./Button-B2stjvVl.js";import{Gt as d,Sn as f}from"./index-D4l8mBj7.js";var p=e(n(),1),m=t();function h({isOpen:e,onClose:t,columns:n,defaultColumns:h,onSave:g}){let{t:_}=r(),[v,y]=(0,p.useState)(n),[b,x]=(0,p.useState)(null),S=(0,p.useRef)(null);if((0,p.useEffect)(()=>{e&&y(n.map(e=>({...e})))},[e,n]),(0,p.useEffect)(()=>{if(!e)return;let n=e=>{e.key===`Escape`&&t()};return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let C=e=>{y(t=>t.map((t,n)=>n===e?{...t,visible:!t.visible}:t))},w=(e,t)=>{t<0||t>=v.length||y(n=>{let r=[...n],[i]=r.splice(e,1);return r.splice(t,0,i),r})},T=(e,t)=>{S.current=t,x(t),e.dataTransfer.effectAllowed=`move`},E=(e,t)=>{e.preventDefault(),e.dataTransfer.dropEffect=`move`;let n=S.current;n!==null&&n!==t&&(w(n,t),S.current=t,x(t))},D=e=>{e.preventDefault()},O=()=>{S.current=null,x(null)},k=()=>{y(h.map(e=>({...e})))},A=()=>{g(v),t()},j=v.filter(e=>e.visible).length;return(0,m.jsx)(`div`,{className:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`,onClick:t,children:(0,m.jsx)(l,{className:`w-full max-w-md max-h-[80vh] flex flex-col`,onClick:e=>e.stopPropagation(),children:(0,m.jsxs)(c,{className:`p-6 flex flex-col min-h-0`,children:[(0,m.jsx)(`h3`,{className:`text-lg font-semibold text-white mb-2`,children:_(`inventory.configureColumns`)}),(0,m.jsxs)(`p`,{className:`text-sm text-bambu-gray mb-4`,children:[_(`inventory.configureColumnsDesc`),(0,m.jsxs)(`span`,{className:`ml-2 text-bambu-gray/60`,children:[`(`,j,` `,_(`inventory.of`),` `,v.length,` `,_(`inventory.visible`),`)`]})]}),(0,m.jsx)(`div`,{className:`space-y-1 overflow-y-auto flex-1 min-h-0 pr-1`,children:v.map((e,t)=>(0,m.jsxs)(`div`,{className:`flex items-center gap-2 p-2 rounded-lg border transition-colors ${b===t?`border-bambu-green bg-bambu-green/10`:`border-bambu-dark-tertiary bg-bambu-dark-tertiary/50`} ${e.visible?``:`opacity-50`}`,draggable:!0,onDragStart:e=>T(e,t),onDragOver:e=>E(e,t),onDrop:D,onDragEnd:O,children:[(0,m.jsx)(`div`,{className:`cursor-grab text-bambu-gray/50 hover:text-bambu-gray`,children:(0,m.jsx)(s,{className:`w-4 h-4`})}),(0,m.jsx)(`span`,{className:`flex-1 font-medium text-sm text-white`,children:e.label}),(0,m.jsxs)(`div`,{className:`flex items-center gap-0.5`,children:[(0,m.jsx)(`button`,{onClick:()=>w(t,t-1),disabled:t===0,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveUp`),children:(0,m.jsx)(a,{className:`w-4 h-4`})}),(0,m.jsx)(`button`,{onClick:()=>w(t,t+1),disabled:t===v.length-1,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveDown`),children:(0,m.jsx)(i,{className:`w-4 h-4`})})]}),(0,m.jsx)(`button`,{onClick:()=>C(t),className:`p-1.5 rounded transition-colors ${e.visible?`text-bambu-green hover:bg-bambu-green/10`:`text-bambu-gray/50 hover:bg-bambu-dark-secondary`}`,title:e.visible?_(`inventory.hideColumn`):_(`inventory.showColumn`),children:e.visible?(0,m.jsx)(f,{className:`w-4 h-4`}):(0,m.jsx)(o,{className:`w-4 h-4`})})]},e.id))}),(0,m.jsxs)(`div`,{className:`flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary`,children:[(0,m.jsxs)(u,{variant:`secondary`,onClick:k,className:`mr-auto`,children:[(0,m.jsx)(d,{className:`w-4 h-4`}),_(`inventory.reset`)]}),(0,m.jsx)(u,{variant:`secondary`,onClick:t,children:_(`inventory.cancel`)}),(0,m.jsx)(u,{onClick:A,children:_(`inventory.applyChanges`)})]})]})})})}export{h as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,t as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./link-2-BrJ-i2LF.js";import{t as s}from"./loader-circle-CbaPxZRP.js";import{t as c}from"./triangle-alert-BUlaDyuA.js";import{Wn as l,yt as u}from"./index-DEbGNNhA.js";var d=e(n(),1),f=t();function p(e,t){let n=new URL(e);for(let[e,r]of Object.entries(t))r&&n.searchParams.set(e,r);return n.toString()}function m(){let{t:e}=a(),{authEnabled:t,loading:n}=u(),[m]=l(),[h,g]=(0,d.useState)({kind:`loading`}),_=(0,d.useRef)(!1),v=m.get(`client_id`)??``,y=m.get(`redirect_uri`)??``,b=m.get(`state`)??``,x=m.get(`code_challenge`)??``,S=m.get(`code_challenge_method`)??``,C=async()=>{g({kind:`redirecting`});try{let e=await r.connectAuthorize({client_id:v,redirect_uri:y,code_challenge:x,code_challenge_method:`S256`});window.location.replace(p(e.redirect_uri,{code:e.code,state:b}))}catch(t){g({kind:`error`,message:t instanceof Error?t.message:e(`connectedApps.authorize.failed`)})}};return(0,d.useEffect)(()=>{if(!(n||_.current)){if(_.current=!0,!t){g({kind:`error`,message:e(`connectedApps.authorize.authDisabled`)});return}if(!v||!y||!x||S!==`S256`){g({kind:`error`,message:e(`connectedApps.authorize.invalidRequest`)});return}r.getConnectAuthorizeInfo(v,y).then(e=>{e.already_granted?C():g({kind:`consent`,info:e})}).catch(t=>{g({kind:`error`,message:t instanceof i&&t.status===409?e(`connectedApps.authorize.authDisabled`):e(`connectedApps.authorize.invalidRequest`)})})}},[n,t]),(0,f.jsx)(`div`,{className:`min-h-screen flex items-center justify-center bg-bambu-dark p-4`,children:(0,f.jsxs)(`div`,{className:`max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg`,children:[(h.kind===`loading`||h.kind===`redirecting`)&&(0,f.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-bambu-gray`,role:`status`,children:[(0,f.jsx)(s,{className:`w-8 h-8 animate-spin text-bambu-green`}),(0,f.jsx)(`p`,{children:h.kind===`loading`?e(`connectedApps.authorize.checking`):e(`connectedApps.authorize.redirecting`)})]}),h.kind===`error`&&(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(c,{className:`w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400`}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white`,children:e(`connectedApps.authorize.errorTitle`)}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:h.message})]}),h.kind===`consent`&&(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center`,children:(0,f.jsx)(o,{className:`w-7 h-7 text-bambu-green`})}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white text-balance`,children:e(`connectedApps.authorize.title`,{app:h.info.app_name})}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.signedInAs`,{username:h.info.username})})]}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.sharedData`,{app:h.info.app_name})}),(0,f.jsxs)(`div`,{className:`flex gap-3`,children:[(0,f.jsx)(`button`,{type:`button`,onClick:()=>{window.location.replace(p(y,{error:`access_denied`,state:b}))},className:`flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green`,children:e(`connectedApps.authorize.deny`)}),(0,f.jsx)(`button`,{type:`button`,onClick:()=>void C(),className:`flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white`,children:e(`connectedApps.authorize.allow`)})]})]})]})})}export{m as ConnectAuthorizePage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,t as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./link-2-BrJ-i2LF.js";import{t as s}from"./loader-circle-CbaPxZRP.js";import{t as c}from"./triangle-alert-BUlaDyuA.js";import{Wn as l,yt as u}from"./index-D4l8mBj7.js";var d=e(n(),1),f=t();function p(e,t){let n=new URL(e);for(let[e,r]of Object.entries(t))r&&n.searchParams.set(e,r);return n.toString()}function m(){let{t:e}=a(),{authEnabled:t,loading:n}=u(),[m]=l(),[h,g]=(0,d.useState)({kind:`loading`}),_=(0,d.useRef)(!1),v=m.get(`client_id`)??``,y=m.get(`redirect_uri`)??``,b=m.get(`state`)??``,x=m.get(`code_challenge`)??``,S=m.get(`code_challenge_method`)??``,C=async()=>{g({kind:`redirecting`});try{let e=await r.connectAuthorize({client_id:v,redirect_uri:y,code_challenge:x,code_challenge_method:`S256`});window.location.replace(p(e.redirect_uri,{code:e.code,state:b}))}catch(t){g({kind:`error`,message:t instanceof Error?t.message:e(`connectedApps.authorize.failed`)})}};return(0,d.useEffect)(()=>{if(!(n||_.current)){if(_.current=!0,!t){g({kind:`error`,message:e(`connectedApps.authorize.authDisabled`)});return}if(!v||!y||!x||S!==`S256`){g({kind:`error`,message:e(`connectedApps.authorize.invalidRequest`)});return}r.getConnectAuthorizeInfo(v,y).then(e=>{e.already_granted?C():g({kind:`consent`,info:e})}).catch(t=>{g({kind:`error`,message:t instanceof i&&t.status===409?e(`connectedApps.authorize.authDisabled`):e(`connectedApps.authorize.invalidRequest`)})})}},[n,t]),(0,f.jsx)(`div`,{className:`min-h-screen flex items-center justify-center bg-bambu-dark p-4`,children:(0,f.jsxs)(`div`,{className:`max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg`,children:[(h.kind===`loading`||h.kind===`redirecting`)&&(0,f.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-bambu-gray`,role:`status`,children:[(0,f.jsx)(s,{className:`w-8 h-8 animate-spin text-bambu-green`}),(0,f.jsx)(`p`,{children:h.kind===`loading`?e(`connectedApps.authorize.checking`):e(`connectedApps.authorize.redirecting`)})]}),h.kind===`error`&&(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(c,{className:`w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400`}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white`,children:e(`connectedApps.authorize.errorTitle`)}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:h.message})]}),h.kind===`consent`&&(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center`,children:(0,f.jsx)(o,{className:`w-7 h-7 text-bambu-green`})}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white text-balance`,children:e(`connectedApps.authorize.title`,{app:h.info.app_name})}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.signedInAs`,{username:h.info.username})})]}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.sharedData`,{app:h.info.app_name})}),(0,f.jsxs)(`div`,{className:`flex gap-3`,children:[(0,f.jsx)(`button`,{type:`button`,onClick:()=>{window.location.replace(p(y,{error:`access_denied`,state:b}))},className:`flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green`,children:e(`connectedApps.authorize.deny`)}),(0,f.jsx)(`button`,{type:`button`,onClick:()=>void C(),className:`flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white`,children:e(`connectedApps.authorize.allow`)})]})]})]})})}export{m as ConnectAuthorizePage};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{t as s}from"./triangle-alert-BUlaDyuA.js";import{Un as c,Vn as l,vt as u}from"./index-DEbGNNhA.js";var d=e(n(),1),f=t();function p(){let{t:e}=i(),{id:t}=c(),{mode:n}=u(),l=(0,d.useRef)(null),p=m(l);g(l);let{data:h,isLoading:_,error:v}=a({queryKey:[`external-link`,t],queryFn:()=>r.getExternalLink(Number(t)),enabled:!!t});return _?(0,f.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,f.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})}):v||!h?(0,f.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-4 text-bambu-gray`,children:[(0,f.jsx)(s,{className:`w-12 h-12`}),(0,f.jsx)(`p`,{children:e(`common.linkNotFound`)})]}):(0,f.jsx)(`iframe`,{ref:l,src:h.url,onLoad:p,className:`h-full w-full border-0`,style:{colorScheme:n},title:h.name,sandbox:`allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`})}function m(e){let{resolvedMode:t,darkStyle:n,darkBackground:r,darkAccent:i,lightStyle:a,lightBackground:o,lightAccent:s}=u(),c=t===`dark`,l=c?n:a,f=c?r:o,p=c?i:s,m=(0,d.useCallback)(()=>{let n=e.current;if(!n?.contentWindow)return;let r;try{r=new URL(n.src).origin}catch{return}n.contentWindow.postMessage({type:`bambuddy:theme`,mode:t,style:l,background:f,accent:p,canNavigate:!0},r)},[e,t,l,f,p]);return(0,d.useEffect)(()=>m(),[m]),(0,d.useEffect)(()=>{let t=t=>{t.source===e.current?.contentWindow&&t.data?.type===`bambuddy:theme-request`&&m()};return window.addEventListener(`message`,t),()=>window.removeEventListener(`message`,t)},[e,m]),m}var h=512;function g(e){let t=l();(0,d.useEffect)(()=>{let n=n=>{let r=e.current;if(!r?.contentWindow||n.source!==r.contentWindow||n.data?.type!==`bambuddy:navigate`)return;let i;try{i=new URL(r.src).origin}catch{return}if(n.origin!==i)return;let a=_(n.data.path);a!==null&&t(a)};return window.addEventListener(`message`,n),()=>window.removeEventListener(`message`,n)},[e,t])}function _(e){if(typeof e!=`string`||e.length>h||!e.startsWith(`/`)||e.startsWith(`//`)||e.includes(`\\`))return null;let t;try{t=new URL(e,window.location.origin)}catch{return null}return t.origin===window.location.origin?t.pathname+t.search+t.hash:null}export{p as ExternalLinkPage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{t as s}from"./triangle-alert-BUlaDyuA.js";import{Un as c,Vn as l,vt as u}from"./index-D4l8mBj7.js";var d=e(n(),1),f=t();function p(){let{t:e}=i(),{id:t}=c(),{mode:n}=u(),l=(0,d.useRef)(null),p=m(l);g(l);let{data:h,isLoading:_,error:v}=a({queryKey:[`external-link`,t],queryFn:()=>r.getExternalLink(Number(t)),enabled:!!t});return _?(0,f.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,f.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})}):v||!h?(0,f.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-4 text-bambu-gray`,children:[(0,f.jsx)(s,{className:`w-12 h-12`}),(0,f.jsx)(`p`,{children:e(`common.linkNotFound`)})]}):(0,f.jsx)(`iframe`,{ref:l,src:h.url,onLoad:p,className:`h-full w-full border-0`,style:{colorScheme:n},title:h.name,sandbox:`allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`})}function m(e){let{resolvedMode:t,darkStyle:n,darkBackground:r,darkAccent:i,lightStyle:a,lightBackground:o,lightAccent:s}=u(),c=t===`dark`,l=c?n:a,f=c?r:o,p=c?i:s,m=(0,d.useCallback)(()=>{let n=e.current;if(!n?.contentWindow)return;let r;try{r=new URL(n.src).origin}catch{return}n.contentWindow.postMessage({type:`bambuddy:theme`,mode:t,style:l,background:f,accent:p,canNavigate:!0},r)},[e,t,l,f,p]);return(0,d.useEffect)(()=>m(),[m]),(0,d.useEffect)(()=>{let t=t=>{t.source===e.current?.contentWindow&&t.data?.type===`bambuddy:theme-request`&&m()};return window.addEventListener(`message`,t),()=>window.removeEventListener(`message`,t)},[e,m]),m}var h=512;function g(e){let t=l();(0,d.useEffect)(()=>{let n=n=>{let r=e.current;if(!r?.contentWindow||n.source!==r.contentWindow||n.data?.type!==`bambuddy:navigate`)return;let i;try{i=new URL(r.src).origin}catch{return}if(n.origin!==i)return;let a=_(n.data.path);a!==null&&t(a)};return window.addEventListener(`message`,n),()=>window.removeEventListener(`message`,n)},[e,t])}function _(e){if(typeof e!=`string`||e.length>h||!e.startsWith(`/`)||e.startsWith(`//`)||e.includes(`\\`))return null;let t;try{t=new URL(e,window.location.origin)}catch{return null}return t.origin===window.location.origin?t.pathname+t.search+t.hash:null}export{p as ExternalLinkPage};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./arrow-left-CSvb6Y3A.js";import{t as s}from"./GcodeToolpathViewer-N4Eow1eU.js";import{Vn as c,Wn as l}from"./index-DEbGNNhA.js";var u=e(n(),1),d=t();function f(){let e=c(),[t,n]=l(),{t:f}=i(),p=t.get(`archive`),m=t.get(`library_file`),h=t.get(`plate`),g=a({queryKey:[`gcode-viewer-archive-colors`,p],queryFn:()=>r.getArchiveCapabilities(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),_=a({queryKey:[`gcode-viewer-library-colors`,m],queryFn:()=>r.getLibraryFilePlates(Number(m)),enabled:!!m,staleTime:5*6e4,retry:!1}),v=a({queryKey:[`gcode-viewer-archive-plates`,p],queryFn:()=>r.getArchivePlates(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),y=(0,u.useMemo)(()=>(p?v.data?.plates:_.data?.plates)??[],[p,v.data,_.data]),b=(0,u.useMemo)(()=>h?Number(h):y.length===0?null:Math.min(...y.map(e=>e.index)),[h,y]),x=e=>{if(e===b)return;let r=new URLSearchParams(t);r.set(`plate`,String(e)),n(r,{replace:!0})},S=(0,u.useMemo)(()=>{if(p)return g.data?.filament_colors;let e=y.find(e=>e.index===b)||y[0];if(!e?.filaments?.length)return;let t=[];for(let n of e.filaments){let e=Math.max(0,(n.slot_id??1)-1);n.color&&(t[e]=n.color)}return t.length>0?t:void 0},[p,g.data,y,b]),C=(0,u.useMemo)(()=>{let e=e=>h?`${e}?plate=${encodeURIComponent(h)}`:e;return p?e(r.getArchiveGcode(Number(p))):m?e(r.getLibraryFileGcodeUrl(Number(m))):null},[p,m,h]),w=()=>{window.history.length>1?e(-1):e(p?`/archives`:`/files`)},T=p?f(`gcodeViewer.backToArchives`,`Back to Archives`):f(`gcodeViewer.backToFiles`,`Back to File Manager`);return(0,d.jsxs)(`div`,{className:`flex flex-col min-h-[calc(100vh-64px)] lg:h-screen`,children:[(0,d.jsxs)(`div`,{className:`flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2`,children:[(0,d.jsxs)(`button`,{type:`button`,onClick:w,className:`inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`,children:[(0,d.jsx)(o,{className:`w-4 h-4`}),T]}),y.length>1&&(0,d.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[(0,d.jsx)(`span`,{className:`text-xs text-bambu-gray`,children:f(`gcodeViewer.plates`,`Plates`)}),y.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>x(e.index),"aria-pressed":e.index===b,title:e.name??void 0,className:`px-2 py-0.5 rounded text-xs transition-colors ${e.index===b?`bg-bambu-green text-white`:`bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white`}`,children:f(`gcodeViewer.plateN`,`Plate {{n}}`,{n:e.index})},e.index))]})]}),C?(0,d.jsx)(s,{gcodeUrl:C,filamentColors:S,className:`flex-1 min-h-0`}):(0,d.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-sm text-bambu-gray`,children:f(`gcodeViewer.noSource`,`No file was given to preview.`)})]})}export{f as GCodeViewerPage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./arrow-left-CSvb6Y3A.js";import{t as s}from"./GcodeToolpathViewer-N4Eow1eU.js";import{Vn as c,Wn as l}from"./index-D4l8mBj7.js";var u=e(n(),1),d=t();function f(){let e=c(),[t,n]=l(),{t:f}=i(),p=t.get(`archive`),m=t.get(`library_file`),h=t.get(`plate`),g=a({queryKey:[`gcode-viewer-archive-colors`,p],queryFn:()=>r.getArchiveCapabilities(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),_=a({queryKey:[`gcode-viewer-library-colors`,m],queryFn:()=>r.getLibraryFilePlates(Number(m)),enabled:!!m,staleTime:5*6e4,retry:!1}),v=a({queryKey:[`gcode-viewer-archive-plates`,p],queryFn:()=>r.getArchivePlates(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),y=(0,u.useMemo)(()=>(p?v.data?.plates:_.data?.plates)??[],[p,v.data,_.data]),b=(0,u.useMemo)(()=>h?Number(h):y.length===0?null:Math.min(...y.map(e=>e.index)),[h,y]),x=e=>{if(e===b)return;let r=new URLSearchParams(t);r.set(`plate`,String(e)),n(r,{replace:!0})},S=(0,u.useMemo)(()=>{if(p)return g.data?.filament_colors;let e=y.find(e=>e.index===b)||y[0];if(!e?.filaments?.length)return;let t=[];for(let n of e.filaments){let e=Math.max(0,(n.slot_id??1)-1);n.color&&(t[e]=n.color)}return t.length>0?t:void 0},[p,g.data,y,b]),C=(0,u.useMemo)(()=>{let e=e=>h?`${e}?plate=${encodeURIComponent(h)}`:e;return p?e(r.getArchiveGcode(Number(p))):m?e(r.getLibraryFileGcodeUrl(Number(m))):null},[p,m,h]),w=()=>{window.history.length>1?e(-1):e(p?`/archives`:`/files`)},T=p?f(`gcodeViewer.backToArchives`,`Back to Archives`):f(`gcodeViewer.backToFiles`,`Back to File Manager`);return(0,d.jsxs)(`div`,{className:`flex flex-col min-h-[calc(100vh-64px)] lg:h-screen`,children:[(0,d.jsxs)(`div`,{className:`flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2`,children:[(0,d.jsxs)(`button`,{type:`button`,onClick:w,className:`inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`,children:[(0,d.jsx)(o,{className:`w-4 h-4`}),T]}),y.length>1&&(0,d.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[(0,d.jsx)(`span`,{className:`text-xs text-bambu-gray`,children:f(`gcodeViewer.plates`,`Plates`)}),y.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>x(e.index),"aria-pressed":e.index===b,title:e.name??void 0,className:`px-2 py-0.5 rounded text-xs transition-colors ${e.index===b?`bg-bambu-green text-white`:`bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white`}`,children:f(`gcodeViewer.plateN`,`Plate {{n}}`,{n:e.index})},e.index))]})]}),C?(0,d.jsx)(s,{gcodeUrl:C,filamentColors:S,className:`flex-1 min-h-0`}):(0,d.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-sm text-bambu-gray`,children:f(`gcodeViewer.noSource`,`No file was given to preview.`)})]})}export{f as GCodeViewerPage};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,l as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{n as s,t as c}from"./zoom-out-CpxEPaFQ.js";import{t as l}from"./file-DDkFXH-b.js";import{Gt as u,un as d}from"./index-DEbGNNhA.js";import{n as f,r as p,t as m}from"./usePreviewFullscreen-Bd6UWAwo.js";var h=e(n(),1),g=t(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:t,fileSize:n,onClose:_}){let{t:v}=a(),T=m(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let t=!1,a=null;if(j(null),N(!1),F(null),q(1,C),n>52428800){F(v(`fileManager.preview.tooLarge`,{size:l(n)}));return}let o={},s=i();return s&&(o.Authorization=`Bearer ${s}`),(async()=>{let n=await fetch(r.getLibraryFileDownloadUrl(e),{headers:o});if(!n.ok)throw Error(`HTTP ${n.status}`);let i=await n.blob();t||(a=URL.createObjectURL(i),j(a))})().catch(e=>{console.error(`[image-preview] load failed`,e),t||F(v(`fileManager.preview.error`))}),()=>{t=!0,a&&URL.revokeObjectURL(a)}},[e,n,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(f,{title:t,fullscreen:T,onClose:_,icon:(0,g.jsx)(d,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:p,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(c,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:p,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(s,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:p,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(u,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:t,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,l as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{n as s,t as c}from"./zoom-out-CpxEPaFQ.js";import{t as l}from"./file-DDkFXH-b.js";import{Gt as u,un as d}from"./index-D4l8mBj7.js";import{n as f,r as p,t as m}from"./usePreviewFullscreen-Bd6UWAwo.js";var h=e(n(),1),g=t(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:t,fileSize:n,onClose:_}){let{t:v}=a(),T=m(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let t=!1,a=null;if(j(null),N(!1),F(null),q(1,C),n>52428800){F(v(`fileManager.preview.tooLarge`,{size:l(n)}));return}let o={},s=i();return s&&(o.Authorization=`Bearer ${s}`),(async()=>{let n=await fetch(r.getLibraryFileDownloadUrl(e),{headers:o});if(!n.ok)throw Error(`HTTP ${n.status}`);let i=await n.blob();t||(a=URL.createObjectURL(i),j(a))})().catch(e=>{console.error(`[image-preview] load failed`,e),t||F(v(`fileManager.preview.error`))}),()=>{t=!0,a&&URL.revokeObjectURL(a)}},[e,n,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(f,{title:t,fullscreen:T,onClose:_,icon:(0,g.jsx)(d,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:p,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(c,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:p,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(s,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:p,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(u,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:t,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{ft as e}from"./index-DEbGNNhA.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
|
||||
import{ft as e}from"./index-D4l8mBj7.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DEbGNNhA.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D4l8mBj7.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">
|
||||
|
||||
Reference in New Issue
Block a user