From 92c48fba225a61956f61dda2b823128ed1bd1e4e Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 19:32:16 +0200 Subject: [PATCH] fix(files): file notes, links, stars and the trash changed offline wait in the queue and sync once the connection is back Every file write went straight to the API, so offline a star snapped back without a word and moving a file to the trash failed with "Failed to delete file". Notes, the place or booking of a file, a further link, the star, the trash move, restore, delete for good and emptying the trash now go through fileRepo and the mutation queue like the other trip data: the local copy changes at once, and an online write to a file that still has an older one queued joins the queue behind it, since file writes carry no version token. The desktop file manager, its assign dialog and the phone's files tab, menu, link and trash sheets all write through it. The single file answers carry no ids of further links, so a replayed edit, star or restore keeps the ones the local copy holds, and the link answer ({ success, links }) writes no row. What only works online says so instead of failing: an upload shows that it needs a connection, a place saved with newly picked files is stopped with a hint, and removing a further link (its row id is known only to the server) asks to try again once online. Refs #2601 --- client/scripts/offline-baseline.json | 3 - .../src/components/Files/FileManager.test.tsx | 4 +- .../Files/FileManagerAssignModal.test.tsx | 27 ++- .../Files/FileManagerAssignModal.tsx | 6 +- .../src/components/Files/fileActions.test.ts | 26 ++- client/src/components/Files/fileActions.ts | 20 +- client/src/components/Files/useFileManager.ts | 4 +- client/src/components/Files/useFileTrash.ts | 18 +- .../Planner/BookingLinkAndFiles.tsx | 4 +- .../screens/trip/tabs/MFileLinkSheet.tsx | 5 +- .../mobile/screens/trip/tabs/MFilesTab.tsx | 11 +- .../pages/tripPlanner/usePlaceEdits.test.ts | 23 ++- client/src/pages/tripPlanner/usePlaceEdits.ts | 4 + client/src/repo/fileRepo.offline.test.ts | 162 ++++++++++++++++ client/src/repo/fileRepo.ts | 181 ++++++++++++++++++ client/src/store/slices/filesSlice.test.ts | 52 ++++- client/src/store/slices/filesSlice.ts | 18 +- client/src/sync/mutationQueue.ts | 16 +- .../unit/mobile/trip/MFileLinkSheet.test.tsx | 4 +- .../unit/mobile/trip/MFileMenuSheet.test.tsx | 6 +- .../unit/mobile/trip/MFileTrashSheet.test.tsx | 4 +- .../tests/unit/mobile/trip/MFilesTab.test.tsx | 2 +- .../unit/sync/mutationQueue.files.test.ts | 100 ++++++++++ shared/src/i18n/ar/files.ts | 4 + shared/src/i18n/az/files.ts | 5 + shared/src/i18n/br/files.ts | 5 + shared/src/i18n/ca/files.ts | 5 + shared/src/i18n/cs/files.ts | 4 + shared/src/i18n/de/files.ts | 6 + shared/src/i18n/en/files.ts | 4 + shared/src/i18n/es/files.ts | 6 + shared/src/i18n/et/files.ts | 4 + shared/src/i18n/fr/files.ts | 4 + shared/src/i18n/gr/files.ts | 5 + shared/src/i18n/hu/files.ts | 4 + shared/src/i18n/id/files.ts | 4 + shared/src/i18n/it/files.ts | 5 + shared/src/i18n/ja/files.ts | 4 + shared/src/i18n/ko/files.ts | 5 + shared/src/i18n/nl/files.ts | 5 + shared/src/i18n/pl/files.ts | 5 + shared/src/i18n/ru/files.ts | 5 + shared/src/i18n/sk/files.ts | 5 + shared/src/i18n/sv/files.ts | 4 + shared/src/i18n/th/files.ts | 4 + shared/src/i18n/tr/files.ts | 5 + shared/src/i18n/uk/files.ts | 5 + shared/src/i18n/vi/files.ts | 4 + shared/src/i18n/zh-TW/files.ts | 3 + shared/src/i18n/zh/files.ts | 3 + 50 files changed, 763 insertions(+), 59 deletions(-) create mode 100644 client/src/repo/fileRepo.offline.test.ts create mode 100644 client/tests/unit/sync/mutationQueue.files.test.ts diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index 659d61731..a6b13ba15 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -47,9 +47,7 @@ "src/components/Files/FileManagerImageLightbox.tsx", "src/components/Files/docsync/useConflicts.ts", "src/components/Files/docsync/useDocSync.ts", - "src/components/Files/fileActions.ts", "src/components/Files/useFileManager.ts", - "src/components/Files/useFileTrash.ts", "src/components/Files/useMediaLightbox.ts", "src/components/Journey/ContributorInviteDialog.tsx", "src/components/Journey/JourneyDetailPageEntryCard.tsx", @@ -134,7 +132,6 @@ "src/mobile/screens/journey/MJourneyDetail.tsx", "src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/tabs/MCollabNotes.tsx", - "src/mobile/screens/trip/tabs/MFilesTab.tsx", "src/pages/JourneyDetailPage.tsx", "src/pages/OAuthAuthorizePage.tsx", "src/pages/TripPlannerPage.tsx", diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 71b268f76..6505fbb92 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -168,7 +168,7 @@ describe('FileManager', () => { const starBtn = screen.getByRole('button', { name: /star/i }); await user.click(starBtn); - expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1); + await waitFor(() => expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)); }); it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => { @@ -207,7 +207,7 @@ describe('FileManager', () => { const restoreBtn = screen.getByRole('button', { name: /restore/i }); await user.click(restoreBtn); - expect(filesApi.restore).toHaveBeenCalledWith(1, 5); + await waitFor(() => expect(filesApi.restore).toHaveBeenCalledWith(1, 5)); }); it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => { diff --git a/client/src/components/Files/FileManagerAssignModal.test.tsx b/client/src/components/Files/FileManagerAssignModal.test.tsx index 3e16286c9..481ff9c6f 100644 --- a/client/src/components/Files/FileManagerAssignModal.test.tsx +++ b/client/src/components/Files/FileManagerAssignModal.test.tsx @@ -26,6 +26,7 @@ import { AssignModal } from './FileManagerAssignModal' const setAssignFileId = vi.fn() const handleAssign = vi.fn(async (_fileId: number, _data: unknown) => {}) const refreshFiles = vi.fn(async () => {}) +const toastError = vi.fn() const file = (overrides: Partial = {}) => ({ id: 7, original_name: 'ticket.pdf', description: '', url: '/uploads/files/ticket.pdf', ...overrides }) as unknown as TripFile @@ -47,6 +48,7 @@ function state(overrides: Partial = {}): FileManagerState { tripId: 3, handleAssign, refreshFiles, + toast: { error: toastError }, ...overrides, } as unknown as FileManagerState } @@ -198,13 +200,14 @@ describe('AssignModal place list', () => { expect(refreshFiles).toHaveBeenCalled() }) - it('FE-W5ASG-014: a failing link request is swallowed', async () => { + it('FE-W5ASG-014: a failing link request shows the assign error and does not refresh', async () => { addLink.mockRejectedValueOnce(new Error('offline')) render() fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) - await waitFor(() => expect(addLink).toHaveBeenCalled()) + await waitFor(() => expect(toastError).toHaveBeenCalledWith('files.toast.assignError')) + expect(addLink).toHaveBeenCalled() expect(refreshFiles).not.toHaveBeenCalled() }) @@ -236,16 +239,32 @@ describe('AssignModal place list', () => { expect(removeLink).not.toHaveBeenCalled() }) - it('FE-W5ASG-018: a failing links lookup is swallowed', async () => { + it('FE-W5ASG-018: a failing links lookup shows the assign error and does not refresh', async () => { getLinks.mockRejectedValueOnce(new Error('offline')) render() fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) - await waitFor(() => expect(getLinks).toHaveBeenCalled()) + await waitFor(() => expect(toastError).toHaveBeenCalledWith('files.toast.assignError')) + expect(getLinks).toHaveBeenCalled() expect(refreshFiles).not.toHaveBeenCalled() }) + it('FE-W5ASG-035: offline, removing a further link says it needs a connection', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }) + const t = (key: string) => (key === 'files.linkNeedsConnection' ? 'Needs a connection' : key) + try { + render()} />) + + fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) + + await waitFor(() => expect(toastError).toHaveBeenCalledWith('Needs a connection')) + expect(getLinks).not.toHaveBeenCalled() + } finally { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) + } + }) + it('FE-W5ASG-019: rows keep the linked highlight on mouse-out, free rows do not', () => { render() const linked = screen.getByRole('button', { name: 'Louvre' }) diff --git a/client/src/components/Files/FileManagerAssignModal.tsx b/client/src/components/Files/FileManagerAssignModal.tsx index 0f4c1f353..5fdd6a2a4 100644 --- a/client/src/components/Files/FileManagerAssignModal.tsx +++ b/client/src/components/Files/FileManagerAssignModal.tsx @@ -7,6 +7,7 @@ import { transportIcon } from './FileManager.helpers' import { planFileLinkToggle, runFileLinkRecordStep, type FileLinkField } from './fileActions' import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell' import { EditorField, INPUT } from '../shared/dialogParts' +import { translateApiError } from '../../i18n' export function AssignModal(S: FileManagerState) { const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S @@ -22,8 +23,9 @@ export function AssignModal(S: FileManagerState) { try { await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind) refreshFiles() - } catch { - // A failed link record change stays quiet. + } catch (err) { + // Offline a link record cannot be removed, and the hint says so. + S.toast.error(translateApiError(t, err, 'files.toast.assignError')) } } return ( diff --git a/client/src/components/Files/fileActions.test.ts b/client/src/components/Files/fileActions.test.ts index 3250af448..564fa2c6a 100644 --- a/client/src/components/Files/fileActions.test.ts +++ b/client/src/components/Files/fileActions.test.ts @@ -1,4 +1,4 @@ -// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-020: the paste, link, field save and trash rules the +// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-021: the paste, link, field save and trash rules the // desktop file manager and the phone's files tab and sheets share, with what each view keeps. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -244,3 +244,27 @@ describe('trashFileWithToast', () => { expect(fb.toast.error).toHaveBeenCalledWith('files.toast.deleteError'); }); }); + +describe('link toggles offline (#2601)', () => { + afterEach(() => { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + vi.restoreAllMocks(); + }); + + it('FE-FILES-ACTIONS-021: the column and a new record wait in the queue, removing a record says it needs a connection', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }); + const update = vi.spyOn(filesApi, 'update'); + const addLink = vi.spyOn(filesApi, 'addLink'); + const getLinks = vi.spyOn(filesApi, 'getLinks'); + + await toggleFileLink(1, buildTripFile({ id: 9, place_id: null }), 'place_id', 5); + await toggleFileLink(1, buildTripFile({ id: 9, place_id: 1 }), 'place_id', 6); + await expect( + toggleFileLink(1, buildTripFile({ id: 9, place_id: 1, linked_place_ids: [6] }), 'place_id', 6) + ).rejects.toThrow('files.linkNeedsConnection'); + + expect(update).not.toHaveBeenCalled(); + expect(addLink).not.toHaveBeenCalled(); + expect(getLinks).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Files/fileActions.ts b/client/src/components/Files/fileActions.ts index 7ddf39774..0c0f633b0 100644 --- a/client/src/components/Files/fileActions.ts +++ b/client/src/components/Files/fileActions.ts @@ -1,4 +1,4 @@ -import { filesApi } from '../../api/client'; +import { fileRepo } from '../../repo/fileRepo'; import type { TranslationFn, TripFile } from '../../types'; import type { useToast } from '../shared/Toast'; @@ -71,7 +71,11 @@ interface FileLinkRecord { reservation_id?: number | string | null; } -/** Runs an `addLink` or `removeLink` step against the server. */ +/** + * Runs an `addLink` or `removeLink` step. Adding one waits in the offline queue; + * removing one needs the server to name the link record, so offline it throws + * the hint that says so. + */ export async function runFileLinkRecordStep( tripId: number, fileId: number, @@ -81,18 +85,16 @@ export async function runFileLinkRecordStep( rules: FileLinkRules = {} ): Promise { if (step === 'addLink') { - await filesApi.addLink( + await fileRepo.addLink( tripId, fileId, field === 'place_id' ? { place_id: targetId } : { reservation_id: targetId } ); return; } - const linksRes = (await filesApi.getLinks(tripId, fileId)) as { links?: FileLinkRecord[] }; - const link = (linksRes.links || []).find((l) => + await fileRepo.removeLinkTo(tripId, fileId, (l: FileLinkRecord) => rules.matchRecordsByNumber ? Number(l[field]) === targetId : l[field] === targetId ); - if (link) await filesApi.removeLink(tripId, fileId, link.id); } /** Toggles one place or booking on a file, whichever of the server calls that takes. */ @@ -104,7 +106,7 @@ export async function toggleFileLink( rules: FileLinkRules = {} ): Promise { const step = planFileLinkToggle(file, field, targetId, rules); - if (step.kind === 'update') await filesApi.update(tripId, file.id, step.data); + if (step.kind === 'update') await fileRepo.update(tripId, file.id, step.data); else await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind, rules); } @@ -128,12 +130,12 @@ export const PHONE_FILE_UPDATE_RULES: FileUpdateRules = { awaitRefresh: true }; export async function updateFileFields( tripId: number, fileId: number, - data: Parameters[2], + data: Parameters[2], { t, toast, refresh }: FileFeedback & { refresh: () => unknown }, rules: FileUpdateRules = {} ): Promise { try { - await filesApi.update(tripId, fileId, data); + await fileRepo.update(tripId, fileId, data); if (rules.awaitRefresh) await refresh(); else void refresh(); } catch { diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 8f4c30e72..37cf7c954 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { useToast } from '../shared/Toast' import { useTranslation, translateApiError } from '../../i18n' -import { filesApi } from '../../api/client' +import { fileRepo } from '../../repo/fileRepo' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' @@ -66,7 +66,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place const handleStar = async (fileId: number) => { try { - await filesApi.toggleStar(tripId, fileId) + await fileRepo.toggleStar(tripId, fileId) refreshFiles() } catch { /* */ } } diff --git a/client/src/components/Files/useFileTrash.ts b/client/src/components/Files/useFileTrash.ts index c2744e895..f7dc81e87 100644 --- a/client/src/components/Files/useFileTrash.ts +++ b/client/src/components/Files/useFileTrash.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; -import { filesApi } from '../../api/client'; import type { useTranslation } from '../../i18n'; +import { fileRepo } from '../../repo/fileRepo'; import type { TripFile } from '../../types'; import type { useToast } from '../shared/Toast'; @@ -31,7 +31,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const load = useCallback(async () => { setLoading(true); try { - const data = await filesApi.list(tripId, true); + const data = await fileRepo.listTrash(tripId); setFiles(data.files || []); } catch { // An unreachable trash shows as empty. @@ -43,8 +43,8 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp if (!open) return; let cancelled = false; setLoading(true); - filesApi - .list(tripId, true) + fileRepo + .listTrash(tripId) .then((data: { files?: TripFile[] }) => { if (!cancelled) setFiles(data.files || []); }) @@ -59,7 +59,11 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const restore = async (fileId: number) => { try { - await filesApi.restore(tripId, fileId); + await fileRepo.restore( + tripId, + fileId, + files.find((f) => f.id === fileId) + ); setFiles((prev) => prev.filter((f) => f.id !== fileId)); onRestored(); toast.success(t('files.toast.restored')); @@ -70,7 +74,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const permanentDelete = async (fileId: number) => { try { - await filesApi.permanentDelete(tripId, fileId); + await fileRepo.permanentDelete(tripId, fileId); setFiles((prev) => prev.filter((f) => f.id !== fileId)); toast.success(t('files.toast.deleted')); } catch { @@ -80,7 +84,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const emptyTrash = async () => { try { - await filesApi.emptyTrash(tripId); + await fileRepo.emptyTrash(tripId); setFiles([]); toast.success(t('files.toast.trashEmptied')); } catch { diff --git a/client/src/components/Planner/BookingLinkAndFiles.tsx b/client/src/components/Planner/BookingLinkAndFiles.tsx index 8db0d1efd..9ce5a3690 100644 --- a/client/src/components/Planner/BookingLinkAndFiles.tsx +++ b/client/src/components/Planner/BookingLinkAndFiles.tsx @@ -1,7 +1,7 @@ import type { ChangeEvent, RefObject } from 'react' import { ExternalLink, FileText, Link2, X } from 'lucide-react' import { useTripStore } from '../../store/tripStore' -import { useTranslation } from '../../i18n' +import { translateApiError, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { openFile } from '../../utils/fileDownload' import { BookingFileButtons } from './BookingFileButtons' @@ -50,7 +50,7 @@ export function BookingLinkAndFiles({ const linkable = reservationId ? tripFiles.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)) : [] const detach = async (file: TripFile) => { if (!tripId || !reservationId) return - try { await unlinkFile(tripId, file, reservationId) } catch { toast.error(t('reservations.toast.updateError')) } + try { await unlinkFile(tripId, file, reservationId) } catch (err) { toast.error(translateApiError(t, err, 'reservations.toast.updateError')) } onDetached(file.id) } diff --git a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx index d23c56288..3ef3d88a7 100644 --- a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx @@ -5,6 +5,7 @@ import MSheet from '../../../components/MSheet' import { PHONE_FILE_LINK_RULES, toggleFileLink, type FileLinkField } from '../../../../components/Files/fileActions' import { linkedFileIds } from '../../../../components/Files/fileListRules' import type { TripFile } from '../../../../types' +import { translateApiError } from '../../../../i18n' import type { TripPlanner } from '../MTripShell' import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi' @@ -46,8 +47,8 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee try { await toggleFileLink(tripId, shown, field, targetId, PHONE_FILE_LINK_RULES) refresh() - } catch { - toast.error(t('files.toast.assignError')) + } catch (err) { + toast.error(translateApiError(t, err, 'files.toast.assignError')) } finally { setBusyKey(null) } diff --git a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx index 674d201b2..675f00172 100644 --- a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent } fr import { Link, Loader2, MoreVertical, Star } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { useTranslation, translateApiError } from '../../../../i18n' -import { filesApi } from '../../../../api/client' +import { fileRepo } from '../../../../repo/fileRepo' import { openFile } from '../../../../utils/fileDownload' import { isMedia, formatSize } from '../../../../components/Files/FileManager.helpers' import type { TripFile } from '../../../../types' @@ -27,9 +27,8 @@ import { * (spec 03 §5.2). Upload and Trash are triggered from the shell's header * (shell.uploadFilesSignal / shell.openFilesTrashSignal, watched below, same * pattern as useTodoList.ts's addItemSignal) since those buttons live outside - * this panel. Star/rename-note/link run straight against filesApi (§7.3); - * add/delete run through planner.tripActions (§7.3 — the only two file - * mutations the store owns). + * this panel. Star/rename-note/link run through fileRepo, so offline they + * wait in the queue; add/delete run through planner.tripActions (§7.3). */ export default function MFilesTab({ planner, shell }: MTabScreenProps) { const { t } = planner @@ -117,10 +116,10 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) { lastDocSyncSignal.current = shell.openDocSyncSignal }, [shell.openDocSyncSignal]) - // ── Star toggle (ungated, §7.6) — direct filesApi call + store refresh, same as §7.3. ── + // ── Star toggle (ungated, §7.6): through fileRepo, then a store refresh. ── const toggleStar = async (file: TripFile) => { try { - await filesApi.toggleStar(planner.tripId, file.id) + await fileRepo.toggleStar(planner.tripId, file.id) planner.tripActions.loadFiles(planner.tripId) } catch { planner.toast.error(t('files.toast.assignError')) diff --git a/client/src/pages/tripPlanner/usePlaceEdits.test.ts b/client/src/pages/tripPlanner/usePlaceEdits.test.ts index 62db80d2b..6c91fbc9d 100644 --- a/client/src/pages/tripPlanner/usePlaceEdits.test.ts +++ b/client/src/pages/tripPlanner/usePlaceEdits.test.ts @@ -1,4 +1,4 @@ -// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-016 +// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-017 // // Writes to the trip's places, driven straight through usePlaceEdits. The store holds // the places and day lists the delete and category handlers capture for their undo, @@ -460,4 +460,25 @@ describe('usePlaceEdits', () => { expect(fixed.pushUndo).toHaveBeenCalledWith('undo.changeCategory', expect.any(Function), undefined, [1, 2]) }) + + it('FE-TP-PLACEEDITS-017: offline, a save with new files is refused before anything is written', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }) + try { + const { result } = renderEdits({ editingPlace: lake }) + let refused: unknown + await act(async () => { + await result.current.handleSavePlace({ name: 'Lake', _pendingFiles: [new File(['x'], 'a.pdf')] }).catch((err: unknown) => { refused = err }) + }) + + expect(refused).toEqual(new Error('files.placeFilesNeedConnection')) + expect(actions.updatePlace).not.toHaveBeenCalled() + expect(actions.addFile).not.toHaveBeenCalled() + + // Without new files the place saves offline as before. + await act(async () => { await result.current.handleSavePlace({ name: 'Lake' }) }) + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'Lake' }) + } finally { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) + } + }) }) diff --git a/client/src/pages/tripPlanner/usePlaceEdits.ts b/client/src/pages/tripPlanner/usePlaceEdits.ts index a0ad1167a..8e6278d39 100644 --- a/client/src/pages/tripPlanner/usePlaceEdits.ts +++ b/client/src/pages/tripPlanner/usePlaceEdits.ts @@ -7,6 +7,7 @@ import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { translateApiError } from '../../i18n' import { useAuthStore } from '../../store/authStore' import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { isEffectivelyOffline } from '../../sync/networkMode' import type { Accommodation, Place } from '../../types' import { capturePlaceVisits, deleteLosesDependents, restorePlace } from './placeUndo' import type { PlannerBase, PlannerHistory } from './plannerTypes' @@ -91,6 +92,9 @@ export function usePlaceEdits(options: PlaceEditsOptions) { const handleSavePlace = useCallback(async (data) => { const pendingFiles = data._pendingFiles + // A file cannot wait in the offline queue the way the place can, so the form + // stays open with them instead of saving a place that loses them. + if (pendingFiles?.length > 0 && isEffectivelyOffline()) throw new Error(t('files.placeFilesNeedConnection')) delete data._pendingFiles // Where a service stop added by hand belongs on the drive. The form worked it out // from the coordinates being saved, because until a place was chosen in it there diff --git a/client/src/repo/fileRepo.offline.test.ts b/client/src/repo/fileRepo.offline.test.ts new file mode 100644 index 000000000..9506233d1 --- /dev/null +++ b/client/src/repo/fileRepo.offline.test.ts @@ -0,0 +1,162 @@ +// FE-REPO-FILE-OFF-001 to FE-REPO-FILE-OFF-008 +// Offline write paths (#2601), fileRepo.test.ts covers the reads. +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildTripFile } from '../../tests/helpers/factories'; +import { server } from '../../tests/helpers/msw/server'; +import { filesApi } from '../api/client'; +import type { QueuedMutation } from '../db/offlineDb'; +import { clearAll, offlineDb } from '../db/offlineDb'; +import { setAuthed } from '../sync/authGate'; +import { mutationQueue } from '../sync/mutationQueue'; +import { fileRepo, NEEDS_CONNECTION } from './fileRepo'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +async function queue(): Promise { + return (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setOnline(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('fileRepo — offline', () => { + it('FE-REPO-FILE-OFF-001: a note and a place set offline land on the local file and wait in the queue', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ id: 12, trip_id: 4, description: null, place_id: null, linked_place_ids: [7] }) + ); + + const { file } = await fileRepo.update(4, 12, { description: 'Gate B', place_id: 5 }); + + expect(file).toMatchObject({ id: 12, description: 'Gate B', place_id: 5, linked_place_ids: [7] }); + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ description: 'Gate B', place_id: 5 }); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ + method: 'PUT', + url: '/trips/4/files/12', + body: { description: 'Gate B', place_id: 5 }, + resource: 'tripFiles', + entityId: 12, + }); + }); + + it('FE-REPO-FILE-OFF-002: each star toggle flips the local star and queues one PATCH', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, starred: 0 })); + + expect((await fileRepo.toggleStar(4, 12)).file.starred).toBe(1); + expect((await fileRepo.toggleStar(4, 12)).file.starred).toBe(0); + + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url])).toEqual([ + ['PATCH', '/trips/4/files/12/star'], + ['PATCH', '/trips/4/files/12/star'], + ]); + }); + + it('FE-REPO-FILE-OFF-003: a file moved to the trash leaves the local list and its DELETE waits', async () => { + await offlineDb.tripFiles.bulkPut([buildTripFile({ id: 12, trip_id: 4 }), buildTripFile({ id: 13, trip_id: 4 })]); + + await fileRepo.delete(4, 12); + + expect((await fileRepo.list(4)).files.map((f) => f.id)).toEqual([13]); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ method: 'DELETE', url: '/trips/4/files/12', resource: 'tripFiles', entityId: 12 }); + }); + + it('FE-REPO-FILE-OFF-004: a further link shows on the local file at once and its POST waits', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ + id: 12, + trip_id: 4, + place_id: 1, + reservation_id: 2, + linked_place_ids: [], + linked_reservation_ids: [], + }) + ); + + await fileRepo.addLink(4, 12, { place_id: 5 }); + await fileRepo.addLink(4, 12, { reservation_id: 9 }); + + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ linked_place_ids: [5], linked_reservation_ids: [9] }); + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url, m.body])).toEqual([ + ['POST', '/trips/4/files/12/link', { place_id: 5 }], + ['POST', '/trips/4/files/12/link', { reservation_id: 9 }], + ]); + }); + + it('FE-REPO-FILE-OFF-005: restoring, deleting for good and emptying the trash wait in the queue in order', async () => { + const trashed = buildTripFile({ id: 20, trip_id: 4, deleted_at: '2026-10-01 10:00:00' }); + + await fileRepo.restore(4, 20, trashed); + await fileRepo.permanentDelete(4, 21); + await fileRepo.emptyTrash(4); + + expect(await offlineDb.tripFiles.get(20)).toMatchObject({ id: 20, deleted_at: null }); + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url, m.resource])).toEqual([ + ['POST', '/trips/4/files/20/restore', 'tripFiles'], + ['DELETE', '/trips/4/files/21/permanent', 'tripFiles'], + ['DELETE', '/trips/4/files/trash/empty', undefined], + ]); + }); + + it('FE-REPO-FILE-OFF-006: an upload and removing a further link say they need a connection and queue nothing', async () => { + const upload = vi.spyOn(filesApi, 'upload'); + const getLinks = vi.spyOn(filesApi, 'getLinks'); + + await expect(fileRepo.upload(4, new FormData())).rejects.toThrow(NEEDS_CONNECTION.uploadKey); + await expect(fileRepo.removeLinkTo(4, 12, () => true)).rejects.toThrow(NEEDS_CONNECTION.linkKey); + + expect(upload).not.toHaveBeenCalled(); + expect(getLinks).not.toHaveBeenCalled(); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); +}); + +describe('fileRepo — online', () => { + it('FE-REPO-FILE-OFF-007: an edit of a file with an older queued write joins the queue behind it', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, starred: 0 })); + await fileRepo.toggleStar(4, 12); + const update = vi.spyOn(filesApi, 'update'); + // Online now, but the flush that sendSoon starts must not run in this test. + setOnline(true); + vi.spyOn(mutationQueue, 'flush').mockResolvedValue(undefined); + + await fileRepo.update(4, 12, { description: 'Later' }); + + expect(update).not.toHaveBeenCalled(); + const rows = await queue(); + expect(rows.map((m) => m.method)).toEqual(['PATCH', 'PUT']); + }); + + it('FE-REPO-FILE-OFF-008: online writes go straight out and keep the link ids the answer leaves out', async () => { + setOnline(true); + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, linked_place_ids: [7], description: null })); + server.use( + http.put('/api/trips/4/files/12', () => + HttpResponse.json({ + file: { ...buildTripFile({ id: 12, trip_id: 4, description: 'Seen' }), linked_place_ids: undefined }, + }) + ) + ); + + await fileRepo.update(4, 12, { description: 'Seen' }); + + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ description: 'Seen', linked_place_ids: [7] }); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); +}); diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 3e6f43fc7..a07592ffd 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -1,8 +1,73 @@ import { filesApi } from '../api/client' import { offlineDb, replaceTripRows } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { TripFile } from '../types' +import type { FileLink, FileLinkRequest, FileUpdateRequest } from '@trek/shared' +/** + * What the file writes that need the server throw offline: an upload, and the + * removal of a further link (see removeLinkTo). The messages are translation + * keys, so a view that shows the failure through translateApiError says why. + */ +export const NEEDS_CONNECTION = { + uploadKey: 'files.uploadNeedsConnection', + linkKey: 'files.linkNeedsConnection', +} as const + +/** + * A file the server answered with. The single-file routes answer the stored row + * without the ids of its further links, so the ones the local copy has stay. + */ +async function keepLinks(file: TripFile | undefined): Promise { + if (!file || typeof file.id !== 'number') return + const local = await offlineDb.tripFiles.get(file.id) + await offlineDb.tripFiles.put({ ...local, ...file }) +} + +/** Queue a write to one file, held behind any older write to it. */ +async function enqueueFileWrite( + tripId: number | string, + id: number, + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + body: unknown, +): Promise { + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method, + url: `/trips/${tripId}/files/${id}${path}`, + body, + resource: 'tripFiles', + entityId: id, + }) + mutationQueue.sendSoon() +} + +/** The local copy of a file with some fields changed, kept in Dexie. */ +async function patchLocal(tripId: number | string, id: number, change: (file: TripFile) => Partial): Promise { + const before = await offlineDb.tripFiles.get(id) + const file = { ...(before ?? {}), ...(before ? change(before) : {}), id, trip_id: Number(tripId) } as TripFile + await offlineDb.tripFiles.put(file) + return file +} + +function asId(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const id = Number(value) + return Number.isFinite(id) ? id : null +} + +/** + * Files through the offline core. Reads fall back to the Dexie copy; a change to + * a file made offline (its note, its place or booking, the star, a link, the + * trash) lands there and in the mutation queue and replays once the connection + * is back. File writes carry no version token, so an online write to a file that + * still has an older one queued joins the queue behind it (mustQueue). An upload + * cannot wait in the queue, so offline it is refused with a hint. + */ export const fileRepo = { async list(tripId: number | string): Promise<{ files: TripFile[] }> { return onlineThenCache( @@ -17,4 +82,120 @@ export const fileRepo = { }), ) }, + + /** The trip's trashed files. Only the server has them. */ + async listTrash(tripId: number | string): Promise<{ files?: TripFile[] }> { + return filesApi.list(tripId, true) + }, + + async upload(tripId: number | string, formData: FormData): Promise<{ file: TripFile }> { + if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.uploadKey) + const result: { file: TripFile } = await filesApi.upload(tripId, formData) + await offlineDb.tripFiles.put(result.file) + return result + }, + + async update(tripId: number | string, id: number, data: FileUpdateRequest): Promise<{ file: TripFile }> { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.update(tripId, id, data) + await keepLinks(result.file) + return result + } + const file = await patchLocal(tripId, id, () => ({ + ...(data.description === undefined ? {} : { description: data.description || null }), + ...(data.place_id === undefined ? {} : { place_id: asId(data.place_id) }), + ...(data.reservation_id === undefined ? {} : { reservation_id: asId(data.reservation_id) }), + })) + await enqueueFileWrite(tripId, id, 'PUT', '', data) + return { file } + }, + + async toggleStar(tripId: number | string, id: number): Promise<{ file: TripFile }> { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.toggleStar(tripId, id) + await keepLinks(result.file) + return result + } + // The server flips the star, so each queued toggle flips it once more. + const file = await patchLocal(tripId, id, before => ({ starred: before.starred ? 0 : 1 })) + await enqueueFileWrite(tripId, id, 'PATCH', '/star', undefined) + return { file } + }, + + /** Moves a file to the trash. */ + async delete(tripId: number | string, id: number): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.delete(tripId, id) + await offlineDb.tripFiles.delete(id) + return + } + await offlineDb.tripFiles.delete(id) + await enqueueFileWrite(tripId, id, 'DELETE', '', undefined) + }, + + /** + * Brings a trashed file back. Offline the trash row the view shows, when it + * has one, is what the list gets until the server answers. + */ + async restore(tripId: number | string, id: number, trashed?: TripFile): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.restore(tripId, id) + await keepLinks(result.file) + return + } + if (trashed) await offlineDb.tripFiles.put({ ...trashed, deleted_at: null }) + await enqueueFileWrite(tripId, id, 'POST', '/restore', undefined) + }, + + async permanentDelete(tripId: number | string, id: number): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.permanentDelete(tripId, id) + return + } + await enqueueFileWrite(tripId, id, 'DELETE', '/permanent', undefined) + }, + + /** Empties the trash. Offline it waits in the queue behind the files moved there before. */ + async emptyTrash(tripId: number | string): Promise { + if (!isEffectivelyOffline()) { + await filesApi.emptyTrash(tripId) + return + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/files/trash/empty`, + body: undefined, + }) + }, + + /** Links a file to one more place or booking, beside the one in its own column. */ + async addLink(tripId: number | string, id: number, link: FileLinkRequest): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.addLink(tripId, id, link) + return + } + const placeId = asId(link.place_id) + const reservationId = asId(link.reservation_id) + await patchLocal(tripId, id, before => ({ + ...(placeId === null ? {} : { linked_place_ids: [...(before.linked_place_ids || []), placeId] }), + ...(reservationId === null ? {} : { linked_reservation_ids: [...(before.linked_reservation_ids || []), reservationId] }), + })) + await enqueueFileWrite(tripId, id, 'POST', '/link', link) + }, + + /** + * Removes the further link `matches` picks. The route names the link row by its + * own id, which only the server knows, so this needs a connection; online it + * first lets the older queued writes to the file through, a link added offline + * among them. + */ + async removeLinkTo(tripId: number | string, id: number, matches: (link: FileLink) => boolean): Promise { + if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.linkKey) + if (await mutationQueue.mustQueue('tripFiles', id)) await mutationQueue.flush() + const { links = [] } = (await filesApi.getLinks(tripId, id)) as { links?: FileLink[] } + const link = links.find(matches) + if (link) await filesApi.removeLink(tripId, id, link.id) + }, } diff --git a/client/src/store/slices/filesSlice.test.ts b/client/src/store/slices/filesSlice.test.ts index 3750d7013..3ffd30b43 100644 --- a/client/src/store/slices/filesSlice.test.ts +++ b/client/src/store/slices/filesSlice.test.ts @@ -1,9 +1,10 @@ -// FE-STORE-FILES-001 to FE-STORE-FILES-007 +// FE-STORE-FILES-001 to FE-STORE-FILES-010 import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildTripFile } from '../../../tests/helpers/factories'; import { useTripStore } from '../tripStore'; +import { clearAll, offlineDb } from '../../db/offlineDb'; /** * unlinkFileFromReservation (#2084): a file can sit on a booking two ways, through @@ -117,3 +118,52 @@ describe('filesSlice.unlinkFileFromReservation', () => { expect(useTripStore.getState().files.map(f => f.id)).toEqual([99]); }); }); + +/** + * Offline (#2601): a file moved to the trash or taken off a booking waits in the + * queue instead of failing, and an upload says it needs a connection. + */ +describe('filesSlice offline', () => { + function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + } + + beforeEach(async () => { + resetAllStores(); + await clearAll(); + setOnline(false); + }); + + afterEach(() => { + setOnline(true); + }); + + it('FE-STORE-FILES-008: a file moved to the trash offline leaves the list and its DELETE waits', async () => { + seedStore(useTripStore, { files: [buildTripFile({ id: 7, trip_id: 1 }), buildTripFile({ id: 8, trip_id: 1 })] }); + + await useTripStore.getState().deleteFile(1, 7); + + expect(useTripStore.getState().files.map((f) => f.id)).toEqual([8]); + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(mutation).toMatchObject({ method: 'DELETE', url: '/trips/1/files/7' }); + }); + + it('FE-STORE-FILES-009: a file uploaded on the booking comes off it offline without asking for its link rows', async () => { + const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 9, linked_reservation_ids: [] }); + await offlineDb.tripFiles.put(file); + + await useTripStore.getState().unlinkFileFromReservation(1, file, 9); + + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(mutation).toMatchObject({ method: 'PUT', url: '/trips/1/files/7', body: { reservation_id: null } }); + expect(useTripStore.getState().files).toEqual([expect.objectContaining({ id: 7, reservation_id: null })]); + }); + + it('FE-STORE-FILES-010: an upload offline throws the hint key and keeps the list', async () => { + seedStore(useTripStore, { files: [buildTripFile({ id: 7, trip_id: 1 })] }); + + await expect(useTripStore.getState().addFile(1, new FormData())).rejects.toThrow('files.uploadNeedsConnection'); + + expect(useTripStore.getState().files.map((f) => f.id)).toEqual([7]); + }); +}); diff --git a/client/src/store/slices/filesSlice.ts b/client/src/store/slices/filesSlice.ts index 8df5d4299..43ff703b3 100644 --- a/client/src/store/slices/filesSlice.ts +++ b/client/src/store/slices/filesSlice.ts @@ -1,5 +1,5 @@ -import { filesApi } from '../../api/client' import { fileRepo } from '../../repo/fileRepo' +import { isEffectivelyOffline } from '../../sync/networkMode' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { TripFile } from '../../types' @@ -31,7 +31,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ addFile: async (tripId, formData) => { try { - const data = await filesApi.upload(tripId, formData) + const data = await fileRepo.upload(tripId, formData) set(state => ({ files: [data.file, ...state.files] })) return data.file } catch (err: unknown) { @@ -41,7 +41,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ deleteFile: async (tripId, id) => { try { - await filesApi.delete(tripId, id) + await fileRepo.delete(tripId, id) set(state => ({ files: state.files.filter(f => f.id !== id) })) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error deleting file')) @@ -50,7 +50,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ linkFile: async (tripId, id, link) => { try { - await filesApi.addLink(tripId, id, link) + await fileRepo.addLink(tripId, id, link) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error linking file')) } @@ -60,10 +60,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ unlinkFileFromReservation: async (tripId, file, reservationId) => { try { // Uploaded on the booking, the file points at it itself; linked later, a link row does. - if (file.reservation_id === reservationId) await filesApi.update(tripId, file.id, { reservation_id: null }) - const { links = [] } = (await filesApi.getLinks(tripId, file.id)) as { links?: { id: number; reservation_id: number | null }[] } - const link = links.find(l => l.reservation_id === reservationId) - if (link) await filesApi.removeLink(tripId, file.id, link.id) + if (file.reservation_id === reservationId) await fileRepo.update(tripId, file.id, { reservation_id: null }) + // A link row is named by its own id, which only the server can look up. Offline + // that is needed only when the list says the file has one for this booking. + if ((file.linked_reservation_ids || []).includes(reservationId) || !isEffectivelyOffline()) { + await fileRepo.removeLinkTo(tripId, file.id, l => l.reservation_id === reservationId) + } } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error unlinking file')) } finally { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 661fde557..384e81521 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -15,7 +15,7 @@ import type { QueuedMutation } from '../db/offlineDb' import type { Table } from 'dexie' import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared' import { cacheAssignment } from '../db/cacheAssignment' -import type { BudgetItem, BudgetItemMember, Reservation } from '../types' +import type { BudgetItem, BudgetItemMember, Reservation, TripFile } from '../types' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { @@ -289,6 +289,18 @@ async function reconcileReservation(mutation: QueuedMutation, data: unknown, idM await offlineDb.reservations.put({ ...local, ...reservation, ...(answer.travelers ? { travelers: answer.travelers } : {}) }) } +/** + * A replayed file write. Only `file` names the file: the link route answers + * `{ success, links }`, and the file routes answer the stored row without the + * ids of its further links, so the ones the local copy holds stay. + */ +async function reconcileTripFile(data: unknown): Promise { + const file = (data && typeof data === 'object' ? data : {}) as { file?: TripFile } + if (!file.file || typeof file.file.id !== 'number') return + const local = await offlineDb.tripFiles.get(file.file.id) + await offlineDb.tripFiles.put({ ...local, ...file.file }) +} + /** * A replayed tour write. The answer names the tour by its place, so the * offline place and facet move from the temporary id to the real one. The @@ -529,6 +541,8 @@ export const mutationQueue = { await reconcileBudgetItem(mutation, response.data, idMap) } else if (mutation.resource === 'reservations' && mutation.method !== 'DELETE') { await reconcileReservation(mutation, response.data, idMap) + } else if (mutation.resource === 'tripFiles' && mutation.method !== 'DELETE') { + await reconcileTripFile(response.data) } else if (mutation.method !== 'DELETE' && mutation.resource) { const table = getTable(mutation.resource) if (table && response.data && typeof response.data === 'object') { diff --git a/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx index 7fbbc3370..f50d50f47 100644 --- a/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx @@ -81,7 +81,7 @@ describe('MFileLinkSheet', () => { it('FE-MOB-FLINK-005: the first place link goes into place_id', async () => { const { planner } = renderSheet() fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' })) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) expect(filesApi.addLink).not.toHaveBeenCalled() }) @@ -118,7 +118,7 @@ describe('MFileLinkSheet', () => { it('FE-MOB-FLINK-010: the first reservation link goes into reservation_id', async () => { const { planner } = renderSheet() fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' })) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) diff --git a/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx index 714194ff6..32d259e17 100644 --- a/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx @@ -65,17 +65,17 @@ describe('MFileMenuSheet', () => { const input = screen.getByPlaceholderText('files.notePlaceholder') fireEvent.change(input, { target: { value: ' Seat 22C ' } }) fireEvent.blur(input) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) - it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', () => { + it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', async () => { renderSheet() const input = screen.getByPlaceholderText('files.notePlaceholder') input.focus() fireEvent.change(input, { target: { value: 'Gate B' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' })) }) it('FE-MOB-FMENU-006: an unchanged note is not sent', () => { diff --git a/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx index 903ff5c50..8c418e49c 100644 --- a/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx @@ -88,7 +88,7 @@ describe('MFileTrashSheet', () => { const { planner } = renderSheet() await settled() fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0]) - expect(filesApi.restore).toHaveBeenCalledWith(1, 31) + await waitFor(() => expect(filesApi.restore).toHaveBeenCalledWith(1, 31)) await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()) expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1) expect(planner.toast.success).toHaveBeenCalledWith('files.toast.restored') @@ -112,7 +112,7 @@ describe('MFileTrashSheet', () => { const confirm = screen.getByRole('dialog', { name: 'common.delete' }) expect(within(confirm).getByText('files.confirm.permanentDelete')).toBeInTheDocument() fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' })) - expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31) + await waitFor(() => expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31)) await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()) expect(planner.toast.success).toHaveBeenCalledWith('files.toast.deleted') expect(planner.tripActions.loadFiles).not.toHaveBeenCalled() diff --git a/client/tests/unit/mobile/trip/MFilesTab.test.tsx b/client/tests/unit/mobile/trip/MFilesTab.test.tsx index 1231b5269..ae5dec715 100644 --- a/client/tests/unit/mobile/trip/MFilesTab.test.tsx +++ b/client/tests/unit/mobile/trip/MFilesTab.test.tsx @@ -191,7 +191,7 @@ describe('MFilesTab', () => { const { planner } = renderTab() expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument() fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0]) - expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1) + await waitFor(() => expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) diff --git a/client/tests/unit/sync/mutationQueue.files.test.ts b/client/tests/unit/sync/mutationQueue.files.test.ts new file mode 100644 index 000000000..cc0a1c48f --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.files.test.ts @@ -0,0 +1,100 @@ +/** + * mutationQueue replay of offline file writes (#2601): the file answers carry no + * ids of further links, so a replayed edit or star keeps the ones the local copy + * holds, the link answer ({ success, links }) writes no row, and a queued trash + * move takes the file off the local list for good. + */ +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; +import { fileRepo } from '../../../src/repo/fileRepo'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { buildTripFile } from '../../helpers/factories'; +import { server } from '../../helpers/msw/server'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('mutationQueue.flush — files', () => { + it('FE-SYNC-FILES-001: a replayed note, star and link keep the further links and send in order', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ + id: 12, + trip_id: 4, + description: null, + starred: 0, + linked_place_ids: [7], + linked_reservation_ids: [], + }) + ); + await fileRepo.update(4, 12, { description: 'Gate B' }); + await fileRepo.toggleStar(4, 12); + await fileRepo.addLink(4, 12, { reservation_id: 9 }); + const sent: string[] = []; + // The single-file routes answer the stored row without the link ids. + const row = (fields: object) => ({ file: { ...buildTripFile({ id: 12, trip_id: 4 }), ...fields } }); + server.use( + http.put('/api/trips/4/files/12', () => { + sent.push('PUT'); + return HttpResponse.json(row({ description: 'Gate B', starred: 0 })); + }), + http.patch('/api/trips/4/files/12/star', () => { + sent.push('PATCH'); + return HttpResponse.json(row({ description: 'Gate B', starred: 1 })); + }), + http.post('/api/trips/4/files/12/link', () => { + sent.push('POST'); + return HttpResponse.json({ success: true, links: [{ id: 3, file_id: 12, reservation_id: 9 }] }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(sent).toEqual(['PUT', 'PATCH', 'POST']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ + description: 'Gate B', + starred: 1, + linked_place_ids: [7], + linked_reservation_ids: [9], + }); + // The link answer named no file, so no other row appeared. + expect(await offlineDb.tripFiles.count()).toBe(1); + }); + + it('FE-SYNC-FILES-002: a replayed trash move and a restore leave the local list as the server has it', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4 })); + await fileRepo.delete(4, 12); + await fileRepo.restore(4, 20, buildTripFile({ id: 20, trip_id: 4, deleted_at: '2026-10-01 10:00:00' })); + server.use( + http.delete('/api/trips/4/files/12', () => HttpResponse.json({ success: true })), + http.post('/api/trips/4/files/20/restore', () => + HttpResponse.json({ file: buildTripFile({ id: 20, trip_id: 4, deleted_at: null, original_name: 'back.pdf' }) }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.tripFiles.get(12)).toBeUndefined(); + expect(await offlineDb.tripFiles.get(20)).toMatchObject({ original_name: 'back.pdf', deleted_at: null }); + }); +}); diff --git a/shared/src/i18n/ar/files.ts b/shared/src/i18n/ar/files.ts index d87107c88..e1335e0c6 100644 --- a/shared/src/i18n/ar/files.ts +++ b/shared/src/i18n/ar/files.ts @@ -76,5 +76,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'حذف جميع ملفات سلة المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.', 'files.noteLabel': 'ملاحظة', 'files.notePlaceholder': 'أضف ملاحظة...', + 'files.uploadNeedsConnection': 'يحتاج الرفع إلى اتصال. حاول مرة أخرى عندما يعود TREK إلى الاتصال.', + 'files.linkNeedsConnection': 'تحتاج إزالة هذا الربط إلى اتصال. حاول مرة أخرى عندما يعود TREK إلى الاتصال.', + 'files.placeFilesNeedConnection': + 'تحتاج الملفات إلى اتصال لرفعها. أزل الملفات الجديدة لحفظ المكان دون اتصال، أو احفظ عند عودتك إلى الاتصال.', }; export default files; diff --git a/shared/src/i18n/az/files.ts b/shared/src/i18n/az/files.ts index 8a4c8bc57..1433ce799 100644 --- a/shared/src/i18n/az/files.ts +++ b/shared/src/i18n/az/files.ts @@ -76,6 +76,11 @@ const files: TranslationStrings = { 'files.noteLabel': 'Qeyd', 'files.notePlaceholder': 'Qeyd əlavə edin...', + 'files.uploadNeedsConnection': 'Yükləmək üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.', + 'files.linkNeedsConnection': + 'Bu əlaqəni silmək üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.', + 'files.placeFilesNeedConnection': + 'Faylları yükləmək üçün bağlantı lazımdır. Məkanı oflayn saxlamaq üçün yeni faylları silin və ya yenidən onlayn olduqda saxlayın.', }; export default files; diff --git a/shared/src/i18n/br/files.ts b/shared/src/i18n/br/files.ts index 0215b0bf8..3b1296c6b 100644 --- a/shared/src/i18n/br/files.ts +++ b/shared/src/i18n/br/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Excluir permanentemente todos os arquivos na lixeira? Não é possível desfazer.', 'files.noteLabel': 'Nota', 'files.notePlaceholder': 'Adicione uma nota...', + 'files.uploadNeedsConnection': 'O envio precisa de conexão. Tente de novo quando o TREK voltar a ficar online.', + 'files.linkNeedsConnection': + 'Remover este vínculo precisa de conexão. Tente de novo quando o TREK voltar a ficar online.', + 'files.placeFilesNeedConnection': + 'Os arquivos precisam de conexão para serem enviados. Remova os novos para salvar o lugar offline ou salve quando estiver online de novo.', }; export default files; diff --git a/shared/src/i18n/ca/files.ts b/shared/src/i18n/ca/files.ts index d407b3463..43a797451 100644 --- a/shared/src/i18n/ca/files.ts +++ b/shared/src/i18n/ca/files.ts @@ -65,5 +65,10 @@ const files: TranslationStrings = { 'files.linkEmpty': 'Encara no hi ha llocs ni reserves per enllaçar', 'files.menu': 'Més opcions', 'files.uploadErrorSize': 'El fitxer és massa gran (màx. {max} MB)', + 'files.uploadNeedsConnection': 'Per pujar cal connexió. Torna-ho a provar quan TREK torni a estar en línia.', + 'files.linkNeedsConnection': + 'Per treure aquest enllaç cal connexió. Torna-ho a provar quan TREK torni a estar en línia.', + 'files.placeFilesNeedConnection': + 'Els fitxers necessiten connexió per pujar-se. Treu els nous per desar el lloc sense connexió, o desa quan tornis a estar en línia.', }; export default files; diff --git a/shared/src/i18n/cs/files.ts b/shared/src/i18n/cs/files.ts index edf67cd35..ad1c88921 100644 --- a/shared/src/i18n/cs/files.ts +++ b/shared/src/i18n/cs/files.ts @@ -67,5 +67,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Trvale smazat všechny soubory v koši? Tuto akci nelze vrátit.', 'files.noteLabel': 'Poznámka', 'files.notePlaceholder': 'Přidat poznámku...', + 'files.uploadNeedsConnection': 'Nahrávání vyžaduje připojení. Zkus to znovu, až bude TREK zase online.', + 'files.linkNeedsConnection': 'Odebrání tohoto propojení vyžaduje připojení. Zkus to znovu, až bude TREK zase online.', + 'files.placeFilesNeedConnection': + 'Soubory potřebují k nahrání připojení. Odeber nové, abys místo uložil offline, nebo ulož, až budeš zase online.', }; export default files; diff --git a/shared/src/i18n/de/files.ts b/shared/src/i18n/de/files.ts index feb35ce58..72c617226 100644 --- a/shared/src/i18n/de/files.ts +++ b/shared/src/i18n/de/files.ts @@ -64,5 +64,11 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Alle Dateien im Papierkorb endgültig löschen? Das kann nicht rückgängig gemacht werden.', 'files.noteLabel': 'Notiz', 'files.notePlaceholder': 'Notiz hinzufügen...', + 'files.uploadNeedsConnection': + 'Zum Hochladen braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.', + 'files.linkNeedsConnection': + 'Diese Verknüpfung lässt sich nur mit Verbindung entfernen. Versuch es noch einmal, sobald TREK wieder online ist.', + 'files.placeFilesNeedConnection': + 'Dateien brauchen zum Hochladen eine Verbindung. Entferne die neuen, um den Ort offline zu speichern, oder speichere, sobald du wieder online bist.', }; export default files; diff --git a/shared/src/i18n/en/files.ts b/shared/src/i18n/en/files.ts index 23256e1af..503206901 100644 --- a/shared/src/i18n/en/files.ts +++ b/shared/src/i18n/en/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.', 'files.noteLabel': 'Note', 'files.notePlaceholder': 'Add a note...', + 'files.uploadNeedsConnection': 'Uploading needs a connection. Try again once TREK is back online.', + 'files.linkNeedsConnection': 'Removing this link needs a connection. Try again once TREK is back online.', + 'files.placeFilesNeedConnection': + 'Files need a connection to upload. Remove the new ones to save the place offline, or save once you are back online.', }; export default files; diff --git a/shared/src/i18n/es/files.ts b/shared/src/i18n/es/files.ts index 06c29531b..687750f5b 100644 --- a/shared/src/i18n/es/files.ts +++ b/shared/src/i18n/es/files.ts @@ -64,5 +64,11 @@ const files: TranslationStrings = { 'files.toast.restoreError': 'Error al restaurar', 'files.confirm.permanentDelete': 'Eliminar este archivo permanentemente? No se puede deshacer.', 'files.confirm.emptyTrash': 'Eliminar todos los archivos de la papelera? No se puede deshacer.', + 'files.uploadNeedsConnection': + 'Para subir hace falta conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.', + 'files.linkNeedsConnection': + 'Para quitar este vínculo hace falta conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.', + 'files.placeFilesNeedConnection': + 'Los archivos necesitan conexión para subirse. Quita los nuevos para guardar el lugar sin conexión, o guarda cuando vuelvas a estar en línea.', }; export default files; diff --git a/shared/src/i18n/et/files.ts b/shared/src/i18n/et/files.ts index 32ee06436..bd488738d 100644 --- a/shared/src/i18n/et/files.ts +++ b/shared/src/i18n/et/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Kas kustutada kõik prügikasti failid jäädavalt? Seda ei saa tagasi võtta.', 'files.noteLabel': 'Märkus', 'files.notePlaceholder': 'Lisa märkus...', + 'files.uploadNeedsConnection': 'Üleslaadimiseks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.', + 'files.linkNeedsConnection': 'Selle lingi eemaldamiseks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.', + 'files.placeFilesNeedConnection': + 'Failide üleslaadimiseks on vaja ühendust. Eemalda uued, et koht võrguta salvestada, või salvesta, kui oled taas võrgus.', }; export default files; diff --git a/shared/src/i18n/fr/files.ts b/shared/src/i18n/fr/files.ts index d14de2e11..1a5138479 100644 --- a/shared/src/i18n/fr/files.ts +++ b/shared/src/i18n/fr/files.ts @@ -65,5 +65,9 @@ const files: TranslationStrings = { 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.', 'files.noteLabel': 'Note', 'files.notePlaceholder': 'Ajouter une note…', + 'files.uploadNeedsConnection': 'L’envoi nécessite une connexion. Réessaie quand TREK sera de nouveau en ligne.', + 'files.linkNeedsConnection': 'Retirer ce lien nécessite une connexion. Réessaie quand TREK sera de nouveau en ligne.', + 'files.placeFilesNeedConnection': + 'Les fichiers ont besoin d’une connexion pour être envoyés. Retire les nouveaux pour enregistrer le lieu hors ligne, ou enregistre une fois de nouveau en ligne.', }; export default files; diff --git a/shared/src/i18n/gr/files.ts b/shared/src/i18n/gr/files.ts index d16b97d59..22a8bcf0a 100644 --- a/shared/src/i18n/gr/files.ts +++ b/shared/src/i18n/gr/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Οριστική διαγραφή όλων των αρχείων στον κάδο; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.', 'files.noteLabel': 'Σημείωση', 'files.notePlaceholder': 'Προσθήκη σημείωσης...', + 'files.uploadNeedsConnection': 'Η μεταφόρτωση χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.', + 'files.linkNeedsConnection': + 'Η αφαίρεση αυτής της σύνδεσης χρειάζεται σύνδεση στο διαδίκτυο. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.', + 'files.placeFilesNeedConnection': + 'Τα αρχεία χρειάζονται σύνδεση για να μεταφορτωθούν. Αφαίρεσε τα νέα για να αποθηκεύσεις το μέρος εκτός σύνδεσης ή αποθήκευσε μόλις συνδεθείς ξανά.', }; export default files; diff --git a/shared/src/i18n/hu/files.ts b/shared/src/i18n/hu/files.ts index 0aea88248..c5cf332bc 100644 --- a/shared/src/i18n/hu/files.ts +++ b/shared/src/i18n/hu/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Véglegesen törlöd az összes kukába helyezett fájlt? Ez nem vonható vissza.', 'files.noteLabel': 'Megjegyzés', 'files.notePlaceholder': 'Megjegyzés hozzáadása...', + 'files.uploadNeedsConnection': 'A feltöltéshez kapcsolat kell. Próbáld újra, amikor a TREK ismét online.', + 'files.linkNeedsConnection': 'A hivatkozás eltávolításához kapcsolat kell. Próbáld újra, amikor a TREK ismét online.', + 'files.placeFilesNeedConnection': + 'A fájlok feltöltéséhez kapcsolat kell. Távolítsd el az újakat, hogy a helyet offline mentsd, vagy ments, amikor újra online vagy.', }; export default files; diff --git a/shared/src/i18n/id/files.ts b/shared/src/i18n/id/files.ts index 9ed47eb32..29421c585 100644 --- a/shared/src/i18n/id/files.ts +++ b/shared/src/i18n/id/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Hapus semua file di sampah secara permanen? Tindakan ini tidak bisa dibatalkan.', 'files.noteLabel': 'Catatan', 'files.notePlaceholder': 'Tambahkan catatan...', + 'files.uploadNeedsConnection': 'Mengunggah butuh koneksi. Coba lagi setelah TREK kembali online.', + 'files.linkNeedsConnection': 'Menghapus tautan ini butuh koneksi. Coba lagi setelah TREK kembali online.', + 'files.placeFilesNeedConnection': + 'File butuh koneksi untuk diunggah. Hapus file baru untuk menyimpan tempat secara offline, atau simpan setelah kamu online lagi.', }; export default files; diff --git a/shared/src/i18n/it/files.ts b/shared/src/i18n/it/files.ts index 6f61afd21..af119421d 100644 --- a/shared/src/i18n/it/files.ts +++ b/shared/src/i18n/it/files.ts @@ -66,5 +66,10 @@ const files: TranslationStrings = { 'Eliminare in modo permanente tutti i file nel cestino? Questa operazione non può essere annullata.', 'files.noteLabel': 'Nota', 'files.notePlaceholder': 'Aggiungi una nota...', + 'files.uploadNeedsConnection': 'Il caricamento richiede una connessione. Riprova quando TREK sarà di nuovo online.', + 'files.linkNeedsConnection': + 'Rimuovere questo collegamento richiede una connessione. Riprova quando TREK sarà di nuovo online.', + 'files.placeFilesNeedConnection': + 'I file richiedono una connessione per essere caricati. Rimuovi quelli nuovi per salvare il luogo offline, oppure salva quando sei di nuovo online.', }; export default files; diff --git a/shared/src/i18n/ja/files.ts b/shared/src/i18n/ja/files.ts index 23bbcd15a..68772954c 100644 --- a/shared/src/i18n/ja/files.ts +++ b/shared/src/i18n/ja/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'ゴミ箱内のファイルをすべて完全に削除しますか?元に戻せません。', 'files.noteLabel': 'メモ', 'files.notePlaceholder': 'メモを追加...', + 'files.uploadNeedsConnection': 'アップロードには接続が必要です。TREKがオンラインに戻ったらもう一度お試しください。', + 'files.linkNeedsConnection': 'このリンクの解除には接続が必要です。TREKがオンラインに戻ったらもう一度お試しください。', + 'files.placeFilesNeedConnection': + 'ファイルのアップロードには接続が必要です。新しいファイルを外して場所をオフラインで保存するか、オンラインに戻ってから保存してください。', }; export default files; diff --git a/shared/src/i18n/ko/files.ts b/shared/src/i18n/ko/files.ts index 8de20a72e..85823e8f9 100644 --- a/shared/src/i18n/ko/files.ts +++ b/shared/src/i18n/ko/files.ts @@ -61,5 +61,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '휴지통의 모든 파일을 영구 삭제할까요? 이 작업은 취소할 수 없습니다.', 'files.noteLabel': '메모', 'files.notePlaceholder': '메모 추가...', + 'files.uploadNeedsConnection': '업로드하려면 연결이 필요합니다. TREK이 다시 온라인이 되면 다시 시도하세요.', + 'files.linkNeedsConnection': + '이 연결을 해제하려면 인터넷 연결이 필요합니다. TREK이 다시 온라인이 되면 다시 시도하세요.', + 'files.placeFilesNeedConnection': + '파일을 업로드하려면 연결이 필요합니다. 새 파일을 빼고 장소를 오프라인으로 저장하거나, 다시 온라인이 되면 저장하세요.', }; export default files; diff --git a/shared/src/i18n/nl/files.ts b/shared/src/i18n/nl/files.ts index 41dddf1a2..8f26febec 100644 --- a/shared/src/i18n/nl/files.ts +++ b/shared/src/i18n/nl/files.ts @@ -65,5 +65,10 @@ const files: TranslationStrings = { 'Alle bestanden in de prullenbak permanent verwijderen? Dit kan niet ongedaan worden gemaakt.', 'files.noteLabel': 'Notitie', 'files.notePlaceholder': 'Notitie toevoegen...', + 'files.uploadNeedsConnection': 'Uploaden heeft een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.', + 'files.linkNeedsConnection': + 'Deze koppeling verwijderen heeft een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.', + 'files.placeFilesNeedConnection': + 'Bestanden hebben een verbinding nodig om te uploaden. Verwijder de nieuwe om de plek offline op te slaan, of sla op zodra je weer online bent.', }; export default files; diff --git a/shared/src/i18n/pl/files.ts b/shared/src/i18n/pl/files.ts index 2304221eb..94e45930c 100644 --- a/shared/src/i18n/pl/files.ts +++ b/shared/src/i18n/pl/files.ts @@ -71,5 +71,10 @@ const files: TranslationStrings = { 'Czy na pewno chcesz trwale usunąć wszystkie pliki z kosza? Tej operacji nie można cofnąć.', 'files.noteLabel': 'Notatka', 'files.notePlaceholder': 'Dodaj notatkę...', + 'files.uploadNeedsConnection': 'Przesyłanie wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.', + 'files.linkNeedsConnection': + 'Usunięcie tego powiązania wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.', + 'files.placeFilesNeedConnection': + 'Pliki wymagają połączenia, aby je przesłać. Usuń nowe, aby zapisać miejsce offline, albo zapisz, gdy znów będziesz online.', }; export default files; diff --git a/shared/src/i18n/ru/files.ts b/shared/src/i18n/ru/files.ts index 8efd13eb5..a5c2afc09 100644 --- a/shared/src/i18n/ru/files.ts +++ b/shared/src/i18n/ru/files.ts @@ -70,5 +70,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Безвозвратно удалить все файлы из корзины? Это действие нельзя отменить.', 'files.noteLabel': 'Заметка', 'files.notePlaceholder': 'Добавить заметку...', + 'files.uploadNeedsConnection': 'Для загрузки нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.', + 'files.linkNeedsConnection': + 'Чтобы убрать эту привязку, нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.', + 'files.placeFilesNeedConnection': + 'Для загрузки файлов нужно подключение. Уберите новые, чтобы сохранить место офлайн, или сохраните, когда снова будете онлайн.', }; export default files; diff --git a/shared/src/i18n/sk/files.ts b/shared/src/i18n/sk/files.ts index 53153735e..c6ad6b889 100644 --- a/shared/src/i18n/sk/files.ts +++ b/shared/src/i18n/sk/files.ts @@ -67,5 +67,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Trvalo zmazať všetky súbory v koši? Túto akciu nie je možné vrátiť.', 'files.noteLabel': 'Poznámka', 'files.notePlaceholder': 'Pridať poznámku...', + 'files.uploadNeedsConnection': 'Nahrávanie potrebuje pripojenie. Skús to znova, keď bude TREK opäť online.', + 'files.linkNeedsConnection': + 'Odstránenie tohto prepojenia potrebuje pripojenie. Skús to znova, keď bude TREK opäť online.', + 'files.placeFilesNeedConnection': + 'Súbory potrebujú na nahranie pripojenie. Odstráň nové, aby si miesto uložil offline, alebo ulož, keď budeš opäť online.', }; export default files; diff --git a/shared/src/i18n/sv/files.ts b/shared/src/i18n/sv/files.ts index 92d3f86b4..037d472d8 100644 --- a/shared/src/i18n/sv/files.ts +++ b/shared/src/i18n/sv/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Vill du radera alla filer i papperskorgen permanent? Detta går inte att ångra.', 'files.noteLabel': 'Notering', 'files.notePlaceholder': 'Lägg till en notering...', + 'files.uploadNeedsConnection': 'Uppladdning kräver en anslutning. Försök igen när TREK är online igen.', + 'files.linkNeedsConnection': 'Att ta bort den här länken kräver en anslutning. Försök igen när TREK är online igen.', + 'files.placeFilesNeedConnection': + 'Filer behöver en anslutning för att laddas upp. Ta bort de nya för att spara platsen offline, eller spara när du är online igen.', }; export default files; diff --git a/shared/src/i18n/th/files.ts b/shared/src/i18n/th/files.ts index ffcde09f4..d295ba442 100644 --- a/shared/src/i18n/th/files.ts +++ b/shared/src/i18n/th/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'ลบไฟล์ในถังขยะทั้งหมดอย่างถาวรใช่ไหม สิ่งนี้ไม่สามารถยกเลิกได้', 'files.noteLabel': 'หมายเหตุ', 'files.notePlaceholder': 'เพิ่มบันทึก...', + 'files.uploadNeedsConnection': 'การอัปโหลดต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์', + 'files.linkNeedsConnection': 'การนำลิงก์นี้ออกต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์', + 'files.placeFilesNeedConnection': + 'ไฟล์ต้องใช้การเชื่อมต่อเพื่ออัปโหลด นำไฟล์ใหม่ออกเพื่อบันทึกสถานที่แบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์', }; export default files; diff --git a/shared/src/i18n/tr/files.ts b/shared/src/i18n/tr/files.ts index 4eb3f63f5..57e5ec26e 100644 --- a/shared/src/i18n/tr/files.ts +++ b/shared/src/i18n/tr/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Çöp kutusundaki tüm dosyalar kalıcı olarak silinsin mi? Bu işlem geri alınamaz.', 'files.noteLabel': 'Not', 'files.notePlaceholder': 'Not ekleyin...', + 'files.uploadNeedsConnection': 'Yükleme için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.', + 'files.linkNeedsConnection': + 'Bu bağlantıyı kaldırmak için internet bağlantısı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.', + 'files.placeFilesNeedConnection': + 'Dosyaları yüklemek için bağlantı gerekiyor. Yeri çevrimdışı kaydetmek için yenilerini kaldır ya da yeniden çevrimiçi olduğunda kaydet.', }; export default files; diff --git a/shared/src/i18n/uk/files.ts b/shared/src/i18n/uk/files.ts index db4cb7bed..d11d26b74 100644 --- a/shared/src/i18n/uk/files.ts +++ b/shared/src/i18n/uk/files.ts @@ -70,5 +70,10 @@ const files: TranslationStrings = { 'files.notePlaceholder': 'Додати нотатку...', 'files.assignTransport': 'Транспорт', 'files.sourceTransport': 'Транспорт', + 'files.uploadNeedsConnection': 'Для завантаження потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.', + 'files.linkNeedsConnection': + 'Щоб прибрати цей зв’язок, потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.', + 'files.placeFilesNeedConnection': + 'Для завантаження файлів потрібне з’єднання. Приберіть нові, щоб зберегти місце офлайн, або збережіть, коли знову будете онлайн.', }; export default files; diff --git a/shared/src/i18n/vi/files.ts b/shared/src/i18n/vi/files.ts index 2ebcc6904..f4255173c 100644 --- a/shared/src/i18n/vi/files.ts +++ b/shared/src/i18n/vi/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Xóa vĩnh viễn tất cả các tập tin vào thùng rác? Điều này không thể hoàn tác được.', 'files.noteLabel': 'Ghi chú', 'files.notePlaceholder': 'Thêm ghi chú...', + 'files.uploadNeedsConnection': 'Tải lên cần có kết nối. Hãy thử lại khi TREK trực tuyến trở lại.', + 'files.linkNeedsConnection': 'Gỡ liên kết này cần có kết nối. Hãy thử lại khi TREK trực tuyến trở lại.', + 'files.placeFilesNeedConnection': + 'Tệp cần có kết nối để tải lên. Hãy bỏ các tệp mới để lưu địa điểm khi ngoại tuyến, hoặc lưu khi bạn trực tuyến trở lại.', }; export default files; diff --git a/shared/src/i18n/zh-TW/files.ts b/shared/src/i18n/zh-TW/files.ts index d1802bf05..788871ca3 100644 --- a/shared/src/i18n/zh-TW/files.ts +++ b/shared/src/i18n/zh-TW/files.ts @@ -61,5 +61,8 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '永久刪除回收站中的所有檔案?此操作無法撤銷。', 'files.noteLabel': '備註', 'files.notePlaceholder': '新增備註...', + 'files.uploadNeedsConnection': '上傳需要網路連線。請在 TREK 恢復連線後再試一次。', + 'files.linkNeedsConnection': '移除此連結需要網路連線。請在 TREK 恢復連線後再試一次。', + 'files.placeFilesNeedConnection': '檔案需要網路連線才能上傳。移除新檔案即可離線儲存此地點,或在恢復連線後再儲存。', }; export default files; diff --git a/shared/src/i18n/zh/files.ts b/shared/src/i18n/zh/files.ts index 5c1e8e98f..950f9e98c 100644 --- a/shared/src/i18n/zh/files.ts +++ b/shared/src/i18n/zh/files.ts @@ -61,5 +61,8 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '永久删除回收站中的所有文件?此操作无法撤销。', 'files.noteLabel': '备注', 'files.notePlaceholder': '添加备注...', + 'files.uploadNeedsConnection': '上传需要网络连接。请在 TREK 恢复在线后重试。', + 'files.linkNeedsConnection': '移除此关联需要网络连接。请在 TREK 恢复在线后重试。', + 'files.placeFilesNeedConnection': '文件需要网络连接才能上传。移除新文件即可离线保存该地点,或在恢复在线后再保存。', }; export default files;