diff --git a/CHANGELOG.md b/CHANGELOG.md index 12586326f..a586453f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. +- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green. + - **Drag-reorder for grouped queue items + collapsed-batch no longer blocks adjacent rows** — Print queue batch grouping (multi-plate auto-batch + manual "Group as batch") shipped with an intentional v1 limitation: the batch parent row had no drag handle and wasn't registered with the SortableContext, so a batch was stuck at whatever position it was created at. Worse, a collapsed batch acted as an unmovable obstacle that adjacent standalone items couldn't drag cleanly past. The only workaround was to ungroup, reorder, and re-group. **Now.** Batch parents register in the SortableContext under a synthetic `batch-` string id, and the parent header carries a real `GripVertical` drag handle next to its existing checkbox + collapse chevron (gated by `queue:reorder` like the per-item handle). `handleDragEnd` learned to resolve both batch ids: when the dragged source is a batch, the moving block is all of its child items in their current order; when the drop target is a batch, the anchor is the batch's first child so the group lands immediately before it. Direction-aware insertion (dragging downward inserts after the target) uses the first moving id's index instead of the previously hard-coded single dragged id, which keeps multi-row and batch drags converging on the same insert math. Within-batch reorder of expanded children is unchanged — they're still individually `useSortable`-registered and the per-row drag handle still works. The `DragOverlay` gets a second branch for batch drags showing ` ( copies)` with a Package icon, so the user sees what they're moving. **i18n.** 2 new keys (`queue.batch.dragGroup` button title + `queue.dragGhost.batch` / `..._plural` overlay text), real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5235 leaves per locale, no English fallback. The FR plural is a legitimate cognate (French plural of "copie" is also "copies") so it's pinned in `FR_COGNATES` rather than fake-translated. **Build + tests.** Full QueuePage vitest 26/26 green; `npm run build` clean; ESLint clean; i18n parity green. - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** — The single global `ams_humidity_fair` knob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (`drying_presets` JSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. **New setting** `ams_humidity_thresholds` — JSON map of filament type to threshold percent, with an explicit `"default"` key for unknown / unmapped types. Empty / unset → both consumers fall back to the existing `ams_humidity_fair` value so the upgrade is silent (no behaviour change until the user opens the editor). **Mixed-AMS resolution: most-restrictive wins**, matching the conservative-params strategy `_get_conservative_drying_params` already uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to the `default` key. **Two consumer sites rewired in lockstep** via a single new `PrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)` static method: (1) `print_scheduler.py::_check_auto_drying` — the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2) `main.py` AMS sensor / alarm worker — the hourly notifier that fires `on_ams_humidity_high` / `on_ams_ht_humidity_high`. Both sites read the same `ams_humidity_thresholds` setting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." **UI.** New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's current `ams_humidity_fair` so the editor starts in a sensible state). Numeric inputs use a **draft-on-edit / commit-on-blur** pattern (transient `humidityDrafts: Record` state per row) so intermediate strings like `""`, `"3"`, `"7"` aren't eaten by the `[5, 95]` clamp while the user is mid-typing. The clamp fires once on `onBlur` (and `Enter` blurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (or `ams_humidity_fair` for the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing `30` into a field showing `60` snapped to `5` between keystrokes because `parseInt("3") | clamp([5, 95])` resolved to `5` before the user finished typing the second digit. **i18n.** Four new keys in the `settings.*` namespace (`humidityThresholds`, `humidityThresholdsDescription`, `humidityThresholdCol`, `humidityThresholdDefault`) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. **Tests.** 12 new backend cases in `test_scheduler_auto_drying.py::TestResolveHumidityThreshold` + `TestGetHumidityThresholds` — empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses the `default` key (not the caller fallback), empty tray slots skipped, all-empty trays use `default`, "PLA Basic" / "pla basic" both normalize to PLA, missing `tray_type` field is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literal `default` key). One updated integration test in `test_settings_ui_preferences.py` pins the new field in the public `_UI_PREFERENCE_FIELDS` allowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case in `SettingsPage.test.tsx` exercises the editor render on the Workflow tab. **Setting is in the public `/ui-preferences` allowlist** because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without granting `SETTINGS_READ` (same rationale as `drying_presets` and `ams_humidity_fair`). **What this does NOT do.** Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills from `ams_humidity_fair` and lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backend `pytest -n 30` 6270/6270 in 73 s; ruff clean; `npm run build` clean; ESLint clean; i18n parity green. @@ -36,6 +38,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **File Manager list-view column headers misaligned with their body cells** — Both the header row and each list row used the same `grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]` template — looked correct at the CSS level — but the two `
`s were **sibling grids**, not a shared grid, so each computed `min-content` for the trailing actions column independently. The header's trailing column is an empty `
` → `min-content` resolved to 0; body rows had 4–7 action icons → `min-content` resolved to ~220px. With different trailing widths, the `1fr` Name column got a different amount of room in each grid, which pushed every fixed column to its right (`Uploaded By`, `Type`, `Size`, `Prints`) further right in the header than in the body. Visually the body cells looked **shifted left** of their column headers. **Fix.** Replaced the trailing `min-content` with a fixed `220px` in both the auth-enabled and auth-disabled grid templates (matching the comment that already documented the expected width of the 7-icon strip on sliced 3MFs). Updated the explanatory comment with the sibling-grid pitfall so the next person doesn't re-introduce it. No tests changed; the misalignment was purely visual (no DOM ordering / interaction changed), and the existing 51 FileManagerPage tests stay green. + - **Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by @biduleman)** — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. **Bug A — firmware reset of `total_layer_num`.** `bambu_mqtt.py:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later, `state.total_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-all-to-last fallback.** The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (`total_layer_num`-based), and "remainder" (the last segment always gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. **Fix A — `bambu_mqtt.py`.** Mirror the existing `_last_valid_layer_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the `_handle_print_start` block at line ~3132 (right next to the existing `state.layer_num = 0`) so the previous print's total still can't bleed into the next one before its first real push arrives. **Fix B — `usage_tracker.py`.** Cascade the linear-fallback denominator: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. **Tests.** 5 new backend cases. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset` — the reporter's exact 260 g / 180 g split scenario with `state.total_layers=0` and `last_layer_num=260`, asserts 180.0 / 80.0 g attribution. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all` — both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour. `test_bambu_mqtt.py::TestTotalLayersPreservation` (× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so the new cascade is dormant for them. **Scope check — what this does NOT change.** The precise per-layer 3MF branch (`extract_layer_filament_usage_from_3mf` returns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (`len(tray_changes) > 1` gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. **One intentional semantic shift worth flagging:** `state.total_layers` now persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in `_handle_print_start` (line ~3135, sibling to the existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across the backend (`main.py`'s first-layer notification, `metrics.py`'s Prometheus gauge, `spoolman_tracking.py`'s progress estimator, `printers.py`'s REST response, `mqtt_relay.py`'s relay payload, `printer_manager.py`'s WS payload, the finish-photo-moment trigger at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backend `pytest -n 30` 6222/6222 in 94 s; ruff clean. - **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 6f8ff341a..6ad88ac55 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -750,11 +750,24 @@ async def list_folders( ) file_counts = dict(file_counts_result.all()) + # Latest immediate-child file activity per folder (#1770). Sibling of the + # file_counts subquery — same WHERE clause, MAX(updated_at) instead of + # COUNT(id). Subfolder descent is not aggregated here; the frontend's + # "sort by recent activity" mode is satisfied by immediate-parent bubble. + latest_file_activity_result = await db.execute( + select(LibraryFile.folder_id, func.max(LibraryFile.updated_at)) + .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None)) + .group_by(LibraryFile.folder_id) + ) + latest_file_activity = dict(latest_file_activity_result.all()) + # Build tree structure folder_map = {} root_folders = [] for folder, project_name, archive_name in rows: + latest_file = latest_file_activity.get(folder.id) + latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at folder_item = FolderTreeItem( id=folder.id, name=folder.name, @@ -767,6 +780,7 @@ async def list_folders( external_path=folder.external_path, external_readonly=folder.external_readonly, file_count=file_counts.get(folder.id, 0), + latest_activity_at=latest_activity_at, children=[], ) folder_map[folder.id] = folder_item @@ -804,14 +818,19 @@ async def get_folders_by_project( folders = [] for folder, project_name in rows: - # Get file count - file_count_result = await db.execute( - select(func.count(LibraryFile.id)).where( + # Get file count + latest file activity (#1770) in one trip + agg_result = await db.execute( + select( + func.count(LibraryFile.id), + func.max(LibraryFile.updated_at), + ).where( LibraryFile.folder_id == folder.id, LibraryFile.deleted_at.is_(None), ) ) - file_count = file_count_result.scalar() or 0 + file_count, latest_file = agg_result.one() + file_count = file_count or 0 + latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at folders.append( FolderResponse( @@ -827,6 +846,7 @@ async def get_folders_by_project( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=file_count, + latest_activity_at=latest_activity_at, created_at=folder.created_at, updated_at=folder.updated_at, ) @@ -857,14 +877,19 @@ async def get_folders_by_archive( folders = [] for folder, archive_name in rows: - # Get file count - file_count_result = await db.execute( - select(func.count(LibraryFile.id)).where( + # Get file count + latest file activity (#1770) in one trip + agg_result = await db.execute( + select( + func.count(LibraryFile.id), + func.max(LibraryFile.updated_at), + ).where( LibraryFile.folder_id == folder.id, LibraryFile.deleted_at.is_(None), ) ) - file_count = file_count_result.scalar() or 0 + file_count, latest_file = agg_result.one() + file_count = file_count or 0 + latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at folders.append( FolderResponse( @@ -880,6 +905,7 @@ async def get_folders_by_archive( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=file_count, + latest_activity_at=latest_activity_at, created_at=folder.created_at, updated_at=folder.updated_at, ) @@ -943,6 +969,9 @@ async def create_folder( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=0, + # New folder has no files yet — fall back to the folder's own + # updated_at so this matches the list-route semantics (#1770). + latest_activity_at=folder.updated_at, created_at=folder.created_at, updated_at=folder.updated_at, ) @@ -973,14 +1002,19 @@ async def get_folder( folder, project_name, archive_name = row - # Get file count - file_count_result = await db.execute( - select(func.count(LibraryFile.id)).where( + # Get file count + latest file activity (#1770) in one trip + agg_result = await db.execute( + select( + func.count(LibraryFile.id), + func.max(LibraryFile.updated_at), + ).where( LibraryFile.folder_id == folder_id, LibraryFile.deleted_at.is_(None), ) ) - file_count = file_count_result.scalar() or 0 + file_count, latest_file = agg_result.one() + file_count = file_count or 0 + latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at return FolderResponse( id=folder.id, @@ -995,6 +1029,7 @@ async def get_folder( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=file_count, + latest_activity_at=latest_activity_at, created_at=folder.created_at, updated_at=folder.updated_at, ) @@ -1064,14 +1099,19 @@ async def update_folder( await db.commit() await db.refresh(folder) - # Get file count and names - file_count_result = await db.execute( - select(func.count(LibraryFile.id)).where( + # Get file count + latest file activity (#1770) and names + agg_result = await db.execute( + select( + func.count(LibraryFile.id), + func.max(LibraryFile.updated_at), + ).where( LibraryFile.folder_id == folder_id, LibraryFile.deleted_at.is_(None), ) ) - file_count = file_count_result.scalar() or 0 + file_count, latest_file = agg_result.one() + file_count = file_count or 0 + latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at # Get project and archive names project_name = None @@ -1096,6 +1136,7 @@ async def update_folder( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=file_count, + latest_activity_at=latest_activity_at, created_at=folder.created_at, updated_at=folder.updated_at, ) @@ -1365,6 +1406,9 @@ async def create_external_folder( external_readonly=folder.external_readonly, external_show_hidden=folder.external_show_hidden, file_count=0, + # Newly-created external folder hasn't been scanned yet — fall back + # to the folder's own updated_at (#1770). + latest_activity_at=folder.updated_at, created_at=folder.created_at, updated_at=folder.updated_at, ) diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index bc5a7b35c..ef7228e62 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -50,6 +50,12 @@ class FolderResponse(BaseModel): external_readonly: bool = False external_show_hidden: bool = False file_count: int = 0 # Computed field + # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the + # File Manager folder tree's "sort by recent activity" mode (#1770) so that + # adding a file inside a folder bubbles it up — folder.updated_at alone only + # tracks rename/move events. Recursion across subfolders is intentionally + # left out to keep the route a single GROUP BY rather than a recursive CTE. + latest_activity_at: datetime | None = None created_at: datetime updated_at: datetime @@ -71,6 +77,8 @@ class FolderTreeItem(BaseModel): external_path: str | None = None external_readonly: bool = False file_count: int = 0 + # See FolderResponse.latest_activity_at — #1770 folder sort source. + latest_activity_at: datetime | None = None children: list["FolderTreeItem"] = [] class Config: diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index bd3d36b7c..92a8b4a25 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -44,6 +44,61 @@ class TestLibraryFoldersAPI: assert response.status_code == 200 assert response.json() == [] + @pytest.mark.asyncio + @pytest.mark.integration + async def test_folder_tree_exposes_latest_activity_at_from_files( + self, async_client: AsyncClient, folder_factory, db_session + ): + """#1770: folder list returns latest_activity_at = MAX(folder.updated_at, + MAX(immediate-child file.updated_at)) so the frontend can sort by + recent activity. Adding a file with a later updated_at must bubble it. + """ + from datetime import datetime, timedelta + + from backend.app.models.library import LibraryFile + + folder = await folder_factory(name="Active Folder") + # File whose updated_at is well after the folder's. Activity should + # surface this timestamp, not the folder's stale one. + future = datetime.utcnow() + timedelta(hours=24) + db_session.add( + LibraryFile( + folder_id=folder.id, + filename="model.3mf", + file_path="library/model.3mf", + file_type="3mf", + file_size=123, + updated_at=future, + ) + ) + await db_session.commit() + + response = await async_client.get("/api/v1/library/folders") + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + item = items[0] + assert item["id"] == folder.id + assert item["latest_activity_at"] is not None + # latest_activity_at should be at least the future stamp we set. + assert item["latest_activity_at"] >= future.isoformat() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at( + self, async_client: AsyncClient, folder_factory, db_session + ): + """#1770: a folder with no files reports its own updated_at, not null — + otherwise the activity sort would dump every empty folder to one end.""" + await folder_factory(name="Empty Folder") + response = await async_client.get("/api/v1/library/folders") + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + item = items[0] + # latest_activity_at == folder.updated_at when there are no files + assert item["latest_activity_at"] is not None + @pytest.mark.asyncio @pytest.mark.integration async def test_create_folder(self, async_client: AsyncClient, db_session): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b50146f13..8ae9b5ba0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6314,6 +6314,9 @@ export interface LibraryFolderTree { external_path: string | null; external_readonly: boolean; file_count: number; + // max(folder.updated_at, max(immediate-child file.updated_at)). Used by + // the File Manager folder tree's "sort by recent activity" mode (#1770). + latest_activity_at: string | null; children: LibraryFolderTree[]; } @@ -6330,6 +6333,7 @@ export interface LibraryFolder { external_readonly: boolean; external_show_hidden: boolean; file_count: number; + latest_activity_at: string | null; created_at: string; updated_at: string; } diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index c93937de6..f0bc465c4 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -3320,6 +3320,9 @@ export default { collapse: 'Einklappen', collapseFoldersByDefault: 'Ordner standardmäßig einklappen', expandFoldersByDefault: 'Ordner standardmäßig ausklappen', + folderSort: 'Ordner sortieren', + folderSortByName: 'Nach Name', + folderSortByActivity: 'Nach letzter Aktivität', dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen', searchFiles: 'Dateien suchen...', allTypes: 'Alle Typen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index eed9b873f..557748d22 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3335,6 +3335,9 @@ export default { collapse: 'Collapse', collapseFoldersByDefault: 'Collapse folders by default', expandFoldersByDefault: 'Expand folders by default', + folderSort: 'Sort folders', + folderSortByName: 'By name', + folderSortByActivity: 'By recent activity', dragToResizeTooltip: 'Drag to resize, double-click to reset', searchFiles: 'Search files...', allTypes: 'All types', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index ebd7f0b35..0149fd5be 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -3323,6 +3323,9 @@ export default { collapse: 'Contraer', collapseFoldersByDefault: 'Contraer las carpetas de forma predeterminada', expandFoldersByDefault: 'Expandir las carpetas de forma predeterminada', + folderSort: 'Ordenar carpetas', + folderSortByName: 'Por nombre', + folderSortByActivity: 'Por actividad reciente', dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer', searchFiles: 'Buscar archivos...', allTypes: 'Todos los tipos', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b188926d9..d11ed334e 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -3309,6 +3309,9 @@ export default { collapse: 'Réduire', collapseFoldersByDefault: 'Réduire les dossiers par défaut', expandFoldersByDefault: 'Développer les dossiers par défaut', + folderSort: 'Trier les dossiers', + folderSortByName: 'Par nom', + folderSortByActivity: 'Par activité récente', dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset', searchFiles: 'Chercher fichiers...', allTypes: 'Tous types', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 08de961cf..cd8fa0bca 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -3308,6 +3308,9 @@ export default { collapse: 'Comprimi', collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita', expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita', + folderSort: 'Ordina cartelle', + folderSortByName: 'Per nome', + folderSortByActivity: 'Per attività recente', dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset', searchFiles: 'Cerca file...', allTypes: 'Tutti i tipi', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index a3837a5df..0fa52af7d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3320,6 +3320,9 @@ export default { collapse: '折りたたむ', collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ', expandFoldersByDefault: 'フォルダをデフォルトで展開する', + folderSort: 'フォルダの並べ替え', + folderSortByName: '名前順', + folderSortByActivity: '最終更新順', dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット', searchFiles: 'ファイルを検索...', allTypes: 'すべての種類', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index ca9d717d3..93e5ca3be 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -3133,6 +3133,9 @@ export default { collapse: '접기', collapseFoldersByDefault: '기본적으로 폴더 접기', expandFoldersByDefault: '기본적으로 폴더 펼치기', + folderSort: '폴더 정렬', + folderSortByName: '이름순', + folderSortByActivity: '최근 활동순', dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화', searchFiles: '파일 검색...', allTypes: '모든 유형', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index f384b57f8..9812bd51b 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -3308,6 +3308,9 @@ export default { collapse: 'Recolher', collapseFoldersByDefault: 'Recolher pastas por padrão', expandFoldersByDefault: 'Expandir pastas por padrão', + folderSort: 'Ordenar pastas', + folderSortByName: 'Por nome', + folderSortByActivity: 'Por atividade recente', dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir', searchFiles: 'Pesquisar arquivos...', allTypes: 'Todos os tipos', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 1ffc012da..b9c58423e 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -3315,6 +3315,9 @@ export default { collapse: 'Daralt', collapseFoldersByDefault: 'Klasörleri varsayılan olarak daralt', expandFoldersByDefault: 'Klasörleri varsayılan olarak genişlet', + folderSort: 'Klasörleri sırala', + folderSortByName: 'Ada göre', + folderSortByActivity: 'Son etkinliğe göre', dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın', searchFiles: 'Dosyalarda ara...', allTypes: 'Tüm türler', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b89c70ff5..1c960ab17 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3308,6 +3308,9 @@ export default { collapse: '折叠', collapseFoldersByDefault: '默认折叠文件夹', expandFoldersByDefault: '默认展开文件夹', + folderSort: '文件夹排序', + folderSortByName: '按名称', + folderSortByActivity: '按最近活动', dragToResizeTooltip: '拖动调整大小,双击重置', searchFiles: '搜索文件...', allTypes: '所有类型', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index d8276e164..a7b2a9a32 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3308,6 +3308,9 @@ export default { collapse: '折疊', collapseFoldersByDefault: '預設折疊資料夾', expandFoldersByDefault: '預設展開資料夾', + folderSort: '資料夾排序', + folderSortByName: '依名稱', + folderSortByActivity: '依最近活動', dragToResizeTooltip: '拖曳調整大小,雙擊重設', searchFiles: '搜尋檔案...', allTypes: '所有類型', diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index f1128d87f..d4c061457 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -978,6 +978,17 @@ export function FileManagerPage() { const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => { return localStorage.getItem('library-collapse-folders') === 'true'; }); + // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour); + // 'activity' = most recent file activity inside the folder first. Persisted + // independently from the file-side sort so each can be tuned to taste. + const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => { + const saved = localStorage.getItem('library-folder-sort-field'); + return saved === 'activity' ? 'activity' : 'name'; + }); + const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => { + const saved = localStorage.getItem('library-folder-sort-direction'); + return saved === 'desc' ? 'desc' : 'asc'; + }); // Resizable sidebar state const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -1060,6 +1071,42 @@ export function FileManagerPage() { queryFn: () => api.getLibraryFolders(), }); + // Recursive folder tree sort (#1770). Applies the same comparator to the + // top-level list AND to each level of `children`, so sort order is uniform + // at every depth of nesting. When sorting by activity, the comparator falls + // back to a created-at fallback for folders with no files (`latest_activity_at` + // is null) so they stay grouped at the end / start of the bucket instead of + // randomly interspersed. + const sortedFolders = useMemo(() => { + if (!folders) return folders; + const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => { + const sorted = [...items].sort((a, b) => { + let comparison = 0; + if (folderSortField === 'name') { + comparison = a.name.localeCompare(b.name); + } else { + // activity: newest first on 'desc', oldest first on 'asc'. + // Folders with no activity timestamp sort to the end regardless + // of direction so an empty folder doesn't elbow a recently-used one. + const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null; + const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null; + if (aTs === null && bTs === null) { + comparison = a.name.localeCompare(b.name); + } else if (aTs === null) { + return 1; + } else if (bTs === null) { + return -1; + } else { + comparison = aTs - bTs; + } + } + return folderSortDirection === 'asc' ? comparison : -comparison; + }); + return sorted.map((f) => ({ ...f, children: sortLevel(f.children) })); + }; + return sortLevel(folders); + }, [folders, folderSortField, folderSortDirection]); + // Trash count for the header badge (#1008). Empty/error are silently treated // as zero so a broken trash endpoint doesn't break the File Manager. const { data: trashCount } = useQuery({ @@ -1606,7 +1653,7 @@ export function FileManagerPage() { {folders?.some((f) => f.is_external) && ( )} - {folders && (() => { + {sortedFolders && (() => { // Flatten folder tree for mobile selector const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => { const result: { id: number; name: string; fileCount: number; depth: number }[] = []; @@ -1618,7 +1665,7 @@ export function FileManagerPage() { } return result; }; - return flattenFolders(folders).map((folder) => ( + return flattenFolders(sortedFolders).map((folder) => ( @@ -1658,6 +1705,35 @@ export function FileManagerPage() {

{t('fileManager.folders')}

+ {/* Folder tree sort (#1770). Dropdown drives the comparator; + direction button flips asc/desc. Both persist to localStorage + on change so the choice survives reloads. */} + +