From d803efd39d17099df0aecda2c8eaf190b46933a3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 19 Sep 2026 23:12:25 +0200 Subject: [PATCH] feat(collections): import a file into a list that already exists The import dialog always made a new list. It now asks where the places go: a new list, as before and still the default, or a list that is already there. The picker offers the lists the person may edit, which is their own plus a shared one where they are an editor or an admin, and starts on the list they have open. Adding to a list only ever adds. A place the list already holds is left exactly as it is, with its status, notes, rating and labels, and counted as already there in what the import reports; it is recognised the way a single save recognises one, by provider id, then name, then position. The list keeps its own name, colour, icon and description whatever the file says, a label the file brings is created only when the list has none of that name, and new places are appended after the ones already there so a manual order survives. The route is POST /api/addons/collections/:id/import, 200 because it adds to a list rather than making one, and both imports now write their places through the same helper. --- client/src/api/collections.ts | 4 + .../ImportCollectionModal.test.tsx | 99 ++++++++- .../Collections/ImportCollectionModal.tsx | 180 +++++++++++++-- client/src/pages/CollectionsPage.tsx | 5 +- .../pages/collections/useCollections.test.tsx | 61 ++++- .../src/pages/collections/useCollections.ts | 41 +++- .../collections/collections.controller.ts | 19 ++ .../src/nest/collections/collections.dto.ts | 2 + .../nest/collections/collections.service.ts | 209 +++++++++++++----- server/tests/e2e/collections.e2e.test.ts | 29 +++ .../unit/nest/collections.service.test.ts | 150 +++++++++++++ .../src/collection/collection-file.schema.ts | 23 +- shared/src/i18n/ar/collection.ts | 10 + shared/src/i18n/br/collection.ts | 10 + shared/src/i18n/ca/collection.ts | 10 + shared/src/i18n/cs/collection.ts | 10 + shared/src/i18n/de/collection.ts | 10 + shared/src/i18n/en/collection.ts | 10 + shared/src/i18n/es/collection.ts | 10 + shared/src/i18n/fr/collection.ts | 10 + shared/src/i18n/gr/collection.ts | 10 + shared/src/i18n/hu/collection.ts | 10 + shared/src/i18n/id/collection.ts | 10 + shared/src/i18n/it/collection.ts | 10 + shared/src/i18n/ja/collection.ts | 10 + shared/src/i18n/ko/collection.ts | 10 + shared/src/i18n/nl/collection.ts | 10 + shared/src/i18n/pl/collection.ts | 10 + shared/src/i18n/ru/collection.ts | 10 + shared/src/i18n/sv/collection.ts | 10 + shared/src/i18n/tr/collection.ts | 10 + shared/src/i18n/uk/collection.ts | 10 + shared/src/i18n/vi/collection.ts | 10 + shared/src/i18n/zh-TW/collection.ts | 10 + shared/src/i18n/zh/collection.ts | 10 + wiki/Collections.md | 7 +- 36 files changed, 978 insertions(+), 81 deletions(-) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 226bbfb55..71f1ed8dd 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -12,6 +12,7 @@ import type { CollectionImportablesResponse, CollectionFile, CollectionImportRequest, + CollectionImportIntoRequest, CollectionImportResult, CollectionGpxExport, CollectionGpxReadRequest, @@ -65,6 +66,9 @@ export const collectionsApi = { ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data), importFile: (body: CollectionImportRequest): Promise => ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data), + /** The same file into a list that already exists: it only ever adds to it. */ + importFileInto: (id: number, body: CollectionImportIntoRequest): Promise => + ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data), // The same list as GPX (#2301). A GPX is read by the server into a list file, // which then goes through importFile above like any other. exportGpx: (id: number): Promise => diff --git a/client/src/components/Collections/ImportCollectionModal.test.tsx b/client/src/components/Collections/ImportCollectionModal.test.tsx index 5bad3561c..6186935ef 100644 --- a/client/src/components/Collections/ImportCollectionModal.test.tsx +++ b/client/src/components/Collections/ImportCollectionModal.test.tsx @@ -1,7 +1,7 @@ -// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-016 +// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-026 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; -import type { CollectionFile } from '@trek/shared'; +import type { Collection, CollectionFile } from '@trek/shared'; import { useTranslation } from '../../i18n/TranslationContext'; import ImportCollectionModal from './ImportCollectionModal'; import type { GpxReader } from './collectionFile'; @@ -233,3 +233,98 @@ describe('ImportCollectionModal with a GPX (#2301)', () => { expect(screen.getByText('Choose a list file')).toBeInTheDocument(); }); }); + +// ── The same file into a list that is already there (#2301 follow-up) ──────── + +describe('ImportCollectionModal into an existing list', () => { + const lists = [ + { id: 7, owner_id: 1, name: 'Lisbon 2027', color: '#22c55e', place_count: 4 }, + { id: 9, owner_id: 1, name: 'Porto', color: '#3b82f6', place_count: 2 }, + ] as Collection[]; + + const intoProps = (over: Partial> = {}) => + renderModal({ onImportInto: vi.fn().mockResolvedValue(undefined), lists, ...over }); + + it('FE-COMP-COLLIMPORT-020: offers the choice only once a file has been read', async () => { + intoProps(); + expect(screen.queryByText('Add to a list')).not.toBeInTheDocument(); + + await choose(JSON.stringify(listFile)); + + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + expect(screen.getByText('New list')).toBeInTheDocument(); + // A new list stays the default, so nothing changes for anyone who ignores it. + expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument(); + expect(importButton()).toBeEnabled(); + }); + + it('FE-COMP-COLLIMPORT-021: keeps to a new list when there is nothing to add to', async () => { + renderModal({ onImportInto: vi.fn(), lists: [] }); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument()); + + expect(screen.queryByText('Add to a list')).not.toBeInTheDocument(); + }); + + it('FE-COMP-COLLIMPORT-022: adds the file to the list that was picked, and asks for no name', async () => { + const props = intoProps(); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + + await userEvent.click(screen.getByText('Add to a list')); + expect(screen.queryByDisplayValue('Lisbon')).not.toBeInTheDocument(); + await userEvent.click(screen.getByText('Porto')); + await userEvent.click(screen.getByRole('button', { name: 'Add to list' })); + + await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), 9)); + expect(props.onImport).not.toHaveBeenCalled(); + }); + + it('FE-COMP-COLLIMPORT-023: starts on the list that is open, whatever order they come in', async () => { + const props = intoProps({ defaultListId: 9 }); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + + await userEvent.click(screen.getByText('Add to a list')); + await userEvent.click(screen.getByRole('button', { name: 'Add to list' })); + + await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.anything(), 9)); + }); + + it('FE-COMP-COLLIMPORT-024: says what adding to a list does and does not do', async () => { + intoProps(); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + + await userEvent.click(screen.getByText('Add to a list')); + + expect(screen.getByText(/Places the list already has stay as they are/)).toBeInTheDocument(); + }); + + it('FE-COMP-COLLIMPORT-025: keeps the dialog open and says why when adding fails', async () => { + const onImportInto = vi.fn().mockRejectedValue( + Object.assign(new Error('x'), { response: { status: 403, data: { error: 'You have read-only access to this list' } } }), + ); + intoProps({ onImportInto }); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + + await userEvent.click(screen.getByText('Add to a list')); + await userEvent.click(screen.getByRole('button', { name: 'Add to list' })); + + await waitFor(() => expect(screen.getByText('You have read-only access to this list')).toBeInTheDocument()); + }); + + it('FE-COMP-COLLIMPORT-026: goes back to a new list with the name it started with', async () => { + const props = intoProps(); + await choose(JSON.stringify(listFile)); + await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument()); + + await userEvent.click(screen.getByText('Add to a list')); + await userEvent.click(screen.getByText('New list')); + await userEvent.click(importButton()); + + await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined)); + expect(props.onImportInto).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Collections/ImportCollectionModal.tsx b/client/src/components/Collections/ImportCollectionModal.tsx index 8a1dcbb5c..c8368c2ec 100644 --- a/client/src/components/Collections/ImportCollectionModal.tsx +++ b/client/src/components/Collections/ImportCollectionModal.tsx @@ -1,7 +1,7 @@ -import React, { useRef, useState } from 'react' -import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info } from 'lucide-react' +import React, { useMemo, useRef, useState } from 'react' +import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react' import Modal from '../shared/Modal' -import { MAX_COLLECTION_FILE_PLACES, type CollectionFile } from '@trek/shared' +import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared' import type { TranslationFn } from '../../types' import { getApiErrorMessage } from '../../types' import { @@ -15,6 +15,12 @@ import { interface ImportCollectionModalProps { onImport: (file: CollectionFile, name?: string) => Promise + /** Adds the file to a list that is already there. Without it the dialog only makes new ones. */ + onImportInto?: (file: CollectionFile, collectionId: number) => Promise + /** The lists this person may add to; the server applies the same rule again. */ + lists?: Collection[] + /** The list that is open, so the obvious target is the one already selected. */ + defaultListId?: number | null /** Reads a GPX into a list file (#2301); the server does the parsing. */ onReadGpx: GpxReader onClose: () => void @@ -50,6 +56,95 @@ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; place ) } +/** One of the two answers to "where do these places go", as a card you can tap. */ +function TargetCard({ active, icon: Icon, title, hint, onClick }: { + active: boolean + icon: typeof Plus + title: string + hint: string + onClick: () => void +}) { + return ( + + ) +} + +/** The lists the file may be added to, the open one first. */ +function ListChoice({ lists, selectedId, onSelect, t }: { + lists: Collection[] + selectedId: number | null + onSelect: (id: number) => void + t: TranslationFn +}) { + const [search, setSearch] = useState('') + const shown = useMemo(() => { + const q = search.trim().toLowerCase() + return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists + }, [lists, search]) + + return ( +
+ {lists.length > 5 && ( +
+ + setSearch(e.target.value)} + placeholder={t('collections.file.searchLists')} + className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent" + /> +
+ )} +
+ {shown.map(list => { + const active = list.id === selectedId + return ( + + ) + })} + {shown.length === 0 && ( +

{t('collections.noOtherLists')}

+ )} +
+
+ ) +} + /** * Read a list file or a GPX and make a list of it (#2198, #2301). * @@ -64,8 +159,15 @@ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; place * read by the server into the same kind of list file, and from then on the two * are one path: what goes to the import is a list file, through the same * contract the server validates against. + * + * The file can go into a list that is already there instead of a new one. That + * is a choice rather than the default, because the two do different things: a + * new list is the file as it stands, while adding to a list leaves everything + * in it alone and skips the places it already has. */ -export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t }: ImportCollectionModalProps): React.ReactElement { +export default function ImportCollectionModal({ + onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t, +}: ImportCollectionModalProps): React.ReactElement { const inputRef = useRef(null) const [file, setFile] = useState(null) const [leftovers, setLeftovers] = useState(null) @@ -75,6 +177,16 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t const [reading, setReading] = useState(false) const [busy, setBusy] = useState(false) const [dragging, setDragging] = useState(false) + const [target, setTarget] = useState<'new' | 'existing'>('new') + // The open list first: it is the one somebody importing from inside a list means. + const targets = useMemo(() => { + const all = lists ?? [] + const open = all.find(l => l.id === defaultListId) + return open ? [open, ...all.filter(l => l.id !== open.id)] : all + }, [lists, defaultListId]) + const [listId, setListId] = useState(() => targets[0]?.id ?? null) + const canChoose = !!onImportInto && targets.length > 0 + const intoExisting = canChoose && target === 'existing' const take = async (chosen: File | undefined) => { if (!chosen || reading) return @@ -109,8 +221,12 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t setError(null) setFailedMessage(null) try { - const trimmed = name.trim() - await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined) + if (intoExisting && listId != null) { + await onImportInto!(file, listId) + } else { + const trimmed = name.trim() + await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined) + } } catch (err) { setError('failed') setFailedMessage(getApiErrorMessage(err, t('common.error'))) @@ -140,11 +256,11 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t } @@ -203,17 +319,45 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t {leftovers && } - + {canChoose && ( +
+ setTarget('new')} + /> + setTarget('existing')} + /> +
+ )} - {!leftovers &&

{t('collections.file.hint')}

} + {intoExisting ? ( + <> + +

{t('collections.file.intoHint')}

+ + ) : ( + <> + + + {!leftovers &&

{t('collections.file.hint')}

} + + )} )} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index c5859060d..c2e21eb42 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -345,10 +345,13 @@ function CollectionsPageDesktop(): React.ReactElement { /> )} - {/* A list file as a new list — no active list needed, it makes one (#2198) */} + {/* A list file as a new list, or added to one that is already there (#2198, #2301) */} {c.showImportFile && ( c.setShowImportFile(false)} t={t} diff --git a/client/src/pages/collections/useCollections.test.tsx b/client/src/pages/collections/useCollections.test.tsx index 4a57168d1..65087a00e 100644 --- a/client/src/pages/collections/useCollections.test.tsx +++ b/client/src/pages/collections/useCollections.test.tsx @@ -1,4 +1,4 @@ -// FE-PAGE-COLL-001 to FE-PAGE-COLL-081 +// FE-PAGE-COLL-001 to FE-PAGE-COLL-085 // The Collections page hook. The collection store is replaced by a fixture so // every handler/branch can be driven directly; the categories request goes // through MSW, and the websocket module is already mocked in tests/setup.ts. @@ -1063,6 +1063,65 @@ describe('useCollections — export and import as a file', () => { expect(result.current.showImportFile).toBe(true) }) + it('FE-PAGE-COLL-082: a file added to another list lands on it and says what it did', async () => { + const seen: unknown[] = [] + server.use(http.post('/api/addons/collections/:id/import', async ({ request, params }) => { + seen.push({ id: params.id, body: await request.json() }) + return HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 2, skipped: 0, duplicates: 1 }) + })) + const { result } = await setup() + + await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) }) + + expect(seen[0]).toEqual({ id: '11', body: { file: listFile } }) + expect(store.loadAll).toHaveBeenCalled() + expect(navigate).toHaveBeenCalledWith('/collections/11') + expect(addToast).toHaveBeenCalledWith('2 added to Lisbon 2027, 1 were already there', 'success', undefined) + expect(result.current.showImportFile).toBe(false) + }) + + it('FE-PAGE-COLL-083: adding to the list that is open refreshes it instead of navigating', async () => { + server.use(http.post('/api/addons/collections/:id/import', () => + HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 2, skipped: 0, duplicates: 0 }))) + store.activeId = 11 + const { result } = await setup() + navigate.mockClear() + + await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) }) + + expect(store.refreshActive).toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalledWith('/collections/11') + expect(addToast).toHaveBeenCalledWith('2 places added to Lisbon 2027', 'success', undefined) + }) + + it('FE-PAGE-COLL-084: a file whose places were all there already says so instead of claiming a success', async () => { + server.use(http.post('/api/addons/collections/:id/import', () => + HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 0, skipped: 0, duplicates: 2 }))) + const { result } = await setup() + + await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) }) + + expect(addToast).toHaveBeenCalledWith('Every place in the file is already in Lisbon 2027', 'info', undefined) + }) + + it('FE-PAGE-COLL-085: only lists this person may write to are offered as a target', async () => { + const shared = (id: number, role: string | null) => ({ + ...collection({ id, is_owner: false }), + members: role === null ? [] : [{ user_id: 7, username: 'me', status: 'accepted', role }], + }) as unknown as Collection + store.collections = [ + collection({ id: 1 }), + shared(2, 'editor'), + shared(3, 'admin'), + shared(4, 'viewer'), + shared(5, null), + { ...shared(6, 'editor'), members: [{ user_id: 7, username: 'me', status: 'pending', role: 'editor' }] } as unknown as Collection, + ] + const { result } = await setup() + + expect(result.current.writableLists.map(l => l.id)).toEqual([1, 2, 3]) + }) + it('FE-PAGE-COLL-076: a renamed import carries the new name to the server', async () => { const seen: unknown[] = [] server.use(http.post('/api/addons/collections/import', async ({ request }) => { diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index 4ce1d0cd4..1857e0cd5 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -170,6 +170,17 @@ export function useCollections() { useEffect(() => { setShowShare(false) }, [activeId]) const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections]) + /** + * Lists a file may be added to: the person's own, plus a shared one where + * they are an editor or an admin. Same rule the server applies on the way in, + * so the import dialog never offers a list the import would refuse. + */ + const writableLists = useMemo( + () => collections.filter(c => c.is_owner !== false || (c.members ?? []).some( + m => m.user_id === currentUserId && m.status === 'accepted' && (m.role === 'editor' || m.role === 'admin'), + )), + [collections, currentUserId], + ) const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections]) // Labels are per-collection, so never apply them on the "All saved" union. @@ -260,6 +271,33 @@ export function useCollections() { setShowImportFile(false) }, [loadAll, navigate, toast, t]) + /** + * Read a chosen file into a list that is already there. + * + * Lands on that list, for the same reason a new one does. Nothing in it is + * overwritten: the server counts the places it already had and leaves them, + * and the toast says so rather than letting a file quietly do less than it + * looked like it would. + */ + const handleImportFileInto = useCallback(async (file: CollectionFile, collectionId: number) => { + const result = await collectionsApi.importFileInto(collectionId, { file }) + const name = (result.collection as Collection).name + const duplicates = result.duplicates ?? 0 + await loadAll() + if (activeId === collectionId) refreshActive() + else navigate(`/collections/${collectionId}`) + if (result.imported === 0 && duplicates > 0) { + toast.info(t('collections.file.doneIntoNothing', { name })) + } else if (duplicates > 0) { + toast.success(t('collections.file.doneIntoDuplicates', { count: result.imported, duplicates, name })) + } else if (result.skipped > 0) { + toast.info(t('collections.file.doneSkipped', { count: result.imported, skipped: result.skipped })) + } else { + toast.success(t('collections.file.doneInto', { count: result.imported, name })) + } + setShowImportFile(false) + }, [activeId, loadAll, refreshActive, navigate, toast, t]) + const handleDeleteList = useCallback(async () => { if (confirmDeleteList == null) return const id = confirmDeleteList @@ -467,7 +505,8 @@ export function useCollections() { showAddPlace, setShowAddPlace, handlePlaceAdded, showImport, setShowImport, exporting, handleExportList, - showImportFile, setShowImportFile, handleImportFile, handleReadGpx, + showImportFile, setShowImportFile, handleImportFile, handleImportFileInto, handleReadGpx, + writableLists, confirmDeleteList, setConfirmDeleteList, mobileRailOpen, setMobileRailOpen, showShare, setShowShare, handleAfterLeave, diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index ff5d3043b..f5aa17230 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -52,6 +52,7 @@ import { CollectionLabelUpdateDto, CollectionLabelAssignDto, CollectionImportDto, + CollectionImportIntoDto, CollectionGpxReadDto, } from './collections.dto'; import { PlaceRatingDto } from '../places/places.dto'; @@ -423,6 +424,24 @@ export class CollectionsController { return this.collections.exportCollectionGpx(user.id, Number(id)); } + /** + * The same file read into a list that already exists (#2301 follow-up). + * + * 200, unlike POST /import above: this one adds to a list rather than making + * one. Anyone who may add a place to the list may do it, and the socket id + * rides along so the browser that asked does not echo its own change. + */ + @Post(':id/import') + @HttpCode(200) + importIntoCollection( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: CollectionImportIntoDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.collections.importIntoCollection(user.id, Number(id), body, socketId); + } + @Get(':id/importable/:tripId') importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { return this.collections.importablePlaces(user.id, Number(id), Number(tripId)); diff --git a/server/src/nest/collections/collections.dto.ts b/server/src/nest/collections/collections.dto.ts index 5fcf7dd4d..8d85929c2 100644 --- a/server/src/nest/collections/collections.dto.ts +++ b/server/src/nest/collections/collections.dto.ts @@ -11,6 +11,7 @@ import { collectionSetStatusRequestSchema, collectionSetStatusManyRequestSchema, collectionSetStatusFromTripRequestSchema, + collectionImportIntoRequestSchema, collectionCopyToTripRequestSchema, collectionInviteRequestSchema, collectionInviteActionRequestSchema, @@ -42,6 +43,7 @@ export class CollectionPlaceUpdateDto extends createZodDto(collectionPlaceUpdate export class CollectionSetStatusDto extends createZodDto(collectionSetStatusRequestSchema) {} export class CollectionSetStatusManyDto extends createZodDto(collectionSetStatusManyRequestSchema) {} export class CollectionSetStatusFromTripDto extends createZodDto(collectionSetStatusFromTripRequestSchema) {} +export class CollectionImportIntoDto extends createZodDto(collectionImportIntoRequestSchema) {} export class CollectionCopyToTripDto extends createZodDto(collectionCopyToTripRequestSchema) {} export class CollectionInviteDto extends createZodDto(collectionInviteRequestSchema) {} export class CollectionInviteActionDto extends createZodDto(collectionInviteActionRequestSchema) {} diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index bcd5c07a8..8006c03e4 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -21,7 +21,9 @@ import { type PlaceMatchCandidate, type CollectionFileLabel, type CollectionFilePlace, + type CollectionFile, type CollectionImportRequest, + type CollectionImportIntoRequest, type CollectionImportResult, type CollectionGpxExport, type CollectionGpxReadRequest, @@ -444,28 +446,20 @@ export class CollectionsService { /** * Read a file back as a new list of the caller's own. * - * Always a new list. Importing into an existing one would mean deciding what - * happens to a place that is already there, and every answer to that is a - * merge somebody did not ask for; a fresh list is a thing they can look at - * and then move places out of, which the list UI already does well. - * * The places go in through the same INSERT the rest of the service uses, * inside one transaction, so a file that fails halfway leaves nothing * behind. Each place is re-validated against the file contract on the way * in — the body was validated once at the pipe, and this is the second pass * that lets one bad row be dropped instead of failing the whole import. + * + * A new list is empty, so every place in the file is written and the answer + * carries no duplicate count. Adding to a list that already exists is + * importIntoCollection below. */ importCollection(userId: number, body: CollectionImportRequest): CollectionImportResult { const file = body.file; const name = (body.name ?? file.name).trim().slice(0, 120) || file.name; - // The palette is instance-wide and read-only here: a file names a category, - // it does not get to create one. - const categoryIdByName = new Map(); - for (const c of this.db.all<{ id: number; name: string }>('SELECT id, name FROM categories')) { - categoryIdByName.set(c.name.trim().toLowerCase(), c.id); - } - const result = this.db.transaction(() => { const collection = this.createCollection(userId, { name, @@ -473,46 +467,9 @@ export class CollectionsService { color: file.color ?? undefined, icon: file.icon ?? undefined, }); - - const labelIdByName = new Map(); - for (const label of (file.labels ?? []).slice(0, MAX_COLLECTION_FILE_LABELS)) { - const key = label.name.trim().toLowerCase(); - if (!key || labelIdByName.has(key)) continue; - labelIdByName.set(key, this.insertImportedLabel(collection.id, label, labelIdByName.size)); - } - - const insertPlace = this.db.prepare(` - INSERT INTO collection_places ( - collection_id, owner_id, saved_by, name, description, lat, lng, address, - category_id, price, currency, notes, image_url, google_place_id, google_ftid, - osm_id, website, phone, status, links, sort_order - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const assignLabel = this.db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)'); - - let imported = 0; - let skipped = 0; - for (const raw of file.places) { - const parsed = collectionFilePlaceSchema.safeParse(raw); - if (!parsed.success) { skipped += 1; continue; } - const place = parsed.data; - const res = insertPlace.run( - collection.id, userId, userId, - place.name, place.description ?? null, place.lat ?? null, place.lng ?? null, place.address ?? null, - place.category ? (categoryIdByName.get(place.category.trim().toLowerCase()) ?? null) : null, - place.price ?? null, place.currency ?? null, place.notes ?? null, - place.image_url ?? null, place.google_place_id ?? null, place.google_ftid ?? null, - place.osm_id ?? null, place.website ?? null, place.phone ?? null, - place.status ?? 'idea', serializeLinks(place.links), imported, - ); - const placeId = Number(res.lastInsertRowid); - for (const labelName of place.labels ?? []) { - const labelId = labelIdByName.get(labelName.trim().toLowerCase()); - if (labelId) assignLabel.run(placeId, labelId); - } - imported += 1; - } - return { collectionId: collection.id, imported, skipped }; + const labels = this.labelIdsForFile(collection.id, file.labels); + const counts = this.writeFilePlaces(collection.id, userId, file, labels.byName, { skipDuplicates: false }); + return { collectionId: collection.id, ...counts }; }); const collection = this.getCollectionRow(result.collectionId); @@ -523,11 +480,155 @@ export class CollectionsService { }; } + /** + * Read the same file into a list that already exists (#2301 follow-up). + * + * Only ever adds. A place the list already holds, by the rule a single save + * uses (provider id, then name, then position), is counted and left exactly + * as it is: its status, notes, rating and labels belong to the people on the + * list, and a file is not a reason to overwrite them. The list keeps its own + * name, colour, icon and description too; what the file says about those is + * about the list it came from. + * + * Editing rights, not ownership: whoever may add a place here may add a file + * of them, and everyone on the list sees the result at once. + */ + importIntoCollection( + userId: number, id: number, body: CollectionImportIntoRequest, socketId?: string, + ): CollectionImportResult { + this.assertCanEdit(userId, id); + const file = body.file; + + const result = this.db.transaction(() => { + const labels = this.labelIdsForFile(id, file.labels); + const counts = this.writeFilePlaces(id, userId, file, labels.byName, { skipDuplicates: true }); + return { ...counts, labelsCreated: labels.created }; + }); + + // A file whose places were all already there can still have brought a label + // with it, and that is a change the other members should see. + if (result.imported > 0 || result.labelsCreated > 0) { + this.notifyCollectionUsers(id, socketId, 'collections:updated'); + } + const collection = this.getCollectionRow(id); + return { + collection: { ...collection, is_owner: collection.owner_id === userId, labels: this.loadLabelsByCollection(id) }, + imported: result.imported, + skipped: result.skipped, + duplicates: result.duplicates, + }; + } + + /** + * The file's labels as ids in this list: the ones it already has, matched by + * name, plus the ones it does not, created in the file's order. A file never + * renames or recolours a label that is already there. + */ + private labelIdsForFile( + collectionId: number, labels: CollectionFileLabel[] | undefined, + ): { byName: Map; created: number } { + const byName = new Map(); + for (const row of this.db.all<{ id: number; name: string }>('SELECT id, name FROM collection_labels WHERE collection_id = ?', collectionId)) { + byName.set(row.name.trim().toLowerCase(), row.id); + } + let sortOrder = this.db.get<{ m: number }>( + 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?', collectionId, + )!.m + 1; + let created = 0; + for (const label of (labels ?? []).slice(0, MAX_COLLECTION_FILE_LABELS)) { + const key = label.name.trim().toLowerCase(); + if (!key || byName.has(key)) continue; + byName.set(key, this.insertImportedLabel(collectionId, label, sortOrder)); + sortOrder += 1; + created += 1; + } + return { byName, created }; + } + + /** + * The places of a file, written into a list that is already there to take + * them. Both imports go through here, so a file behaves the same way + * whichever one read it. + * + * `skipDuplicates` is the whole difference. A list that was just created out + * of this file is empty and takes every place it carries, duplicates within + * the file included, exactly as it always has; a list people have been + * working in keeps what it has. + * + * New places are appended after the ones already there rather than renumbered + * from zero, so a manual order survives an import. + */ + private writeFilePlaces( + collectionId: number, + savedBy: number, + file: CollectionFile, + labelIdByName: Map, + opts: { skipDuplicates: boolean }, + ): { imported: number; skipped: number; duplicates: number } { + // The palette is instance-wide and read-only here: a file names a category, + // it does not get to create one. + const categoryIdByName = new Map(); + for (const c of this.db.all<{ id: number; name: string }>('SELECT id, name FROM categories')) { + categoryIdByName.set(c.name.trim().toLowerCase(), c.id); + } + const ownerId = this.ownerOf(collectionId); + const firstOrder = this.db.get<{ m: number }>( + 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_places WHERE collection_id = ?', collectionId, + )!.m + 1; + + const insertPlace = this.db.prepare(` + INSERT INTO collection_places ( + collection_id, owner_id, saved_by, name, description, lat, lng, address, + category_id, price, currency, notes, image_url, google_place_id, google_ftid, + osm_id, website, phone, status, links, sort_order + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const assignLabel = this.db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)'); + + let imported = 0; + let skipped = 0; + let duplicates = 0; + for (const raw of file.places) { + const parsed = collectionFilePlaceSchema.safeParse(raw); + if (!parsed.success) { skipped += 1; continue; } + const place = parsed.data; + // Rows written earlier in this same run count as already there, so a file + // that lists a place twice adds it once. + if (opts.skipDuplicates && this.findDuplicateCollectionPlace(collectionId, { + name: place.name, + lat: place.lat ?? null, + lng: place.lng ?? null, + google_place_id: place.google_place_id ?? null, + google_ftid: place.google_ftid ?? null, + osm_id: place.osm_id ?? null, + })) { + duplicates += 1; + continue; + } + const res = insertPlace.run( + collectionId, ownerId, savedBy, + place.name, place.description ?? null, place.lat ?? null, place.lng ?? null, place.address ?? null, + place.category ? (categoryIdByName.get(place.category.trim().toLowerCase()) ?? null) : null, + place.price ?? null, place.currency ?? null, place.notes ?? null, + place.image_url ?? null, place.google_place_id ?? null, place.google_ftid ?? null, + place.osm_id ?? null, place.website ?? null, place.phone ?? null, + place.status ?? 'idea', serializeLinks(place.links), firstOrder + imported, + ); + const placeId = Number(res.lastInsertRowid); + for (const labelName of place.labels ?? []) { + const labelId = labelIdByName.get(labelName.trim().toLowerCase()); + if (labelId) assignLabel.run(placeId, labelId); + } + imported += 1; + } + return { imported, skipped, duplicates }; + } + /** * A label straight from a file, without createLabel's duplicate check. * - * The caller already de-duplicates by name, the list is empty, and - * createLabel would notify the (nonexistent) members once per label. + * The caller de-duplicates by name, and one notification for the whole + * import is sent by the caller rather than one per label. */ private insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): number { const res = this.db.run( diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index fb26b1fa2..623018455 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -403,6 +403,35 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect((await request(server).get(`/api/addons/collections/${col.id}/export/gpx`).set('Cookie', sessionCookie(otherId))).status).toBe(404); }); + it('COLLECTIONS-E2E-083: a file goes into a list that already exists, without touching what is in it', async () => { + const as = (userId: number) => ({ Cookie: sessionCookie(userId) }); + const col = (await request(server).post('/api/addons/collections').set(as(ownerId)).send({ name: 'Keep me' })).body; + await request(server).post('/api/addons/collections/places').set(as(ownerId)) + .send({ collection_id: col.id, name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' }); + + const file = { + format: 'trek.collection', version: 1, name: 'From a friend', color: '#ef4444', + places: [{ name: 'Pinned', lat: 41.9, lng: 12.48 }, { name: 'New one' }], + }; + const added = await request(server).post(`/api/addons/collections/${col.id}/import`).set(as(ownerId)).send({ file }); + + expect(added.status).toBe(200); + expect(added.body).toMatchObject({ imported: 1, skipped: 0, duplicates: 1 }); + expect(added.body.collection).toMatchObject({ id: col.id, name: 'Keep me' }); + const detail = await request(server).get(`/api/addons/collections/${col.id}`).set(as(ownerId)); + expect(detail.body.places.map((p: { name: string }) => p.name)).toEqual(['Pinned', 'New one']); + expect(detail.body.places[0]).toMatchObject({ status: 'want' }); + }); + + it('COLLECTIONS-E2E-084: a list the caller may only read, or cannot see at all, takes no file', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Mine' })).body; + const file = { format: 'trek.collection', version: 1, name: 'Theirs', places: [{ name: 'Belém' }] }; + + expect((await request(server).post(`/api/addons/collections/${col.id}/import`).set('Cookie', sessionCookie(otherId)).send({ file })).status).toBe(404); + expect((await request(server).post(`/api/addons/collections/${col.id}/import`).send({ file })).status).toBe(401); + expect((await request(server).post(`/api/addons/collections/${col.id}/import`).set('Cookie', sessionCookie(ownerId)).send({ file: { name: 'no format' } })).status).toBe(400); + }); + // ── delete ─────────────────────────────────────────────────────────────── it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => { const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body; diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 60d5e1ae7..438e007cd 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -1284,6 +1284,156 @@ describe('exportCollection / importCollection (#2198)', () => { }); }); +// ── The same file into a list that is already there (#2301 follow-up) ──────── + +describe('importIntoCollection', () => { + const file = (places: unknown[], labels?: { name: string; color?: string }[]) => ({ + format: 'trek.collection' as const, version: 1, name: 'From a friend', + description: 'Their notes', color: '#ef4444', ...(labels ? { labels } : {}), places, + }); + + it('COLLECTIONS-SVC-120: adds the file to the list and leaves the list itself alone', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Lisbon', description: 'Mine', color: '#111827' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); + + const result = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'Alfama' }]) }); + + expect(result).toMatchObject({ imported: 2, skipped: 0, duplicates: 0 }); + const after = svc.getCollection(owner.id, col.id); + expect(after.collection).toMatchObject({ name: 'Lisbon', description: 'Mine', color: '#111827' }); + expect(after.places.map(p => p.name)).toEqual(['Time Out Market', 'Belém', 'Alfama']); + // Appended after what was there, so a manual order survives. + const orders = testDb.prepare('SELECT name, sort_order FROM collection_places WHERE collection_id = ? ORDER BY sort_order').all(col.id); + expect(orders).toEqual([ + { name: 'Time Out Market', sort_order: 0 }, + { name: 'Belém', sort_order: 1 }, + { name: 'Alfama', sort_order: 2 }, + ]); + }); + + it('COLLECTIONS-SVC-121: a place the list already has is counted and left exactly as it was', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Lisbon' }); + const mine = svc.savePlace(owner.id, { + collection_id: col.id, name: 'Time Out Market', notes: 'Before noon', status: 'visited', osm_id: 'node/1', + }).place!; + + const result = svc.importIntoCollection(owner.id, col.id, { file: file([ + { name: 'Time Out Market', notes: 'Overrated', status: 'idea' }, + { name: 'Renamed at the source', osm_id: 'node/1' }, + { name: 'Belém' }, + ]) }); + + expect(result).toMatchObject({ imported: 1, duplicates: 2, skipped: 0 }); + const places = svc.getCollection(owner.id, col.id).places; + expect(places.map(p => p.name)).toEqual(['Time Out Market', 'Belém']); + expect(places[0]).toMatchObject({ id: mine.id, notes: 'Before noon', status: 'visited' }); + }); + + it('COLLECTIONS-SVC-122: a file that lists the same place twice adds it once', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Lisbon' }); + + const result = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'belém' }]) }); + + expect(result).toMatchObject({ imported: 1, duplicates: 1 }); + }); + + it('COLLECTIONS-SVC-123: labels are matched by name, and only the missing ones are created', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Lisbon' }); + const must = svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); + + svc.importIntoCollection(owner.id, col.id, { file: file( + [{ name: 'Belém', labels: ['Must see', 'Rainy day'] }], + [{ name: 'must see', color: '#00ff00' }, { name: 'Rainy day', color: '#0000ff' }], + ) }); + + const labels = svc.getCollection(owner.id, col.id).collection.labels ?? []; + expect(labels.map(l => ({ name: l.name, color: l.color }))).toEqual([ + // The list's own label keeps its name and its colour. + { name: 'Must see', color: '#ff0000' }, + { name: 'Rainy day', color: '#0000ff' }, + ]); + const added = svc.getCollection(owner.id, col.id).places.find(p => p.name === 'Belém')!; + expect([...(added.label_ids ?? [])].sort()).toEqual([must.id, labels[1].id].sort()); + }); + + it('COLLECTIONS-SVC-124: an editor may add a file, a viewer may not, a stranger does not see the list', () => { + const owner = createUser(testDb).user; + const editor = createUser(testDb).user; + const viewer = createUser(testDb).user; + const stranger = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(col.id, editor.id, 'editor'); + addMember(col.id, viewer.id, 'viewer'); + + expect(svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) })).toMatchObject({ imported: 1 }); + expect(() => svc.importIntoCollection(viewer.id, col.id, { file: file([{ name: 'Alfama' }]) })).toThrow(); + try { + svc.importIntoCollection(stranger.id, col.id, { file: file([{ name: 'Alfama' }]) }); + throw new Error('should have thrown'); + } catch (e) { + expect((e as { status: number }).status).toBe(404); + } + expect(svc.getCollection(owner.id, col.id).places.map(p => p.name)).toEqual(['Belém']); + }); + + it('COLLECTIONS-SVC-125: the places stay the list owner\'s, saved by whoever brought the file', () => { + const owner = createUser(testDb).user; + const editor = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(col.id, editor.id, 'editor'); + + svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) }); + + const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE collection_id = ?').get(col.id); + expect(row).toEqual({ owner_id: owner.id, saved_by: editor.id }); + }); + + it('COLLECTIONS-SVC-126: everyone on the list is told once, and nothing is said when nothing changed', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(col.id, member.id, 'editor'); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Belém' }); + broadcastToUser.mockClear(); + + svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); + // Once per person on the list, whole import, not once per place. + expect(broadcastToUser).toHaveBeenCalledTimes(2); + expect(broadcastToUser).toHaveBeenCalledWith(member.id, expect.objectContaining({ type: 'collections:updated' }), undefined); + + broadcastToUser.mockClear(); + const again = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); + expect(again).toMatchObject({ imported: 0, duplicates: 1 }); + expect(broadcastToUser).not.toHaveBeenCalled(); + }); + + it('COLLECTIONS-SVC-127: a file that fails halfway leaves the list as it was', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Lisbon' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); + const real = testDb.prepare.bind(testDb); + const spy = vi.spyOn(testDb, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('INSERT INTO collection_places')) throw new Error('disk is full'); + return real(sql); + }); + + try { + expect(() => svc.importIntoCollection(owner.id, col.id, { + file: file([{ name: 'Belém' }], [{ name: 'Rainy day' }]), + })).toThrow('disk is full'); + } finally { + spy.mockRestore(); + } + + expect(svc.getCollection(owner.id, col.id).places.map(p => p.name)).toEqual(['Time Out Market']); + expect(svc.getCollection(owner.id, col.id).collection.labels ?? []).toEqual([]); + }); +}); + // ── The same list as GPX (#2301) ───────────────────────────────────────────── describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { diff --git a/shared/src/collection/collection-file.schema.ts b/shared/src/collection/collection-file.schema.ts index b41091688..3f7e4251e 100644 --- a/shared/src/collection/collection-file.schema.ts +++ b/shared/src/collection/collection-file.schema.ts @@ -172,8 +172,8 @@ export type CollectionFile = z.infer; /** * The import request: the file itself, plus the one thing the importer may - * decide rather than the file. A list always arrives as a NEW list, so there - * is no target id here and no way for a file to write into an existing one. + * decide rather than the file. This one always makes a NEW list, so it carries + * no target id; the list to add to is named in the URL of the route below. */ export const collectionImportRequestSchema = z.object({ file: collectionFileSchema, @@ -182,12 +182,29 @@ export const collectionImportRequestSchema = z.object({ }); export type CollectionImportRequest = z.infer; +/** + * The same file read into a list that already exists. + * + * Only the file: what the list is called, how it looks and what it says stay + * the list's own. Nothing in the request can overwrite a place either, so the + * worst a file can do to a list somebody else shares is add to it. + */ +export const collectionImportIntoRequestSchema = z.object({ + file: collectionFileSchema, +}); +export type CollectionImportIntoRequest = z.infer; + export interface CollectionImportResult { - /** The list as created, so the client can select it without a refetch. */ + /** The list as created or added to, so the client can select it without a refetch. */ collection: unknown; imported: number; /** Places the file carried that the contract refused. */ skipped: number; + /** + * Places the list already had, left exactly as they were. Only an import + * into an existing list can report any: a new list starts empty. + */ + duplicates?: number; } /* diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index bf2adea5a..4c21f5936 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'استيراد قائمة', 'collections.file.choose': 'اختر ملف قائمة', 'collections.file.confirm': 'استيراد', + 'collections.file.targetNew': 'قائمة جديدة', + 'collections.file.targetNewHint': 'الملف كقائمة مستقلة', + 'collections.file.targetExisting': 'الإضافة إلى قائمة', + 'collections.file.targetExistingHint': 'إلى قائمة لديك بالفعل', + 'collections.file.searchLists': 'البحث في القوائم', + 'collections.file.intoHint': 'الأماكن الموجودة في القائمة تبقى كما هي، وكذلك اسمها ولونها. وتُضاف التسميات القادمة من الملف.', + 'collections.file.confirmInto': 'إضافة إلى القائمة', + 'collections.file.doneInto': 'تمت إضافة {count} مكانًا إلى {name}', + 'collections.file.doneIntoDuplicates': 'تمت إضافة {count} إلى {name}، و{duplicates} كانت موجودة بالفعل', + 'collections.file.doneIntoNothing': 'كل الأماكن في الملف موجودة بالفعل في {name}', 'collections.file.change': 'تغيير', 'collections.file.labelCount': '{count} تسمية', 'collections.file.hint': 'تصل الأماكن كقائمة جديدة خاصة بك. أما التقييمات والأعضاء والصور المرفوعة فلا تأتي معها.', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index 90a3c3292..813b8210b 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importar lista', 'collections.file.choose': 'Escolher um arquivo de lista', 'collections.file.confirm': 'Importar', + 'collections.file.targetNew': 'Nova lista', + 'collections.file.targetNewHint': 'O arquivo como lista própria', + 'collections.file.targetExisting': 'Adicionar a uma lista', + 'collections.file.targetExistingHint': 'A uma que você já tem', + 'collections.file.searchLists': 'Buscar listas', + 'collections.file.intoHint': 'Os lugares que a lista já tem continuam como estão, assim como o nome e a cor. As etiquetas do arquivo são adicionadas.', + 'collections.file.confirmInto': 'Adicionar à lista', + 'collections.file.doneInto': '{count} lugares adicionados a {name}', + 'collections.file.doneIntoDuplicates': '{count} adicionados a {name}, {duplicates} já estavam lá', + 'collections.file.doneIntoNothing': 'Todos os lugares do arquivo já estão em {name}', 'collections.file.change': 'Trocar', 'collections.file.labelCount': '{count} etiquetas', 'collections.file.hint': diff --git a/shared/src/i18n/ca/collection.ts b/shared/src/i18n/ca/collection.ts index dc399c36f..66920ca11 100644 --- a/shared/src/i18n/ca/collection.ts +++ b/shared/src/i18n/ca/collection.ts @@ -142,6 +142,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importa una llista', 'collections.file.choose': 'Tria un fitxer de llista', 'collections.file.confirm': 'Importa', + 'collections.file.targetNew': 'Llista nova', + 'collections.file.targetNewHint': 'El fitxer com a llista pròpia', + 'collections.file.targetExisting': 'Afegeix a una llista', + 'collections.file.targetExistingHint': 'A una que ja tens', + 'collections.file.searchLists': 'Cerca llistes', + 'collections.file.intoHint': 'Els llocs que la llista ja té es queden com estan, igual que el nom i el color. Les etiquetes del fitxer s\'hi afegeixen.', + 'collections.file.confirmInto': 'Afegeix a la llista', + 'collections.file.doneInto': '{count} llocs afegits a {name}', + 'collections.file.doneIntoDuplicates': '{count} afegits a {name}, {duplicates} ja hi eren', + 'collections.file.doneIntoNothing': 'Tots els llocs del fitxer ja són a {name}', 'collections.file.change': 'Canvia', 'collections.file.labelCount': '{count} etiquetes', 'collections.file.hint': diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index bf0e4378a..fe7dc06a6 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Import seznamu', 'collections.file.choose': 'Vyberte soubor se seznamem', 'collections.file.confirm': 'Importovat', + 'collections.file.targetNew': 'Nový seznam', + 'collections.file.targetNewHint': 'Soubor jako samostatný seznam', + 'collections.file.targetExisting': 'Přidat do seznamu', + 'collections.file.targetExistingHint': 'Do seznamu, který už máte', + 'collections.file.searchLists': 'Hledat seznamy', + 'collections.file.intoHint': 'Místa, která seznam už má, zůstanou beze změny, stejně jako jeho název a barva. Štítky ze souboru se přidají.', + 'collections.file.confirmInto': 'Přidat do seznamu', + 'collections.file.doneInto': '{count} míst přidáno do {name}', + 'collections.file.doneIntoDuplicates': '{count} přidáno do {name}, {duplicates} už tam bylo', + 'collections.file.doneIntoNothing': 'Všechna místa ze souboru už v {name} jsou', 'collections.file.change': 'Změnit', 'collections.file.labelCount': 'Štítky: {count}', 'collections.file.hint': diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index 0a1c38c00..d8f796947 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -152,6 +152,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Liste importieren', 'collections.file.choose': 'Listendatei auswählen', 'collections.file.confirm': 'Importieren', + 'collections.file.targetNew': 'Neue Liste', + 'collections.file.targetNewHint': 'Die Datei als eigene Liste', + 'collections.file.targetExisting': 'Zu einer Liste hinzufügen', + 'collections.file.targetExistingHint': 'Zu einer, die es schon gibt', + 'collections.file.searchLists': 'Listen durchsuchen', + 'collections.file.intoHint': 'Orte, die die Liste schon hat, bleiben unverändert, ebenso ihr Name und ihre Farbe. Labels aus der Datei kommen dazu.', + 'collections.file.confirmInto': 'Hinzufügen', + 'collections.file.doneInto': '{count} Orte zu {name} hinzugefügt', + 'collections.file.doneIntoDuplicates': '{count} zu {name} hinzugefügt, {duplicates} waren schon drin', + 'collections.file.doneIntoNothing': 'Alle Orte der Datei sind schon in {name}', 'collections.file.change': 'Ändern', 'collections.file.labelCount': '{count} Labels', 'collections.file.hint': diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index f371d15a0..04c7f5d70 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Import a list', 'collections.file.choose': 'Choose a list file', 'collections.file.confirm': 'Import', + 'collections.file.targetNew': 'New list', + 'collections.file.targetNewHint': 'The file as a list of its own', + 'collections.file.targetExisting': 'Add to a list', + 'collections.file.targetExistingHint': 'Into one you already have', + 'collections.file.searchLists': 'Search lists', + 'collections.file.intoHint': 'Places the list already has stay as they are, and so do its name and colour. Labels the file brings are added.', + 'collections.file.confirmInto': 'Add to list', + 'collections.file.doneInto': '{count} places added to {name}', + 'collections.file.doneIntoDuplicates': '{count} added to {name}, {duplicates} were already there', + 'collections.file.doneIntoNothing': 'Every place in the file is already in {name}', 'collections.file.change': 'Change', 'collections.file.labelCount': '{count} labels', 'collections.file.hint': diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index be9be4699..a52d4347b 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importar una lista', 'collections.file.choose': 'Elegir un archivo de lista', 'collections.file.confirm': 'Importar', + 'collections.file.targetNew': 'Nueva lista', + 'collections.file.targetNewHint': 'El archivo como lista propia', + 'collections.file.targetExisting': 'Añadir a una lista', + 'collections.file.targetExistingHint': 'A una que ya tienes', + 'collections.file.searchLists': 'Buscar listas', + 'collections.file.intoHint': 'Los lugares que la lista ya tiene se quedan como están, igual que su nombre y su color. Las etiquetas del archivo se añaden.', + 'collections.file.confirmInto': 'Añadir a la lista', + 'collections.file.doneInto': '{count} lugares añadidos a {name}', + 'collections.file.doneIntoDuplicates': '{count} añadidos a {name}, {duplicates} ya estaban', + 'collections.file.doneIntoNothing': 'Todos los lugares del archivo ya están en {name}', 'collections.file.change': 'Cambiar', 'collections.file.labelCount': '{count} etiquetas', 'collections.file.hint': diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index ce458ec37..7517b358f 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importer une liste', 'collections.file.choose': 'Choisir un fichier de liste', 'collections.file.confirm': 'Importer', + 'collections.file.targetNew': 'Nouvelle liste', + 'collections.file.targetNewHint': 'Le fichier comme liste à part', + 'collections.file.targetExisting': 'Ajouter à une liste', + 'collections.file.targetExistingHint': 'À une liste que vous avez déjà', + 'collections.file.searchLists': 'Rechercher une liste', + 'collections.file.intoHint': 'Les lieux déjà dans la liste restent tels quels, comme son nom et sa couleur. Les étiquettes du fichier sont ajoutées.', + 'collections.file.confirmInto': 'Ajouter à la liste', + 'collections.file.doneInto': '{count} lieux ajoutés à {name}', + 'collections.file.doneIntoDuplicates': '{count} ajoutés à {name}, {duplicates} y étaient déjà', + 'collections.file.doneIntoNothing': 'Tous les lieux du fichier sont déjà dans {name}', 'collections.file.change': 'Changer', 'collections.file.labelCount': '{count} étiquettes', 'collections.file.hint': diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 3efc5d32b..329cd9a90 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Εισαγωγή λίστας', 'collections.file.choose': 'Επιλέξτε αρχείο λίστας', 'collections.file.confirm': 'Εισαγωγή', + 'collections.file.targetNew': 'Νέα λίστα', + 'collections.file.targetNewHint': 'Το αρχείο ως ξεχωριστή λίστα', + 'collections.file.targetExisting': 'Προσθήκη σε λίστα', + 'collections.file.targetExistingHint': 'Σε μία που έχετε ήδη', + 'collections.file.searchLists': 'Αναζήτηση λιστών', + 'collections.file.intoHint': 'Τα μέρη που έχει ήδη η λίστα μένουν όπως είναι, όπως και το όνομα και το χρώμα της. Οι ετικέτες του αρχείου προστίθενται.', + 'collections.file.confirmInto': 'Προσθήκη στη λίστα', + 'collections.file.doneInto': '{count} μέρη προστέθηκαν στη λίστα {name}', + 'collections.file.doneIntoDuplicates': '{count} προστέθηκαν στη λίστα {name}, {duplicates} υπήρχαν ήδη', + 'collections.file.doneIntoNothing': 'Όλα τα μέρη του αρχείου είναι ήδη στη λίστα {name}', 'collections.file.change': 'Αλλαγή', 'collections.file.labelCount': '{count} ετικέτες', 'collections.file.hint': diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index 1d1876411..0ce34a6cc 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -152,6 +152,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Lista importálása', 'collections.file.choose': 'Válassz listafájlt', 'collections.file.confirm': 'Importálás', + 'collections.file.targetNew': 'Új lista', + 'collections.file.targetNewHint': 'A fájl külön listaként', + 'collections.file.targetExisting': 'Hozzáadás listához', + 'collections.file.targetExistingHint': 'Egy meglévő listához', + 'collections.file.searchLists': 'Listák keresése', + 'collections.file.intoHint': 'A listában már meglévő helyek változatlanok maradnak, ahogy a neve és a színe is. A fájl címkéi bekerülnek.', + 'collections.file.confirmInto': 'Hozzáadás a listához', + 'collections.file.doneInto': '{count} hely hozzáadva ehhez: {name}', + 'collections.file.doneIntoDuplicates': '{count} hozzáadva ehhez: {name}, {duplicates} már benne volt', + 'collections.file.doneIntoNothing': 'A fájl minden helye már benne van ebben: {name}', 'collections.file.change': 'Csere', 'collections.file.labelCount': '{count} címke', 'collections.file.hint': diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index e95c35c0f..7015612c3 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Impor daftar', 'collections.file.choose': 'Pilih berkas daftar', 'collections.file.confirm': 'Impor', + 'collections.file.targetNew': 'Daftar baru', + 'collections.file.targetNewHint': 'Berkas sebagai daftar tersendiri', + 'collections.file.targetExisting': 'Tambahkan ke daftar', + 'collections.file.targetExistingHint': 'Ke daftar yang sudah ada', + 'collections.file.searchLists': 'Cari daftar', + 'collections.file.intoHint': 'Tempat yang sudah ada di daftar tetap seperti semula, begitu juga nama dan warnanya. Label dari berkas akan ditambahkan.', + 'collections.file.confirmInto': 'Tambahkan ke daftar', + 'collections.file.doneInto': '{count} tempat ditambahkan ke {name}', + 'collections.file.doneIntoDuplicates': '{count} ditambahkan ke {name}, {duplicates} sudah ada', + 'collections.file.doneIntoNothing': 'Semua tempat dalam berkas sudah ada di {name}', 'collections.file.change': 'Ganti', 'collections.file.labelCount': '{count} label', 'collections.file.hint': diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index 8ae2cca8a..857297d2e 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importa un elenco', 'collections.file.choose': 'Scegli un file di elenco', 'collections.file.confirm': 'Importa', + 'collections.file.targetNew': 'Nuova lista', + 'collections.file.targetNewHint': 'Il file come lista a sé', + 'collections.file.targetExisting': 'Aggiungi a una lista', + 'collections.file.targetExistingHint': 'A una che hai già', + 'collections.file.searchLists': 'Cerca liste', + 'collections.file.intoHint': 'I luoghi già presenti nella lista restano come sono, così come il suo nome e il suo colore. Le etichette del file vengono aggiunte.', + 'collections.file.confirmInto': 'Aggiungi alla lista', + 'collections.file.doneInto': '{count} luoghi aggiunti a {name}', + 'collections.file.doneIntoDuplicates': '{count} aggiunti a {name}, {duplicates} erano già presenti', + 'collections.file.doneIntoNothing': 'Tutti i luoghi del file sono già in {name}', 'collections.file.change': 'Cambia', 'collections.file.labelCount': '{count} etichette', 'collections.file.hint': diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index aeeb96d76..6811382b4 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'リストのインポート', 'collections.file.choose': 'リストファイルを選択', 'collections.file.confirm': 'インポート', + 'collections.file.targetNew': '新しいリスト', + 'collections.file.targetNewHint': 'ファイルを独立したリストに', + 'collections.file.targetExisting': 'リストに追加', + 'collections.file.targetExistingHint': 'すでにあるリストへ', + 'collections.file.searchLists': 'リストを検索', + 'collections.file.intoHint': 'リストにすでにある場所はそのまま残り、名前と色も変わりません。ファイルのラベルは追加されます。', + 'collections.file.confirmInto': 'リストに追加', + 'collections.file.doneInto': '{count} 件の場所を {name} に追加しました', + 'collections.file.doneIntoDuplicates': '{count} 件を {name} に追加しました。{duplicates} 件はすでにありました', + 'collections.file.doneIntoNothing': 'ファイルの場所はすべてすでに {name} にあります', 'collections.file.change': '変更', 'collections.file.labelCount': 'ラベル {count} 件', 'collections.file.hint': diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index 352a986bc..1db3109ae 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': '목록 가져오기', 'collections.file.choose': '목록 파일 선택', 'collections.file.confirm': '가져오기', + 'collections.file.targetNew': '새 리스트', + 'collections.file.targetNewHint': '파일을 별도 리스트로', + 'collections.file.targetExisting': '리스트에 추가', + 'collections.file.targetExistingHint': '이미 있는 리스트에', + 'collections.file.searchLists': '리스트 검색', + 'collections.file.intoHint': '리스트에 이미 있는 장소는 그대로 유지되고, 이름과 색도 그대로입니다. 파일의 라벨은 추가됩니다.', + 'collections.file.confirmInto': '리스트에 추가', + 'collections.file.doneInto': '{count}개 장소를 {name}에 추가했습니다', + 'collections.file.doneIntoDuplicates': '{count}개를 {name}에 추가했고, {duplicates}개는 이미 있었습니다', + 'collections.file.doneIntoNothing': '파일의 모든 장소가 이미 {name}에 있습니다', 'collections.file.change': '변경', 'collections.file.labelCount': '라벨 {count}개', 'collections.file.hint': '장소는 내 새 목록으로 들어옵니다. 평가, 멤버, 업로드한 사진은 함께 오지 않습니다.', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index 62d9e4c4a..55164bfa4 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Lijst importeren', 'collections.file.choose': 'Kies een lijstbestand', 'collections.file.confirm': 'Importeren', + 'collections.file.targetNew': 'Nieuwe lijst', + 'collections.file.targetNewHint': 'Het bestand als eigen lijst', + 'collections.file.targetExisting': 'Aan een lijst toevoegen', + 'collections.file.targetExistingHint': 'Aan een lijst die je al hebt', + 'collections.file.searchLists': 'Lijsten zoeken', + 'collections.file.intoHint': 'Plekken die de lijst al heeft blijven zoals ze zijn, net als de naam en de kleur. Labels uit het bestand komen erbij.', + 'collections.file.confirmInto': 'Aan lijst toevoegen', + 'collections.file.doneInto': '{count} plekken toegevoegd aan {name}', + 'collections.file.doneIntoDuplicates': '{count} toegevoegd aan {name}, {duplicates} stonden er al in', + 'collections.file.doneIntoNothing': 'Alle plekken uit het bestand staan al in {name}', 'collections.file.change': 'Wijzigen', 'collections.file.labelCount': '{count} labels', 'collections.file.hint': diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 01d8786ac..3d55b0e1e 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importuj listę', 'collections.file.choose': 'Wybierz plik listy', 'collections.file.confirm': 'Importuj', + 'collections.file.targetNew': 'Nowa lista', + 'collections.file.targetNewHint': 'Plik jako osobna lista', + 'collections.file.targetExisting': 'Dodaj do listy', + 'collections.file.targetExistingHint': 'Do listy, którą już masz', + 'collections.file.searchLists': 'Szukaj list', + 'collections.file.intoHint': 'Miejsca, które lista już ma, zostają bez zmian, tak samo jej nazwa i kolor. Etykiety z pliku zostaną dodane.', + 'collections.file.confirmInto': 'Dodaj do listy', + 'collections.file.doneInto': 'Dodano {count} miejsc do {name}', + 'collections.file.doneIntoDuplicates': 'Dodano {count} do {name}, {duplicates} już tam było', + 'collections.file.doneIntoNothing': 'Wszystkie miejsca z pliku są już w {name}', 'collections.file.change': 'Zmień', 'collections.file.labelCount': 'Etykiety: {count}', 'collections.file.hint': diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index 2267c9520..a121b99a2 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Импорт списка', 'collections.file.choose': 'Выберите файл списка', 'collections.file.confirm': 'Импортировать', + 'collections.file.targetNew': 'Новый список', + 'collections.file.targetNewHint': 'Файл как отдельный список', + 'collections.file.targetExisting': 'Добавить в список', + 'collections.file.targetExistingHint': 'В список, который уже есть', + 'collections.file.searchLists': 'Поиск списков', + 'collections.file.intoHint': 'Места, которые уже есть в списке, остаются без изменений, как и его название и цвет. Метки из файла добавляются.', + 'collections.file.confirmInto': 'Добавить в список', + 'collections.file.doneInto': '{count} мест добавлено в {name}', + 'collections.file.doneIntoDuplicates': '{count} добавлено в {name}, {duplicates} уже были там', + 'collections.file.doneIntoNothing': 'Все места из файла уже есть в {name}', 'collections.file.change': 'Изменить', 'collections.file.labelCount': 'Меток: {count}', 'collections.file.hint': diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index 4b40b16d7..6e2043a9d 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Importera lista', 'collections.file.choose': 'Välj en listfil', 'collections.file.confirm': 'Importera', + 'collections.file.targetNew': 'Ny lista', + 'collections.file.targetNewHint': 'Filen som en egen lista', + 'collections.file.targetExisting': 'Lägg till i en lista', + 'collections.file.targetExistingHint': 'I en du redan har', + 'collections.file.searchLists': 'Sök listor', + 'collections.file.intoHint': 'Platser som listan redan har lämnas som de är, liksom namn och färg. Etiketter från filen läggs till.', + 'collections.file.confirmInto': 'Lägg till i listan', + 'collections.file.doneInto': '{count} platser tillagda i {name}', + 'collections.file.doneIntoDuplicates': '{count} tillagda i {name}, {duplicates} fanns redan', + 'collections.file.doneIntoNothing': 'Alla platser i filen finns redan i {name}', 'collections.file.change': 'Byt', 'collections.file.labelCount': '{count} etiketter', 'collections.file.hint': diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index 91af91369..3a2195e6f 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Liste içe aktar', 'collections.file.choose': 'Bir liste dosyası seç', 'collections.file.confirm': 'İçe aktar', + 'collections.file.targetNew': 'Yeni liste', + 'collections.file.targetNewHint': 'Dosya kendi listesi olarak', + 'collections.file.targetExisting': 'Bir listeye ekle', + 'collections.file.targetExistingHint': 'Zaten sahip olduğun bir listeye', + 'collections.file.searchLists': 'Listelerde ara', + 'collections.file.intoHint': 'Listede zaten bulunan yerler olduğu gibi kalır, adı ve rengi de öyle. Dosyadaki etiketler eklenir.', + 'collections.file.confirmInto': 'Listeye ekle', + 'collections.file.doneInto': '{name} listesine {count} yer eklendi', + 'collections.file.doneIntoDuplicates': '{name} listesine {count} eklendi, {duplicates} tanesi zaten vardı', + 'collections.file.doneIntoNothing': 'Dosyadaki tüm yerler zaten {name} listesinde', 'collections.file.change': 'Değiştir', 'collections.file.labelCount': '{count} etiket', 'collections.file.hint': diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 1d5d815b2..4c2bf12cd 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Імпорт списку', 'collections.file.choose': 'Виберіть файл списку', 'collections.file.confirm': 'Імпортувати', + 'collections.file.targetNew': 'Новий список', + 'collections.file.targetNewHint': 'Файл як окремий список', + 'collections.file.targetExisting': 'Додати до списку', + 'collections.file.targetExistingHint': 'До списку, який уже є', + 'collections.file.searchLists': 'Пошук списків', + 'collections.file.intoHint': 'Місця, які вже є у списку, лишаються без змін, як і його назва та колір. Мітки з файлу додаються.', + 'collections.file.confirmInto': 'Додати до списку', + 'collections.file.doneInto': '{count} місць додано до {name}', + 'collections.file.doneIntoDuplicates': '{count} додано до {name}, {duplicates} уже були там', + 'collections.file.doneIntoNothing': 'Усі місця з файлу вже є у {name}', 'collections.file.change': 'Змінити', 'collections.file.labelCount': 'Міток: {count}', 'collections.file.hint': diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index 4f3a0bc9d..f325d62ce 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -151,6 +151,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': 'Nhập danh sách', 'collections.file.choose': 'Chọn tệp danh sách', 'collections.file.confirm': 'Nhập', + 'collections.file.targetNew': 'Danh sách mới', + 'collections.file.targetNewHint': 'Tệp thành một danh sách riêng', + 'collections.file.targetExisting': 'Thêm vào một danh sách', + 'collections.file.targetExistingHint': 'Vào danh sách bạn đã có', + 'collections.file.searchLists': 'Tìm danh sách', + 'collections.file.intoHint': 'Những địa điểm danh sách đã có vẫn giữ nguyên, tên và màu cũng vậy. Nhãn từ tệp sẽ được thêm vào.', + 'collections.file.confirmInto': 'Thêm vào danh sách', + 'collections.file.doneInto': 'Đã thêm {count} địa điểm vào {name}', + 'collections.file.doneIntoDuplicates': 'Đã thêm {count} vào {name}, {duplicates} đã có sẵn', + 'collections.file.doneIntoNothing': 'Mọi địa điểm trong tệp đều đã có trong {name}', 'collections.file.change': 'Đổi', 'collections.file.labelCount': '{count} nhãn', 'collections.file.hint': diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index 7e58ea733..1701268f1 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': '匯入清單', 'collections.file.choose': '選擇清單檔案', 'collections.file.confirm': '匯入', + 'collections.file.targetNew': '新增清單', + 'collections.file.targetNewHint': '把檔案當成獨立清單', + 'collections.file.targetExisting': '加入清單', + 'collections.file.targetExistingHint': '加入已有的清單', + 'collections.file.searchLists': '搜尋清單', + 'collections.file.intoHint': '清單中已有的地點維持原樣,名稱與顏色也不變。檔案帶來的標籤會加入。', + 'collections.file.confirmInto': '加入清單', + 'collections.file.doneInto': '已將 {count} 個地點加入 {name}', + 'collections.file.doneIntoDuplicates': '已將 {count} 個加入 {name},{duplicates} 個原本就在其中', + 'collections.file.doneIntoNothing': '檔案中的地點都已在 {name} 中', 'collections.file.change': '更換', 'collections.file.labelCount': '{count} 個標籤', 'collections.file.hint': '這些地點會成為你自己的新清單。評分、成員與上傳的照片不會一併匯入。', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index 272822c78..979d588a2 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -150,6 +150,16 @@ const collection: TranslationStrings = { 'collections.file.importTitle': '导入列表', 'collections.file.choose': '选择列表文件', 'collections.file.confirm': '导入', + 'collections.file.targetNew': '新建列表', + 'collections.file.targetNewHint': '把文件作为单独的列表', + 'collections.file.targetExisting': '添加到列表', + 'collections.file.targetExistingHint': '添加到已有的列表', + 'collections.file.searchLists': '搜索列表', + 'collections.file.intoHint': '列表中已有的地点保持不变,名称和颜色也不变。文件中的标签会被添加。', + 'collections.file.confirmInto': '添加到列表', + 'collections.file.doneInto': '已将 {count} 个地点添加到 {name}', + 'collections.file.doneIntoDuplicates': '已将 {count} 个添加到 {name},{duplicates} 个原本就在其中', + 'collections.file.doneIntoNothing': '文件中的地点都已在 {name} 中', 'collections.file.change': '更换', 'collections.file.labelCount': '{count} 个标签', 'collections.file.hint': '这些地点会成为你自己的新列表。评分、成员和上传的照片不会一起导入。', diff --git a/wiki/Collections.md b/wiki/Collections.md index 6b069c435..0e9bd09fb 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -105,7 +105,12 @@ Sharing works between people on the same TREK. To give a list to somebody who ru - **TREK list** downloads the open list as a `.trekcollection.json` file: the list's name, description and colour, its labels, and every place with its address, coordinates, notes, status, website, phone, category name and labels. This is the one to give to another TREK. - **GPX** downloads the places as waypoints in a `.gpx` file, for OsmAnd, Organic Maps, a Garmin, gpx.studio or any other app that reads GPX. Each waypoint carries the place's name, description and address, notes, website and category, which every such app shows. Labels, status, phone and the rest travel alongside in a TREK extension that other apps ignore, so a GPX made by TREK comes back into TREK complete. A place without coordinates cannot be a waypoint: it is left out, and TREK tells you how many were. -**Import** sits next to **New list** in the lists rail, as the button with the upload arrow. Pick a TREK list file or a GPX file and TREK shows what is in it before anything happens: the name, how many places, how many labels. Rename it there if you like, then import. The file always becomes a **new list of your own**, so nothing you already have can be overwritten, and you land on the new list when it is done. +**Import** sits next to **New list** in the lists rail, as the button with the upload arrow. Pick a TREK list file or a GPX file and TREK shows what is in it before anything happens: the name, how many places, how many labels. Then choose where the places go, and you land on that list when it is done. + +- **New list** is the default: the file becomes a list of your own, under a name you can change right there. +- **Add to a list** puts the places into a list you already have. The list is picked from the ones you may edit, which is your own lists plus a shared one where you are an editor or an admin; the list you have open is preselected. + +Adding to a list only ever adds. A place the list already has is left exactly as it is, with its status, notes, rating and labels, and counted as already there when the import reports what it did. TREK recognises it the same way it does when you save a single place: by the provider it came from, then by name, then by position. The list keeps its own name, colour, icon and description, whatever the file says about the list it came from; a label the file brings that the list does not have is created, and one it already has keeps its name and colour. From a GPX, every waypoint becomes a place, and so does a route point somebody named. A track is a line rather than a place, so it is not imported, and the preview says how many track points were left behind. A waypoint's type (OsmAnd files its favourites into groups this way) becomes the place's category when your palette has one of that name. A waypoint without a name is called "Waypoint" and its number in the file; one without usable coordinates is skipped and counted.