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:
maziggy
2026-08-15 14:53:46 +02:00
20 changed files with 389 additions and 18 deletions
+101 -3
View File
@@ -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,
)
+7
View File
@@ -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()) == []
+5
View File
@@ -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),
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -4142,6 +4142,7 @@ export default {
runningToast: '{{name}} 슬라이싱 중 — {{elapsed}}',
runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
completedToast: '{{name}} 슬라이싱 완료',
externalWriteFallbackToast: '내부 라이브러리에 저장했습니다: 외부 폴더에 쓸 수 없습니다',
failedToast: '{{name}} 슬라이싱 실패: {{detail}}',
tier: {
local: '가져온 것',
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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
View File
@@ -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>