mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
fix(slicer): write slice output to the source's external folder (#2810)
slice_and_persist always wrote to get_library_files_dir() while giving the new row the source folder's id, so slicing a file on a NAS mount produced a .gcode.3mf that showed up in the right folder in the UI and never reached the share -- invisible from the web UI, which is why it did not reproduce. Resolve the destination from the target folder like uploads (#1112) and moves already do, set is_external and store the absolute path. Collisions uniquify to "Model (2).gcode.3mf": a 409 would throw away minutes of CPU on a routine re-slice, and overwriting a file on someone's NAS is worse. An external folder that cannot take the file (read-only, unreachable, not writable) falls back to managed storage rather than discarding the slice, and reports why on SliceResponse.external_write_fallback -- surfaced as a warning toast. Silent fallback is what made this bug invisible.
This commit is contained in:
@@ -27,6 +27,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
|
||||
|
||||
### Fixed
|
||||
- **Slicing a model stored on a network share wrote the result into Bambuddy's internal library instead of onto the share (#2810, reported by @zevulos)** — Server-side slicing files kept in an external folder produced a `.gcode.3mf` that appeared in the correct folder in the File Manager but never arrived in the folder itself, so it could not be opened from any other machine on the share. The output was always written to Bambuddy's own storage while the new entry was filed under the external folder, which is why the file looked present in the web interface and was missing everywhere else — and why it could not be reproduced without looking at the mount from outside Bambuddy. Uploads and moves already wrote to external folders correctly; slicing was the last path that did not, which is why moving a sliced file to another folder on the same share made it appear there. The result is now written next to its source, under a name that is readable on the share rather than an internal identifier. An existing file of the same name is never overwritten — a re-slice becomes `Model (2).gcode.3mf` — because the target is somebody's NAS and the file being replaced may not be Bambuddy's. If the external folder cannot accept the file at all, because it is mounted read-only, unreachable or not writable, the slice is kept rather than thrown away: it goes to the internal library as before, and now says so with a warning, since a file quietly filed somewhere you are not looking is the whole of this bug.
|
||||
- **A virtual printer's address spilled outside its card on the Virtual Printer page (#2808, reported by @underscan)** — The collapsed header of each virtual-printer card lays its details out in one row: name, mode, model, the printer it proxies to, and the IP addresses it is bound to. Every item in that row was marked as not shrinkable, so the row was always as wide as its contents, and the card does not clip what does not fit — the last IP address and the enable toggle were drawn outside the card's border, on the page background. The name was supposed to shorten with an ellipsis to prevent this, but could not: an item in a row like that will not shrink below its own text unless it is explicitly allowed to, so the ellipsis never appeared. It takes three things at once to run out of room, which is why it took a report to surface: both address fields are filled in only when Bambuddy and the printer are on different subnets (the reporter runs two VLANs), a printer adopted without a name is called "Printer at <address>", and the cards sit three to a row on a wide screen. The details now wrap onto a second line inside the card when they do not fit. Nothing is hidden or shortened away — these are the addresses an operator opens the page to check — and the card clips at its own border as a backstop.
|
||||
- **Restoring a SQLite backup into PostgreSQL failed part-way with a foreign-key violation on the file library** — The restore rebuilds the schema and then imports every table, and it is meant to create those tables carrying no foreign keys at all, so that the order rows arrive in cannot matter; the constraints go back on once everything has landed. It did that by taking the keys off the application's own schema description before creating the tables, which only suppresses the `REFERENCES` clause written inside each `CREATE TABLE`. SQLAlchemy keeps a second view of a table's keys, derived from its columns, and that one was untouched -- so whenever it met a group of tables whose dependencies form a loop and could not put them in order, it fell back to adding their keys afterwards with `ALTER TABLE`, reading them from exactly the view that still had them. `library_files`, `library_folders` and `print_archives` all point at one another, so twelve constraints came back across those three tables. The same loop also costs them their place in the import order, so they were imported alphabetically instead -- which puts `library_files` ahead of the `library_folders` rows its `folder_id` refers to, and PostgreSQL refused the very first batch. The restore now drops the foreign keys in the database itself, after the tables exist, instead of trying to stop them being written: it no longer matters how a constraint came to be there, and a future loop between other tables cannot bring the problem back. One more fault went with it -- the keys were stripped from a schema description shared by the whole running process and only restored after the rebuild had finished, so a failure in between left the process without them until it was restarted. Verified end to end against a real PostgreSQL: a backup whose child rows import before their parents now restores cleanly, with all ninety constraints in place afterwards. Separately, the warning about keys that genuinely cannot be put back -- because the backup itself holds rows pointing at something that no longer exists -- now names them by the columns they link, such as `print_archives(library_file_id) -> library_files.id`. These constraints have no name of their own, so the warning used to print `print_archives.None` once for each of that table's five keys, which said nothing about where to look; the offending value PostgreSQL reports is logged alongside it. The restore itself is unaffected, and so is the data -- those columns are simply left unenforced. Covered by backend tests.
|
||||
- **The slice dialog showed a guessed filament list for projects saved by a newer Bambu Studio than the slicer sidecar** — Opening the slice dialog on an unsliced project runs a quick preview slice, purely to ask the slicer which AMS slots the chosen plate actually consumes. Bambu Studio 2.8 writes a machine G-code template containing `{if timelapse_inline_photo}` but does not export a definition for that variable, so the template is unresolvable the moment it leaves Studio: a sidecar running an older build stops with a placeholder parse error before producing any slice data. The preview then returned nothing and the dialog quietly fell back to guessing from the file's painted faces, with no indication the numbers were an estimate. On the H2D project this was found with, the guess dropped a whole slot -- the support material -- from a four-filament plate. Bambuddy now retries the preview once with just that one unparsable template emptied, leaving every other setting in the file untouched, which is what keeps the answer honest: the process settings, support configuration and per-slot filament assignments are all still the project's own, so the filament list and its gram figures match what the file would really print. Only templates that cannot extrude are ever emptied -- a start or filament-change template lays a prime line or purges, so silencing one would change the very grams the preview reports, and Bambuddy would rather return nothing than a confident wrong number. Verified against a real H2D slice: the retry reproduces the full four-slot list, gram for gram. Covered by backend tests.
|
||||
|
||||
@@ -73,6 +73,7 @@ from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_miss
|
||||
from backend.app.services.process_overrides import apply_process_overrides
|
||||
from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
|
||||
from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
|
||||
from backend.app.utils.safe_path import PathTraversalError, safe_join_under
|
||||
from backend.app.utils.threemf_tools import (
|
||||
expand_to_project_slots,
|
||||
extract_embedded_presets_from_3mf,
|
||||
@@ -292,6 +293,82 @@ def _resolve_upload_destination(target_folder: LibraryFolder | None, filename: s
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}{ext}", False
|
||||
|
||||
|
||||
def _unique_external_name(ext_dir: Path, filename: str) -> str:
|
||||
"""Return ``filename``, or the first free ``<stem> (n)<suffix>`` variant.
|
||||
|
||||
Splits on the *compound* extension so re-slicing ``Bidoof.3mf`` yields
|
||||
``Bidoof (2).gcode.3mf`` rather than ``Bidoof.gcode (2).3mf``.
|
||||
|
||||
Uploads answer a name collision with a 409, which is right for a file the
|
||||
user just chose to send. A slice is not that: re-slicing the same source
|
||||
with different settings is routine, and the second run has already spent
|
||||
minutes of CPU by the time the name is known -- refusing to store it would
|
||||
throw that away. Overwriting is worse still, since the target is somebody's
|
||||
NAS and the file being replaced may not even be ours.
|
||||
"""
|
||||
stem = filename[: -len(".gcode.3mf")] if filename.endswith(".gcode.3mf") else Path(filename).stem
|
||||
suffix = ".gcode.3mf" if filename.endswith(".gcode.3mf") else Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 2
|
||||
# Bounded: a directory holding 999 re-slices of one model is pathological,
|
||||
# and an unbounded loop here would hang the request on a mount that lies
|
||||
# about exists() (some SMB shares do under contention).
|
||||
#
|
||||
# safe_join_under rather than `ext_dir / candidate`: `filename` derives
|
||||
# from a name read out of a 3MF, so the very first probe must not be able
|
||||
# to stat its way outside the mount. It raises PathTraversalError, which
|
||||
# the caller turns into a managed-storage fallback.
|
||||
while safe_join_under(ext_dir, candidate, http=False).exists() and counter < 1000:
|
||||
candidate = f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _resolve_slice_destination(target_folder: LibraryFolder | None, out_filename: str) -> tuple[Path, bool, str | None]:
|
||||
"""Resolve where a slice result should be written.
|
||||
|
||||
Returns ``(path, is_external, fallback_reason)``. ``fallback_reason`` is
|
||||
``None`` on the normal paths and otherwise names why an external folder
|
||||
could not receive the file, so the caller can tell the user instead of
|
||||
quietly filing it elsewhere.
|
||||
|
||||
Slicing a file that lives on an external mount used to store the output in
|
||||
the managed library dir unconditionally, while giving the new row the
|
||||
external folder's ``folder_id`` (#2810). The file therefore appeared in the
|
||||
right folder in the UI and never arrived on the share, which is the one
|
||||
place the user was looking -- and made it un-reproducible from the web UI
|
||||
alone. Uploads learned this in #1112 (``_resolve_upload_destination``) and
|
||||
moves in its follow-up (``_move_file_bytes``); slicing was the last write
|
||||
path still assuming managed storage.
|
||||
|
||||
Unlike uploads, a failure here does not raise. The bytes exist and cost
|
||||
real time to produce, so an unwritable target falls back to managed storage
|
||||
with a reason attached rather than discarding the slice.
|
||||
"""
|
||||
if target_folder is None or not target_folder.is_external:
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, None
|
||||
|
||||
if target_folder.external_readonly:
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_readonly"
|
||||
if not target_folder.external_path:
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_no_path"
|
||||
|
||||
ext_dir = Path(target_folder.external_path)
|
||||
if not ext_dir.exists() or not ext_dir.is_dir():
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_unreachable"
|
||||
if not os.access(ext_dir, os.W_OK):
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_not_writable"
|
||||
|
||||
try:
|
||||
dest = safe_join_under(ext_dir, _unique_external_name(ext_dir, out_filename), http=False)
|
||||
except PathTraversalError:
|
||||
# The source filename reached us from a 3MF on disk, so this is
|
||||
# defensive rather than expected -- but a name that escapes the mount
|
||||
# must land in managed storage, never outside it.
|
||||
return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_invalid_name"
|
||||
return dest, True, None
|
||||
|
||||
|
||||
def _stored_file_path(abs_path: Path, is_external: bool) -> str:
|
||||
"""Produce the value to persist in ``LibraryFile.file_path``.
|
||||
|
||||
@@ -4162,8 +4239,25 @@ async def slice_and_persist(
|
||||
|
||||
base_name = model_filename.rsplit(".", 1)[0]
|
||||
out_filename = f"{base_name}.gcode.3mf"
|
||||
unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
|
||||
out_path = get_library_files_dir() / unique_name # SEC-PATH-OK: unique_name = uuid.uuid4().hex + ".gcode.3mf"
|
||||
# Write next to the source when the source lives on an external mount
|
||||
# (#2810). The folder is loaded here rather than passed in because every
|
||||
# caller already has only the id.
|
||||
target_folder: LibraryFolder | None = None
|
||||
if folder_id is not None:
|
||||
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
target_folder = folder_result.scalar_one_or_none()
|
||||
out_path, out_is_external, external_fallback = _resolve_slice_destination(target_folder, out_filename)
|
||||
if out_is_external:
|
||||
# _unique_external_name may have suffixed it; the library row has to
|
||||
# show the name the file actually has on the share, or the two drift.
|
||||
out_filename = out_path.name
|
||||
if external_fallback:
|
||||
logger.warning(
|
||||
"Slice output for %s stored in managed library instead of external folder %s: %s",
|
||||
model_filename,
|
||||
target_folder.external_path if target_folder else None,
|
||||
external_fallback,
|
||||
)
|
||||
# BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
|
||||
# inject server-side so the library card has a thumbnail. Best-effort:
|
||||
# no-op when the slicer did embed thumbs (desktop Studio path), and
|
||||
@@ -4211,13 +4305,16 @@ async def slice_and_persist(
|
||||
)
|
||||
if used_embedded_settings:
|
||||
metadata["used_embedded_settings"] = True
|
||||
if external_fallback:
|
||||
metadata["external_write_fallback"] = external_fallback
|
||||
if extra_metadata:
|
||||
metadata.update(extra_metadata)
|
||||
|
||||
new_file = LibraryFile(
|
||||
folder_id=folder_id,
|
||||
is_external=out_is_external,
|
||||
filename=out_filename,
|
||||
file_path=to_relative_path(out_path),
|
||||
file_path=_stored_file_path(out_path, out_is_external),
|
||||
# The on-disk payload is a ZIP container — the file_type must
|
||||
# record that so the preview endpoint opens it as a 3MF instead
|
||||
# of returning the ZIP bytes as text/plain (#1709 / yanglei1980).
|
||||
@@ -4245,6 +4342,7 @@ async def slice_and_persist(
|
||||
filament_used_g=filament_g,
|
||||
filament_used_mm=filament_mm,
|
||||
used_embedded_settings=used_embedded_settings,
|
||||
external_write_fallback=external_fallback,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -211,6 +211,13 @@ class SliceResponse(BaseModel):
|
||||
filament_used_g: float
|
||||
filament_used_mm: float
|
||||
used_embedded_settings: bool = False
|
||||
# Set when the source lives in an external folder that could not receive
|
||||
# the result (read-only, unreachable, not writable), so the file went to
|
||||
# managed storage instead. Names which of those it was. ``None`` on every
|
||||
# normal slice. Reported rather than silently absorbed: filing the output
|
||||
# somewhere the user isn't looking, with no signal, is what made #2810
|
||||
# impossible to reproduce from the UI.
|
||||
external_write_fallback: str | None = None
|
||||
|
||||
|
||||
class SliceArchiveResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Slicing a file on an external mount writes the result to that mount (#2810).
|
||||
|
||||
Uploads learned to respect external folders in #1112 and moves in its
|
||||
follow-up; ``slice_and_persist`` was the last write path that still sent
|
||||
everything to managed storage. It kept giving the new row the external
|
||||
folder's ``folder_id``, so the sliced file appeared in the right folder in the
|
||||
File Manager while the share it was supposed to land on stayed empty -- which
|
||||
is why the bug could not be reproduced from the web UI at all.
|
||||
|
||||
The fallback cases matter as much as the happy path. A slice costs minutes of
|
||||
CPU, so an unwritable mount must not throw the bytes away; it stores them in
|
||||
the managed library and *says so*, because filing the output somewhere the user
|
||||
is not looking with no signal is the failure this issue was made of.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.library import (
|
||||
_resolve_slice_destination,
|
||||
_unique_external_name,
|
||||
slice_and_persist,
|
||||
)
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.schemas.slicer import SliceRequest
|
||||
from backend.app.services.slicer_api import SliceResult
|
||||
|
||||
|
||||
def _external_folder(path: Path, *, readonly: bool = False) -> LibraryFolder:
|
||||
return LibraryFolder(
|
||||
name="NAS",
|
||||
parent_id=None,
|
||||
is_external=True,
|
||||
external_path=str(path),
|
||||
external_readonly=readonly,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveSliceDestination:
|
||||
def test_managed_folder_keeps_the_uuid_name(self, tmp_path):
|
||||
folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
|
||||
|
||||
path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
|
||||
|
||||
assert is_external is False
|
||||
assert fallback is None
|
||||
# Managed storage is content-addressed by uuid: the display name lives
|
||||
# on the DB row, so two files of the same name can coexist.
|
||||
assert path.name.endswith(".gcode.3mf")
|
||||
assert path.name != "Bidoof.gcode.3mf"
|
||||
|
||||
def test_no_folder_at_all_is_managed(self):
|
||||
path, is_external, fallback = _resolve_slice_destination(None, "Bidoof.gcode.3mf")
|
||||
|
||||
assert is_external is False
|
||||
assert fallback is None
|
||||
assert path.name.endswith(".gcode.3mf")
|
||||
|
||||
def test_writable_external_folder_gets_the_real_filename(self, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
|
||||
path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
|
||||
|
||||
assert is_external is True
|
||||
assert fallback is None
|
||||
# The point of the whole fix: next to the source, under a name a human
|
||||
# can find on the share.
|
||||
assert path == mount / "Bidoof.gcode.3mf"
|
||||
|
||||
def test_read_only_mount_falls_back_instead_of_failing(self, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
|
||||
path, is_external, fallback = _resolve_slice_destination(
|
||||
_external_folder(mount, readonly=True), "Bidoof.gcode.3mf"
|
||||
)
|
||||
|
||||
assert is_external is False
|
||||
assert fallback == "external_readonly"
|
||||
assert path.parent != mount
|
||||
|
||||
def test_vanished_mount_falls_back(self, tmp_path):
|
||||
missing = tmp_path / "unplugged-nas" # deliberately not created
|
||||
|
||||
_path, is_external, fallback = _resolve_slice_destination(_external_folder(missing), "Bidoof.gcode.3mf")
|
||||
|
||||
assert is_external is False
|
||||
assert fallback == "external_unreachable"
|
||||
|
||||
def test_folder_with_no_path_configured_falls_back(self):
|
||||
folder = LibraryFolder(name="NAS", parent_id=None, is_external=True, external_path=None)
|
||||
|
||||
_path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
|
||||
|
||||
assert is_external is False
|
||||
assert fallback == "external_no_path"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the write bit")
|
||||
def test_unwritable_mount_falls_back(self, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
mount.chmod(0o500)
|
||||
try:
|
||||
_path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
|
||||
finally:
|
||||
mount.chmod(0o700)
|
||||
|
||||
assert is_external is False
|
||||
assert fallback == "external_not_writable"
|
||||
|
||||
def test_a_name_that_escapes_the_mount_lands_in_managed_storage(self, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
|
||||
path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "../escaped.gcode.3mf")
|
||||
|
||||
# Never write outside the configured mount, whatever the name claims.
|
||||
assert is_external is False
|
||||
assert fallback == "external_invalid_name"
|
||||
assert path.parent.resolve() != tmp_path.resolve()
|
||||
|
||||
|
||||
class TestUniqueExternalName:
|
||||
def test_free_name_is_used_as_is(self, tmp_path):
|
||||
assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof.gcode.3mf"
|
||||
|
||||
def test_collision_suffixes_before_the_compound_extension(self, tmp_path):
|
||||
(tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first slice")
|
||||
|
||||
# Not "Bidoof.gcode (2).3mf" -- the whole ".gcode.3mf" is the extension
|
||||
# and splitting it would produce a name the printer path won't accept.
|
||||
assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (2).gcode.3mf"
|
||||
|
||||
def test_it_keeps_counting_past_the_first_collision(self, tmp_path):
|
||||
(tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first")
|
||||
(tmp_path / "Bidoof (2).gcode.3mf").write_bytes(b"second")
|
||||
|
||||
assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (3).gcode.3mf"
|
||||
|
||||
def test_re_slicing_never_overwrites_what_is_already_on_the_share(self, tmp_path):
|
||||
(tmp_path / "Bidoof.gcode.3mf").write_bytes(b"do not lose me")
|
||||
|
||||
chosen = _unique_external_name(tmp_path, "Bidoof.gcode.3mf")
|
||||
|
||||
assert (tmp_path / chosen).exists() is False
|
||||
assert (tmp_path / "Bidoof.gcode.3mf").read_bytes() == b"do not lose me"
|
||||
|
||||
|
||||
class TestSliceAndPersistWritesToTheMount:
|
||||
"""End to end through ``slice_and_persist`` with the slicer stubbed out."""
|
||||
|
||||
@staticmethod
|
||||
def _patched_slicer(content: bytes = b"PK\x03\x04 not-a-real-3mf"):
|
||||
return patch(
|
||||
"backend.app.api.routes.library._run_slicer_with_fallback",
|
||||
AsyncMock(return_value=(SliceResult(content, 3600, 12.5, 4200.0), False)),
|
||||
)
|
||||
|
||||
async def _slice_into(self, db_session, folder: LibraryFolder):
|
||||
db_session.add(folder)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(folder)
|
||||
|
||||
with self._patched_slicer():
|
||||
response = await slice_and_persist(
|
||||
db_session,
|
||||
model_bytes=b"source model",
|
||||
model_filename="Bidoof.3mf",
|
||||
folder_id=folder.id,
|
||||
extra_metadata=None,
|
||||
request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
|
||||
current_user_id=None,
|
||||
)
|
||||
file_row = await db_session.get(LibraryFile, response.library_file_id)
|
||||
return response, file_row
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_bytes_land_on_the_share(self, db_session, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
|
||||
response, file_row = await self._slice_into(db_session, _external_folder(mount))
|
||||
|
||||
assert (mount / "Bidoof.gcode.3mf").exists()
|
||||
assert response.external_write_fallback is None
|
||||
# The row has to agree with the disk, or the next move/scan/delete
|
||||
# works on a path that isn't there.
|
||||
assert file_row.is_external is True
|
||||
assert file_row.file_path == str(mount / "Bidoof.gcode.3mf")
|
||||
assert file_row.filename == "Bidoof.gcode.3mf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_row_records_the_suffixed_name_on_a_collision(self, db_session, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
(mount / "Bidoof.gcode.3mf").write_bytes(b"an earlier slice")
|
||||
|
||||
_response, file_row = await self._slice_into(db_session, _external_folder(mount))
|
||||
|
||||
assert file_row.filename == "Bidoof (2).gcode.3mf"
|
||||
assert file_row.file_path == str(mount / "Bidoof (2).gcode.3mf")
|
||||
assert (mount / "Bidoof.gcode.3mf").read_bytes() == b"an earlier slice"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_managed_folder_is_unaffected(self, db_session, tmp_path):
|
||||
folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
|
||||
|
||||
response, file_row = await self._slice_into(db_session, folder)
|
||||
|
||||
assert response.external_write_fallback is None
|
||||
assert file_row.is_external is False
|
||||
# Managed rows stay relative to base_dir so the install stays portable.
|
||||
assert not Path(file_row.file_path).is_absolute()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_read_only_mount_still_yields_a_usable_file_and_says_why(self, db_session, tmp_path):
|
||||
mount = tmp_path / "share"
|
||||
mount.mkdir()
|
||||
|
||||
response, file_row = await self._slice_into(db_session, _external_folder(mount, readonly=True))
|
||||
|
||||
# Minutes of slicing must not be discarded because the mount is
|
||||
# read-only -- but the user has to learn where the file went.
|
||||
assert response.external_write_fallback == "external_readonly"
|
||||
assert file_row.is_external is False
|
||||
assert (file_row.file_metadata or {}).get("external_write_fallback") == "external_readonly"
|
||||
assert list(mount.iterdir()) == []
|
||||
@@ -1860,6 +1860,11 @@ export interface SliceResponse {
|
||||
filament_used_g: number;
|
||||
filament_used_mm: number;
|
||||
used_embedded_settings: boolean;
|
||||
/** Why the result could not be written to the external folder the source
|
||||
* lives in, and so went to managed storage instead. Null on every normal
|
||||
* slice. Surfaced to the user — a file filed somewhere they aren't looking
|
||||
* with no signal is what made #2810 invisible from the UI. */
|
||||
external_write_fallback?: string | null;
|
||||
}
|
||||
|
||||
export interface SliceArchiveResponse {
|
||||
|
||||
@@ -197,6 +197,23 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
|
||||
t('slice.completedToast', 'Sliced {{name}}', { name: prettifyFilename(job.sourceName) }),
|
||||
'success',
|
||||
);
|
||||
// The result normally lands next to its source, including on an
|
||||
// external mount. When the mount can't take it the file is still
|
||||
// kept — in managed storage — but the user has to be told, or they
|
||||
// go looking on the share and find nothing (#2810).
|
||||
const fallback =
|
||||
state.result && 'external_write_fallback' in state.result
|
||||
? state.result.external_write_fallback
|
||||
: null;
|
||||
if (fallback) {
|
||||
showToast(
|
||||
t(
|
||||
'slice.externalWriteFallbackToast',
|
||||
'Saved to the internal library: the external folder could not be written to',
|
||||
),
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
} else if (state.status === 'failed') {
|
||||
setSliceError({
|
||||
name: prettifyFilename(job.sourceName),
|
||||
|
||||
@@ -4356,6 +4356,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Plate {{plateIndex}} von {{plateCount}} • {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
|
||||
completedToast: '{{name}} wurde gesliced',
|
||||
externalWriteFallbackToast: 'In der internen Bibliothek gespeichert: In den externen Ordner konnte nicht geschrieben werden',
|
||||
failedTitle: 'Slicen fehlgeschlagen',
|
||||
failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4390,6 +4390,7 @@ export default {
|
||||
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Plate {{plateIndex}} of {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
||||
completedToast: 'Sliced {{name}}',
|
||||
externalWriteFallbackToast: 'Saved to the internal library: the external folder could not be written to',
|
||||
failedTitle: 'Slicing failed',
|
||||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4358,6 +4358,7 @@ export default {
|
||||
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Bandeja {{plateIndex}} de {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
||||
completedToast: '{{name}} laminado',
|
||||
externalWriteFallbackToast: 'Guardado en la biblioteca interna: no se pudo escribir en la carpeta externa',
|
||||
failedTitle: 'Error al laminar',
|
||||
failedToast: 'Error al laminar {{name}}: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4345,6 +4345,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Plateau {{plateIndex}} sur {{plateCount}} • {{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
|
||||
completedToast: '{{name}} découpé',
|
||||
externalWriteFallbackToast: 'Enregistré dans la bibliothèque interne : impossible d\'écrire dans le dossier externe',
|
||||
failedTitle: 'Échec du découpage',
|
||||
failedToast: 'Échec du découpage de {{name}} : {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4344,6 +4344,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Piatto {{plateIndex}} di {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '{{name}} sezionato',
|
||||
externalWriteFallbackToast: 'Salvato nella libreria interna: impossibile scrivere nella cartella esterna',
|
||||
failedTitle: 'Slicing fallito',
|
||||
failedToast: 'Slicing di {{name}} fallito: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4356,6 +4356,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'プレート {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '{{name}}をスライス済み',
|
||||
externalWriteFallbackToast: '内部ライブラリに保存しました: 外部フォルダーに書き込めませんでした',
|
||||
failedTitle: 'スライスに失敗しました',
|
||||
failedToast: '{{name}}のスライスに失敗: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4142,6 +4142,7 @@ export default {
|
||||
runningToast: '{{name}} 슬라이싱 중 — {{elapsed}}',
|
||||
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
||||
completedToast: '{{name}} 슬라이싱 완료',
|
||||
externalWriteFallbackToast: '내부 라이브러리에 저장했습니다: 외부 폴더에 쓸 수 없습니다',
|
||||
failedToast: '{{name}} 슬라이싱 실패: {{detail}}',
|
||||
tier: {
|
||||
local: '가져온 것',
|
||||
|
||||
@@ -4344,6 +4344,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: 'Bandeja {{plateIndex}} de {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '{{name}} fatiado',
|
||||
externalWriteFallbackToast: 'Salvo na biblioteca interna: não foi possível gravar na pasta externa',
|
||||
failedTitle: 'Falha ao fatiar',
|
||||
failedToast: 'Falha ao fatiar {{name}}: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4139,6 +4139,7 @@ export default {
|
||||
runningWithProgress: "{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
|
||||
runningWithProgressMultiPlate: "Пластина {{plateIndex}} из {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
|
||||
completedToast: "Нарезка {{name}} завершена",
|
||||
externalWriteFallbackToast: "Сохранено во внутренней библиотеке: не удалось записать во внешнюю папку",
|
||||
failedTitle: "Ошибка нарезки",
|
||||
failedToast: "Не удалось нарезать {{name}}: {{detail}}",
|
||||
tier: {
|
||||
|
||||
@@ -4345,6 +4345,7 @@ export default {
|
||||
runningWithProgress: '{{name}} — {{stage}} (%{{percent}}) — {{elapsed}}',
|
||||
runningWithProgressMultiPlate: '{{plateCount}}/{{plateIndex}} plaka • {{name}} — {{stage}} (%{{percent}}) — {{elapsed}}',
|
||||
completedToast: '{{name}} dilimlendi',
|
||||
externalWriteFallbackToast: 'Dahili kitaplığa kaydedildi: harici klasöre yazılamadı',
|
||||
failedTitle: 'Dilimleme başarısız',
|
||||
failedToast: '{{name}} dilimleme başarısız: {{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4389,6 +4389,7 @@ export default {
|
||||
runningWithProgress: "{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
|
||||
runningWithProgressMultiPlate: "Пластина {{plateIndex}} з {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}",
|
||||
completedToast: "Нарізання {{name}} завершено",
|
||||
externalWriteFallbackToast: "Збережено у внутрішній бібліотеці: не вдалося записати в зовнішню теку",
|
||||
failedTitle: "Помилка нарізання",
|
||||
failedToast: "Помилка нарізання {{name}}: {{detail}}",
|
||||
tier: {
|
||||
|
||||
@@ -4344,6 +4344,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: '盘面 {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '已切片 {{name}}',
|
||||
externalWriteFallbackToast: '已保存到内部库:无法写入外部文件夹',
|
||||
failedTitle: '切片失败',
|
||||
failedToast: '切片 {{name}} 失败:{{detail}}',
|
||||
tier: {
|
||||
|
||||
@@ -4344,6 +4344,7 @@ export default {
|
||||
runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
runningWithProgressMultiPlate: '盤面 {{plateIndex}} / {{plateCount}} • {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
|
||||
completedToast: '已切片 {{name}}',
|
||||
externalWriteFallbackToast: '已儲存到內部庫:無法寫入外部資料夾',
|
||||
failedTitle: '切片失敗',
|
||||
failedToast: '切片 {{name}} 失敗:{{detail}}',
|
||||
tier: {
|
||||
|
||||
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-DhpDphGT.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-0f94D8BS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BkuH4t27.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user