mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +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:
@@ -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