فهرست منبع

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.
maziggy 3 هفته پیش
والد
کامیت
3dc681d9f1

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


+ 101 - 3
backend/app/api/routes/library.py

@@ -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 - 0
backend/app/schemas/slicer.py

@@ -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):

+ 231 - 0
backend/tests/unit/test_slice_external_folder_output.py

@@ -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 - 0
frontend/src/api/client.ts

@@ -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 {

+ 17 - 0
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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 - 0
frontend/src/i18n/locales/ko.ts

@@ -4142,6 +4142,7 @@ export default {
     runningToast: '{{name}} 슬라이싱 중 — {{elapsed}}',
     runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
     completedToast: '{{name}} 슬라이싱 완료',
+    externalWriteFallbackToast: '내부 라이브러리에 저장했습니다: 외부 폴더에 쓸 수 없습니다',
     failedToast: '{{name}} 슬라이싱 실패: {{detail}}',
     tier: {
       local: '가져온 것',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/ru.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/uk.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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: {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-0f94D8BS.js


+ 1 - 1
static/index.html

@@ -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>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است