mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked preset's own values could not be read" -- with no indication of what to do about it. The overwhelmingly common cause has an obvious fix, and it isn't an edge case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of which Bambuddy channel it is on, so a current Bambuddy talking to a sidecar that predates POST /profiles/resolve is the normal state, not a misconfiguration. Those users would have seen an amber warning on every slice with nothing pointing at the sidecar image. resolve_profile now returns ResolvedProfile(values, reason) instead of None for everything, the route passes the reason through, and the panel picks its message from it: sidecar_outdated -> name the fix: update the sidecar image sidecar_unavailable -> the sidecar did not answer not_configured -> no sidecar is configured preset_unresolved -> the previous generic wording A request that fails outright maps to sidecar_unavailable, since a backend we cannot reach and a sidecar that will not answer are the same thing from the dialog. Every variant still ends with "anything you don't change still uses the preset" -- that reassurance is the point of the notice, and it is true whichever way the lookup failed. Tests pin the distinction rather than just the happy path: a 404 and a 500 must produce different reasons, and each panel case asserts both that its own message appears and that the "update the sidecar image" line does not leak into the others.
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests.
|
||||
- **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests.
|
||||
|
||||
- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog -- so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped -- it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature -- support base and interface, and the per-region pickers for walls, infill and surfaces -- list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form -- the whole Line width group showed "0." rather than "0" -- and those are now cleaned as the data is generated instead of being papered over at display time. Every field starts from the values your picked process preset actually sets, fetched by flattening it through the slicer sidecar -- the same resolver that does the slicing, so the numbers cannot disagree with what a slice produces. A field you never touch shows the preset's value, and reverting returns to it. Where those values cannot be read -- a sidecar that is offline or predates the endpoint -- the panel falls back to the slicer's own defaults and says so, rather than presenting them as if they were your preset's. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests.
|
||||
- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog — so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped — it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature — support base and interface, and the per-region pickers for walls, infill and surfaces — list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form — the whole Line width group showed "0." rather than "0" — and those are now cleaned as the data is generated instead of being papered over at display time. Every field starts from the values your picked process preset actually sets, fetched by flattening it through the slicer sidecar — the same resolver that does the slicing, so the numbers cannot disagree with what a slice produces. A field you never touch shows the preset's value, and reverting returns to it. Where those values cannot be read the panel falls back to the slicer's own defaults and says why — a sidecar older than the feature, one that did not answer, or none configured at all — rather than presenting the defaults as if they were your preset's. The first is much the most likely, because the sidecar image is pulled independently of your Bambuddy version, so that message names the fix outright: update the sidecar image. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests.
|
||||
|
||||
### Changed
|
||||
- **The slice dialog's process and filament lists now leave out presets that belong to another printer** — They were already sorted by compatibility, but a preset for a different Bambu model still appeared, demoted to an "Other printers" group at the bottom of the dropdown. With a large cloud filament library that group is most of the list, so the filtering was doing little for the thing it was meant to help: finding the profile you actually want. Those presets are now held back, with the label reporting how many ("3 hidden") next to a **Show all** link that brings them back for that one dropdown. Two things are never hidden. A preset with no detectable printer — a custom or renamed profile — stays in the list, because absence of evidence is not evidence of incompatibility and hiding those would make people's own imported profiles vanish. And whatever is currently selected stays visible even when the list is collapsed, so a deliberate cross-printer pick, or one restored from a pipeline, is never silently discarded by being dropped from the options. Re-slicing for another printer remains fully supported, so this is a default view rather than a restriction. Fixing this also corrected a screen-reader bug in those dropdowns: the controls sat inside the label wrapping the select, which handed them the entire label as their spoken name. A separate defect surfaced alongside it — the filter did nothing at all when the selected printer was a preset you had edited, because BambuStudio names those copies with a leading "# " and the matcher did not know to look past it. On such a printer every preset read as "compatibility unknown", and profiles listing their compatible printers by name could be ruled out against the very printer they were cloned from.
|
||||
|
||||
@@ -563,40 +563,45 @@ async def get_preset_values(
|
||||
OrcaSlicer's published tree instead, which can disagree with what actually
|
||||
slices; showing numbers from it would be confidently wrong.
|
||||
|
||||
Returns ``{"resolved": false, "values": {}}`` rather than an error whenever
|
||||
the values can't be obtained (sidecar offline, too old for the endpoint, or
|
||||
slicing not configured). The panel then falls back to schema defaults and
|
||||
tells the user the values are indicative, which is a far better outcome
|
||||
than a modal that won't open.
|
||||
Returns ``{"resolved": false, "values": {}, "reason": "..."}`` rather than
|
||||
an error whenever the values can't be obtained. ``reason`` is what makes
|
||||
the fallback actionable: a Bambuddy install pulls its sidecar as
|
||||
``SIDECAR_TAG:-latest`` regardless of its own release channel, so the
|
||||
overwhelmingly common cause is a sidecar older than the endpoint — which
|
||||
the user fixes by pulling a newer image, if we tell them that instead of
|
||||
"could not read the values".
|
||||
"""
|
||||
if slot != "process":
|
||||
raise HTTPException(status_code=400, detail="Only the 'process' slot is supported")
|
||||
|
||||
ref = PresetRef(source=source, id=id)
|
||||
|
||||
def unresolved(reason: str) -> dict:
|
||||
return {"resolved": False, "values": {}, "reason": reason}
|
||||
|
||||
try:
|
||||
profile_json = await resolve_preset_ref(db, current_user, ref, slot)
|
||||
except HTTPException:
|
||||
# A preset the caller can't resolve is not a reason to break the panel;
|
||||
# the slice itself will report it properly if they go ahead.
|
||||
logger.info("Could not resolve %s preset %s for value lookup", slot, id)
|
||||
return {"resolved": False, "values": {}}
|
||||
return unresolved("preset_unresolved")
|
||||
|
||||
api_url = await _resolve_slicer_api_url(db)
|
||||
if not api_url:
|
||||
return {"resolved": False, "values": {}}
|
||||
return unresolved("not_configured")
|
||||
|
||||
service = SlicerApiService(api_url)
|
||||
try:
|
||||
values = await service.resolve_profile(profile_json, "process")
|
||||
resolved = await service.resolve_profile(profile_json, "process")
|
||||
except SlicerApiUnavailableError:
|
||||
return {"resolved": False, "values": {}}
|
||||
return unresolved("sidecar_unavailable")
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
if values is None:
|
||||
return {"resolved": False, "values": {}}
|
||||
return {"resolved": True, "values": values}
|
||||
if resolved.values is None:
|
||||
return unresolved(resolved.reason)
|
||||
return {"resolved": True, "values": resolved.values, "reason": "ok"}
|
||||
|
||||
|
||||
@router.get("/presets", response_model=UnifiedPresetsResponse)
|
||||
|
||||
@@ -54,6 +54,18 @@ class SlicerTimeoutError(SlicerApiError):
|
||||
"""
|
||||
|
||||
|
||||
class ResolvedProfile(NamedTuple):
|
||||
"""A preset's effective values, or why they are unavailable.
|
||||
|
||||
``reason`` is one of ``ok`` / ``sidecar_outdated`` / ``sidecar_unavailable``
|
||||
/ ``preset_unresolved``. It exists so the UI can say something actionable
|
||||
instead of one generic "could not read the values" for four causes.
|
||||
"""
|
||||
|
||||
values: dict | None
|
||||
reason: str
|
||||
|
||||
|
||||
class SliceResult(NamedTuple):
|
||||
"""Result of a slice operation."""
|
||||
|
||||
@@ -310,7 +322,7 @@ class SlicerApiService:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
|
||||
return response.json()
|
||||
|
||||
async def resolve_profile(self, profile_json: str, category: str) -> dict | None:
|
||||
async def resolve_profile(self, profile_json: str, category: str) -> "ResolvedProfile":
|
||||
"""POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
|
||||
|
||||
Returns the effective key/value map the slicer would actually use, so
|
||||
@@ -325,15 +337,19 @@ class SlicerApiService:
|
||||
the one baked into the running sidecar image — values from it would look
|
||||
authoritative and could quietly disagree with what gets sliced.
|
||||
|
||||
Returns ``None`` when the sidecar is too old to have the endpoint, so
|
||||
callers can degrade to schema defaults instead of failing the modal.
|
||||
Genuine transport failures still raise.
|
||||
Returns a :class:`ResolvedProfile` whose ``reason`` distinguishes *why*
|
||||
values are missing. That matters more than it looks: the common case in
|
||||
practice is a sidecar older than this endpoint, because a Bambuddy
|
||||
install pulls ``SIDECAR_TAG:-latest`` independently of its own release
|
||||
channel. "Could not read the values" sends that user hunting; "your
|
||||
sidecar image is older than this feature" is a one-line fix. Genuine
|
||||
transport failures still raise.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(profile_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Cannot resolve %s preset: content is not valid JSON", category)
|
||||
return None
|
||||
return ResolvedProfile(None, "preset_unresolved")
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
@@ -345,21 +361,25 @@ class SlicerApiService:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
|
||||
if response.status_code == 404:
|
||||
# Sidecar predates the endpoint. Not an error — the caller shows
|
||||
# schema defaults and says so.
|
||||
# Sidecar predates the endpoint. Not an error, and specifically not
|
||||
# the same as a broken one — this is the case that has a fix the
|
||||
# user can act on.
|
||||
logger.info("Slicer sidecar has no /profiles/resolve; falling back to schema defaults")
|
||||
return None
|
||||
return ResolvedProfile(None, "sidecar_outdated")
|
||||
if response.status_code >= 400:
|
||||
logger.warning(
|
||||
"Slicer sidecar /profiles/resolve returned %s: %s",
|
||||
response.status_code,
|
||||
_format_sidecar_error(response),
|
||||
)
|
||||
return None
|
||||
return ResolvedProfile(None, "sidecar_unavailable")
|
||||
|
||||
body = response.json()
|
||||
resolved = body.get("profile") if isinstance(body, dict) else None
|
||||
return resolved if isinstance(resolved, dict) else None
|
||||
if not isinstance(resolved, dict):
|
||||
logger.warning("Slicer sidecar /profiles/resolve returned no profile object")
|
||||
return ResolvedProfile(None, "sidecar_unavailable")
|
||||
return ResolvedProfile(resolved, "ok")
|
||||
|
||||
async def list_bundled_profiles(self) -> dict:
|
||||
"""GET /profiles/bundled — return the slicer's stock profiles by slot.
|
||||
|
||||
@@ -34,22 +34,28 @@ class TestResolveProfile:
|
||||
return httpx.Response(200, json={"profile": {"line_width": "0.42", "wall_loops": "2"}})
|
||||
|
||||
service = _service(handler)
|
||||
assert await service.resolve_profile(PROCESS_STUB, "process") == {
|
||||
"line_width": "0.42",
|
||||
"wall_loops": "2",
|
||||
}
|
||||
result = await service.resolve_profile(PROCESS_STUB, "process")
|
||||
assert result.values == {"line_width": "0.42", "wall_loops": "2"}
|
||||
assert result.reason == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_sidecar_without_the_endpoint_is_not_an_error(self):
|
||||
# Older images 404 here. The panel degrades to schema defaults and says
|
||||
# so; failing would make the modal unusable against an old sidecar.
|
||||
async def test_a_sidecar_without_the_endpoint_is_reported_as_outdated(self):
|
||||
# Older images 404 here. This is the dominant case in practice -- an
|
||||
# install pulls SIDECAR_TAG:-latest regardless of its own release
|
||||
# channel -- and it is the one with a fix the user can act on, so it
|
||||
# must not be flattened into the generic failure.
|
||||
service = _service(lambda request: httpx.Response(404, json={"message": "Not Found"}))
|
||||
assert await service.resolve_profile(PROCESS_STUB, "process") is None
|
||||
result = await service.resolve_profile(PROCESS_STUB, "process")
|
||||
assert result.values is None
|
||||
assert result.reason == "sidecar_outdated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_sidecar_error_degrades_rather_than_raising(self):
|
||||
async def test_a_sidecar_error_is_not_reported_as_outdated(self):
|
||||
# A broken sidecar and an old one call for different advice.
|
||||
service = _service(lambda request: httpx.Response(500, json={"message": "boom"}))
|
||||
assert await service.resolve_profile(PROCESS_STUB, "process") is None
|
||||
result = await service.resolve_profile(PROCESS_STUB, "process")
|
||||
assert result.values is None
|
||||
assert result.reason == "sidecar_unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_sidecar_still_raises(self):
|
||||
@@ -62,15 +68,19 @@ class TestResolveProfile:
|
||||
await _service(handler).resolve_profile(PROCESS_STUB, "process")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_preset_content_returns_none(self):
|
||||
async def test_unparseable_preset_content_blames_the_preset(self):
|
||||
service = _service(lambda request: httpx.Response(200, json={"profile": {}}))
|
||||
assert await service.resolve_profile("not json", "process") is None
|
||||
result = await service.resolve_profile("not json", "process")
|
||||
assert result.values is None
|
||||
assert result.reason == "preset_unresolved"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_response_without_a_profile_object_returns_none(self):
|
||||
async def test_a_response_without_a_profile_object_returns_no_values(self):
|
||||
# Guards against reading a differently-shaped body as if it were values.
|
||||
service = _service(lambda request: httpx.Response(200, json={"ok": True}))
|
||||
assert await service.resolve_profile(PROCESS_STUB, "process") is None
|
||||
result = await service.resolve_profile(PROCESS_STUB, "process")
|
||||
assert result.values is None
|
||||
assert result.reason == "sidecar_unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_already_flat_preset_round_trips(self):
|
||||
@@ -81,7 +91,7 @@ class TestResolveProfile:
|
||||
assert "inherits" not in body["profile"]
|
||||
return httpx.Response(200, json={"profile": json.loads(flat)})
|
||||
|
||||
assert await _service(handler).resolve_profile(flat, "process") == {
|
||||
assert (await _service(handler).resolve_profile(flat, "process")).values == {
|
||||
"line_width": "0.45",
|
||||
"type": "process",
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('SliceModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
|
||||
mockApi.getSliceJob.mockResolvedValue({
|
||||
job_id: 42,
|
||||
status: 'running',
|
||||
@@ -1559,7 +1559,7 @@ describe('SliceModal — process settings in "slice as designed" mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
|
||||
mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
|
||||
mockApi.getSlicerPrinterModels.mockResolvedValue({});
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue({
|
||||
@@ -1643,7 +1643,7 @@ describe('SliceModal — process settings layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
|
||||
mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
|
||||
mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
|
||||
mockApi.getLibraryFilePlates.mockResolvedValue({
|
||||
file_id: 100,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { render } from '../utils';
|
||||
import SlicerSettingsPanel, { type FilamentChoice } from '../../components/SlicerSettingsPanel';
|
||||
import type { SettingValue } from '../../types/slicerSettings';
|
||||
import type { DesignOverride } from '../../types/plates';
|
||||
import type { SlicerPresetValuesReason } from '../../api/client';
|
||||
|
||||
/**
|
||||
* The panel is a controlled component: it renders from the `values` prop and
|
||||
@@ -22,6 +23,7 @@ function Harness({
|
||||
filamentChoices,
|
||||
presetValues,
|
||||
presetValuesResolved,
|
||||
presetValuesReason,
|
||||
}: {
|
||||
initial: Record<string, SettingValue>;
|
||||
onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
|
||||
@@ -30,6 +32,7 @@ function Harness({
|
||||
filamentChoices?: FilamentChoice[];
|
||||
presetValues?: Record<string, SettingValue>;
|
||||
presetValuesResolved?: boolean;
|
||||
presetValuesReason?: SlicerPresetValuesReason;
|
||||
}) {
|
||||
const [values, setValues] = useState(initial);
|
||||
const [selected, setSelected] = useState(new Set(initialSelected ?? []));
|
||||
@@ -43,6 +46,7 @@ function Harness({
|
||||
filamentChoices={filamentChoices}
|
||||
presetValues={presetValues}
|
||||
presetValuesResolved={presetValuesResolved}
|
||||
presetValuesReason={presetValuesReason}
|
||||
sourceOverrides={sourceOverrides}
|
||||
sourceSelected={selected}
|
||||
onToggleSource={(key, on) =>
|
||||
@@ -66,6 +70,7 @@ async function renderPanel(
|
||||
filamentChoices?: FilamentChoice[];
|
||||
presetValues?: Record<string, SettingValue>;
|
||||
presetValuesResolved?: boolean;
|
||||
presetValuesReason?: SlicerPresetValuesReason;
|
||||
} = {},
|
||||
) {
|
||||
const onChange = vi.fn();
|
||||
@@ -447,6 +452,26 @@ describe('SlicerSettingsPanel — the picked preset\'s values', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Showing slicer defaults/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('names the fix when the sidecar predates the endpoint', async () => {
|
||||
// The dominant case: an install pulls SIDECAR_TAG:-latest regardless of
|
||||
// its own release channel, so a current Bambuddy against an old sidecar is
|
||||
// normal. A generic "could not be read" sends that user hunting.
|
||||
await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_outdated' });
|
||||
await waitFor(() => expect(screen.getByText(/Update the sidecar image/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('distinguishes a sidecar that is missing from one that is merely old', async () => {
|
||||
await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'not_configured' });
|
||||
await waitFor(() => expect(screen.getByText(/no slicer sidecar is configured/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('distinguishes a sidecar that did not answer', async () => {
|
||||
await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_unavailable' });
|
||||
await waitFor(() => expect(screen.getByText(/did not answer/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no such notice when they resolved', async () => {
|
||||
await renderPanel({}, { presetValues: { line_width: '0.42' }, presetValuesResolved: true });
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
|
||||
|
||||
@@ -1614,11 +1614,28 @@ export interface PresetRef {
|
||||
source: PresetSource;
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* Why a preset's effective values are unavailable.
|
||||
*
|
||||
* `sidecar_outdated` is the one that matters in practice: an install pulls its
|
||||
* sidecar as `SIDECAR_TAG:-latest` regardless of which Bambuddy channel it is
|
||||
* on, so a user can perfectly well be running a current Bambuddy against a
|
||||
* sidecar that predates this endpoint. That has a one-line fix, and saying so
|
||||
* beats a generic "could not read the values".
|
||||
*/
|
||||
export type SlicerPresetValuesReason =
|
||||
| 'ok'
|
||||
| 'sidecar_outdated'
|
||||
| 'sidecar_unavailable'
|
||||
| 'not_configured'
|
||||
| 'preset_unresolved';
|
||||
|
||||
export interface SlicerPresetValues {
|
||||
/** False when the sidecar could not supply values; `values` is then empty. */
|
||||
resolved: boolean;
|
||||
/** Flattened key -> value map, in the string forms a process preset stores. */
|
||||
values: Record<string, string | string[]>;
|
||||
reason: SlicerPresetValuesReason;
|
||||
}
|
||||
|
||||
export interface SliceRequest {
|
||||
|
||||
@@ -440,6 +440,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
// rather than blocking the panel on it.
|
||||
const presetValues = presetValuesQuery.data?.values as Record<string, SettingValue> | undefined;
|
||||
const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading;
|
||||
// A failed request (rather than a 'resolved: false' answer) means we
|
||||
// never reached the backend, which is the same situation as an
|
||||
// unreachable sidecar as far as the user is concerned.
|
||||
const presetValuesReason = presetValuesQuery.data?.reason ?? (presetValuesQuery.isError ? 'sidecar_unavailable' : undefined);
|
||||
|
||||
// Slot list for the settings panel's filament pickers (support base and
|
||||
// interface, and the Multimaterial page's per-region options). Those store a
|
||||
@@ -1051,6 +1055,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
filamentChoices={filamentChoices}
|
||||
presetValues={presetValues}
|
||||
presetValuesResolved={presetValuesResolved}
|
||||
presetValuesReason={presetValuesReason}
|
||||
sourceOverrides={designOverrides}
|
||||
sourceSelected={designKeys}
|
||||
onToggleSource={(key, on) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
|
||||
import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
|
||||
import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
|
||||
import type { DesignOverride } from '../types/plates';
|
||||
import type { SlicerPresetValuesReason } from '../api/client';
|
||||
|
||||
interface SlicerData {
|
||||
schema: ProcessSchema;
|
||||
@@ -81,6 +82,11 @@ interface Props {
|
||||
presetValues?: Record<string, SettingValue>;
|
||||
/** False when the preset's values could not be fetched. */
|
||||
presetValuesResolved?: boolean;
|
||||
/**
|
||||
* Why they could not be fetched, so the notice can name a fix. Left
|
||||
* unset while the fetch is still in flight.
|
||||
*/
|
||||
presetValuesReason?: SlicerPresetValuesReason;
|
||||
}
|
||||
|
||||
export interface FilamentChoice {
|
||||
@@ -123,6 +129,7 @@ export default function SlicerSettingsPanel({
|
||||
filamentChoices,
|
||||
presetValues,
|
||||
presetValuesResolved = true,
|
||||
presetValuesReason,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<SlicerData | null>(null);
|
||||
@@ -288,10 +295,25 @@ export default function SlicerSettingsPanel({
|
||||
|
||||
{!presetValuesResolved && (
|
||||
<p className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[0.7rem] text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
{t(
|
||||
'slicerSettings.presetValuesUnavailable',
|
||||
"Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
|
||||
)}
|
||||
{presetValuesReason === 'sidecar_outdated'
|
||||
? t(
|
||||
'slicerSettings.presetValuesOutdatedSidecar',
|
||||
"Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.",
|
||||
)
|
||||
: presetValuesReason === 'not_configured'
|
||||
? t(
|
||||
'slicerSettings.presetValuesNotConfigured',
|
||||
"Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.",
|
||||
)
|
||||
: presetValuesReason === 'sidecar_unavailable'
|
||||
? t(
|
||||
'slicerSettings.presetValuesSidecarUnavailable',
|
||||
"Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.",
|
||||
)
|
||||
: t(
|
||||
'slicerSettings.presetValuesUnavailable',
|
||||
"Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4279,6 +4279,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Es werden Slicer-Standardwerte angezeigt: Ihr Slicer-Sidecar ist älter als diese Funktion und kann die Werte eines Profils nicht liefern. Aktualisieren Sie das Sidecar-Image, um sie zu sehen. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
|
||||
presetValuesNotConfigured: 'Es werden Slicer-Standardwerte angezeigt: Es ist kein Slicer-Sidecar konfiguriert, daher können die Werte eines Profils nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
|
||||
presetValuesSidecarUnavailable: 'Es werden Slicer-Standardwerte angezeigt: Das Slicer-Sidecar hat nicht geantwortet, daher können die Werte eines Profils nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
|
||||
presetValuesUnavailable: 'Es werden Slicer-Standardwerte angezeigt: Die Werte des gewählten Profils konnten nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
|
||||
filamentDefault: 'Standard',
|
||||
fromFile: 'aus Datei',
|
||||
|
||||
@@ -4313,6 +4313,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: "Showing slicer defaults: your slicer sidecar is older than this feature and can't report a preset's values. Update the sidecar image to see them. Anything you don't change still uses the preset.",
|
||||
presetValuesNotConfigured: "Showing slicer defaults: no slicer sidecar is configured, so a preset's values can't be read. Anything you don't change still uses the preset.",
|
||||
presetValuesSidecarUnavailable: "Showing slicer defaults: the slicer sidecar did not answer, so a preset's values can't be read. Anything you don't change still uses the preset.",
|
||||
presetValuesUnavailable: "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
|
||||
filamentDefault: 'Default',
|
||||
fromFile: 'from file',
|
||||
|
||||
@@ -4281,6 +4281,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Se muestran los valores predeterminados del laminador: tu sidecar es más antiguo que esta función y no puede informar los valores de un perfil. Actualiza la imagen del sidecar para verlos. Todo lo que no cambies seguirá usando el perfil.',
|
||||
presetValuesNotConfigured: 'Se muestran los valores predeterminados del laminador: no hay ningún sidecar configurado, así que no se pueden leer los valores de un perfil. Todo lo que no cambies seguirá usando el perfil.',
|
||||
presetValuesSidecarUnavailable: 'Se muestran los valores predeterminados del laminador: el sidecar no respondió, así que no se pueden leer los valores de un perfil. Todo lo que no cambies seguirá usando el perfil.',
|
||||
presetValuesUnavailable: 'Se muestran los valores predeterminados del laminador: no se pudieron leer los del perfil seleccionado. Todo lo que no cambies seguirá usando el perfil.',
|
||||
filamentDefault: 'Predeterminado',
|
||||
fromFile: 'del archivo',
|
||||
|
||||
@@ -4268,6 +4268,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: "Valeurs par défaut du trancheur affichées : votre sidecar est plus ancien que cette fonctionnalité et ne peut pas fournir les valeurs d'un profil. Mettez à jour l'image du sidecar pour les voir. Tout ce que vous ne modifiez pas utilise toujours le profil.",
|
||||
presetValuesNotConfigured: "Valeurs par défaut du trancheur affichées : aucun sidecar n'est configuré, les valeurs d'un profil ne peuvent donc pas être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
|
||||
presetValuesSidecarUnavailable: "Valeurs par défaut du trancheur affichées : le sidecar n'a pas répondu, les valeurs d'un profil ne peuvent donc pas être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
|
||||
presetValuesUnavailable: "Valeurs par défaut du trancheur affichées : celles du profil choisi n'ont pas pu être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
|
||||
filamentDefault: 'Par défaut',
|
||||
fromFile: 'du fichier',
|
||||
|
||||
@@ -4267,6 +4267,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: "Sono mostrati i valori predefiniti dello slicer: il tuo sidecar è più vecchio di questa funzione e non può fornire i valori di un profilo. Aggiorna l'immagine del sidecar per vederli. Tutto ciò che non modifichi continua a usare il profilo.",
|
||||
presetValuesNotConfigured: 'Sono mostrati i valori predefiniti dello slicer: nessun sidecar è configurato, quindi i valori di un profilo non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
|
||||
presetValuesSidecarUnavailable: 'Sono mostrati i valori predefiniti dello slicer: il sidecar non ha risposto, quindi i valori di un profilo non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
|
||||
presetValuesUnavailable: 'Sono mostrati i valori predefiniti dello slicer: quelli del profilo scelto non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
|
||||
filamentDefault: 'Predefinito',
|
||||
fromFile: 'dal file',
|
||||
|
||||
@@ -4279,6 +4279,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'スライサーの既定値を表示しています。スライサーサイドカーがこの機能より古く、プリセットの値を取得できません。値を表示するにはサイドカーのイメージを更新してください。変更しない項目は引き続きプリセットの値が使われます。',
|
||||
presetValuesNotConfigured: 'スライサーの既定値を表示しています。スライサーサイドカーが設定されていないため、プリセットの値を読み取れません。変更しない項目は引き続きプリセットの値が使われます。',
|
||||
presetValuesSidecarUnavailable: 'スライサーの既定値を表示しています。スライサーサイドカーが応答しなかったため、プリセットの値を読み取れません。変更しない項目は引き続きプリセットの値が使われます。',
|
||||
presetValuesUnavailable: 'スライサーの既定値を表示しています。選択したプリセットの値を読み取れませんでした。変更しない項目は引き続きプリセットの値が使われます。',
|
||||
filamentDefault: '既定',
|
||||
fromFile: 'ファイル由来',
|
||||
|
||||
@@ -4070,6 +4070,9 @@ export default {
|
||||
},
|
||||
},
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 이 기능보다 오래되어 프리셋 값을 가져올 수 없습니다. 값을 보려면 사이드카 이미지를 업데이트하세요. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
|
||||
presetValuesNotConfigured: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 설정되지 않아 프리셋 값을 읽을 수 없습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
|
||||
presetValuesSidecarUnavailable: '슬라이서 기본값을 표시합니다. 슬라이서 사이드카가 응답하지 않아 프리셋 값을 읽을 수 없습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
|
||||
presetValuesUnavailable: '슬라이서 기본값을 표시합니다. 선택한 프리셋의 값을 읽을 수 없었습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
|
||||
filamentDefault: '기본값',
|
||||
fromFile: '파일에서',
|
||||
|
||||
@@ -4267,6 +4267,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Exibindo os padrões do fatiador: seu sidecar é mais antigo que este recurso e não consegue informar os valores de um perfil. Atualize a imagem do sidecar para vê-los. Tudo o que você não alterar continua usando o perfil.',
|
||||
presetValuesNotConfigured: 'Exibindo os padrões do fatiador: nenhum sidecar está configurado, portanto os valores de um perfil não podem ser lidos. Tudo o que você não alterar continua usando o perfil.',
|
||||
presetValuesSidecarUnavailable: 'Exibindo os padrões do fatiador: o sidecar não respondeu, portanto os valores de um perfil não podem ser lidos. Tudo o que você não alterar continua usando o perfil.',
|
||||
presetValuesUnavailable: 'Exibindo os padrões do fatiador: não foi possível ler os valores do perfil escolhido. Tudo o que você não alterar continua usando o perfil.',
|
||||
filamentDefault: 'Padrão',
|
||||
fromFile: 'do arquivo',
|
||||
|
||||
@@ -4062,6 +4062,9 @@ export default {
|
||||
},
|
||||
},
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Показаны значения по умолчанию слайсера: ваш sidecar старше этой функции и не может сообщить значения профиля. Обновите образ sidecar, чтобы увидеть их. Всё, что вы не измените, по-прежнему берётся из профиля.',
|
||||
presetValuesNotConfigured: 'Показаны значения по умолчанию слайсера: sidecar не настроен, поэтому значения профиля прочитать нельзя. Всё, что вы не измените, по-прежнему берётся из профиля.',
|
||||
presetValuesSidecarUnavailable: 'Показаны значения по умолчанию слайсера: sidecar не ответил, поэтому значения профиля прочитать нельзя. Всё, что вы не измените, по-прежнему берётся из профиля.',
|
||||
presetValuesUnavailable: 'Показаны значения по умолчанию слайсера: значения выбранного профиля прочитать не удалось. Всё, что вы не измените, по-прежнему берётся из профиля.',
|
||||
filamentDefault: 'По умолчанию',
|
||||
fromFile: 'из файла',
|
||||
|
||||
@@ -4268,6 +4268,9 @@ export default {
|
||||
|
||||
// Dilimle (SliceModal ile slicer-API entegrasyonu)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Dilimleyici varsayılanları gösteriliyor: dilimleyici sidecar bu özellikten eski olduğu için bir ön ayarın değerlerini bildiremiyor. Görmek için sidecar imajını güncelleyin. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
|
||||
presetValuesNotConfigured: 'Dilimleyici varsayılanları gösteriliyor: yapılandırılmış bir sidecar olmadığı için ön ayarın değerleri okunamıyor. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
|
||||
presetValuesSidecarUnavailable: 'Dilimleyici varsayılanları gösteriliyor: sidecar yanıt vermediği için ön ayarın değerleri okunamıyor. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
|
||||
presetValuesUnavailable: 'Dilimleyici varsayılanları gösteriliyor: seçilen ön ayarın kendi değerleri okunamadı. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
|
||||
filamentDefault: 'Varsayılan',
|
||||
fromFile: 'dosyadan',
|
||||
|
||||
@@ -4312,6 +4312,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: 'Показано типові значення слайсера: ваш sidecar старіший за цю функцію і не може повідомити значення профілю. Оновіть образ sidecar, щоб їх побачити. Усе, чого ви не змінюєте, і далі береться з профілю.',
|
||||
presetValuesNotConfigured: 'Показано типові значення слайсера: sidecar не налаштовано, тому значення профілю прочитати не можна. Усе, чого ви не змінюєте, і далі береться з профілю.',
|
||||
presetValuesSidecarUnavailable: 'Показано типові значення слайсера: sidecar не відповів, тому значення профілю прочитати не можна. Усе, чого ви не змінюєте, і далі береться з профілю.',
|
||||
presetValuesUnavailable: 'Показано типові значення слайсера: значення вибраного профілю не вдалося прочитати. Усе, чого ви не змінюєте, і далі береться з профілю.',
|
||||
filamentDefault: 'За замовчуванням',
|
||||
fromFile: 'з файлу',
|
||||
|
||||
@@ -4267,6 +4267,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: '当前显示切片器默认值:切片 sidecar 版本早于此功能,无法提供预设的实际值。更新 sidecar 镜像即可查看。未改动的项目仍使用预设。',
|
||||
presetValuesNotConfigured: '当前显示切片器默认值:未配置切片 sidecar,无法读取预设的实际值。未改动的项目仍使用预设。',
|
||||
presetValuesSidecarUnavailable: '当前显示切片器默认值:切片 sidecar 未响应,无法读取预设的实际值。未改动的项目仍使用预设。',
|
||||
presetValuesUnavailable: '当前显示切片器默认值:无法读取所选预设的实际值。未改动的项目仍使用预设。',
|
||||
filamentDefault: '默认',
|
||||
fromFile: '来自文件',
|
||||
|
||||
@@ -4267,6 +4267,9 @@ export default {
|
||||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesOutdatedSidecar: '目前顯示切片器預設值:切片 sidecar 版本早於此功能,無法提供預設的實際值。更新 sidecar 映像即可查看。未變更的項目仍使用預設。',
|
||||
presetValuesNotConfigured: '目前顯示切片器預設值:未設定切片 sidecar,無法讀取預設的實際值。未變更的項目仍使用預設。',
|
||||
presetValuesSidecarUnavailable: '目前顯示切片器預設值:切片 sidecar 未回應,無法讀取預設的實際值。未變更的項目仍使用預設。',
|
||||
presetValuesUnavailable: '目前顯示切片器預設值:無法讀取所選預設的實際值。未變更的項目仍使用預設。',
|
||||
filamentDefault: '預設',
|
||||
fromFile: '來自檔案',
|
||||
|
||||
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-i4ueH5ac.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D1NzqW4U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4VkH83v.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user