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.
This commit is contained in:
Maurice
2026-09-19 23:12:25 +02:00
parent 7cfec6452d
commit d803efd39d
36 changed files with 978 additions and 81 deletions
+4
View File
@@ -12,6 +12,7 @@ import type {
CollectionImportablesResponse, CollectionImportablesResponse,
CollectionFile, CollectionFile,
CollectionImportRequest, CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult, CollectionImportResult,
CollectionGpxExport, CollectionGpxExport,
CollectionGpxReadRequest, CollectionGpxReadRequest,
@@ -65,6 +66,9 @@ export const collectionsApi = {
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data), ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> => importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data), 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<CollectionImportResult> =>
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, // 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. // which then goes through importFile above like any other.
exportGpx: (id: number): Promise<CollectionGpxExport> => exportGpx: (id: number): Promise<CollectionGpxExport> =>
@@ -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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; 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 { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal'; import ImportCollectionModal from './ImportCollectionModal';
import type { GpxReader } from './collectionFile'; import type { GpxReader } from './collectionFile';
@@ -233,3 +233,98 @@ describe('ImportCollectionModal with a GPX (#2301)', () => {
expect(screen.getByText('Choose a list file')).toBeInTheDocument(); 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<React.ComponentProps<typeof ImportCollectionModal>> = {}) =>
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();
});
});
@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react' import React, { useMemo, useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info } from 'lucide-react' import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal' 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 type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import { import {
@@ -15,6 +15,12 @@ import {
interface ImportCollectionModalProps { interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void> onImport: (file: CollectionFile, name?: string) => Promise<void>
/** Adds the file to a list that is already there. Without it the dialog only makes new ones. */
onImportInto?: (file: CollectionFile, collectionId: number) => Promise<void>
/** 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. */ /** Reads a GPX into a list file (#2301); the server does the parsing. */
onReadGpx: GpxReader onReadGpx: GpxReader
onClose: () => void 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 (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 px-3 py-3 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center ${active ? 'bg-accent text-accent-text' : 'bg-surface-input text-content-muted'}`}>
<Icon size={15} />
</span>
<span className="text-[13px] font-semibold text-content">{title}</span>
<span className="text-[11.5px] text-content-faint leading-snug">{hint}</span>
{active && (
<span className="absolute top-2 right-2 w-4 h-4 rounded-full bg-accent text-accent-text flex items-center justify-center">
<Check size={11} strokeWidth={3} />
</span>
)}
</button>
)
}
/** 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 (
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={search}
onChange={e => 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"
/>
</div>
)}
<div className="flex flex-col gap-1 max-h-[34vh] overflow-y-auto -mx-1 px-1">
{shown.map(list => {
const active = list.id === selectedId
return (
<button
key={list.id}
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge bg-surface-card hover:bg-surface-hover'
}`}
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{active && <Check size={16} className="shrink-0 text-accent" />}
</button>
)
})}
{shown.length === 0 && (
<p className="text-center text-[13px] text-content-faint py-6">{t('collections.noOtherLists')}</p>
)}
</div>
</div>
)
}
/** /**
* Read a list file or a GPX and make a list of it (#2198, #2301). * 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 * 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 * are one path: what goes to the import is a list file, through the same
* contract the server validates against. * 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<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null) const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null) const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
@@ -75,6 +177,16 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
const [reading, setReading] = useState(false) const [reading, setReading] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [dragging, setDragging] = 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<number | null>(() => targets[0]?.id ?? null)
const canChoose = !!onImportInto && targets.length > 0
const intoExisting = canChoose && target === 'existing'
const take = async (chosen: File | undefined) => { const take = async (chosen: File | undefined) => {
if (!chosen || reading) return if (!chosen || reading) return
@@ -109,8 +221,12 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
setError(null) setError(null)
setFailedMessage(null) setFailedMessage(null)
try { try {
const trimmed = name.trim() if (intoExisting && listId != null) {
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined) await onImportInto!(file, listId)
} else {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
}
} catch (err) { } catch (err) {
setError('failed') setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error'))) setFailedMessage(getApiErrorMessage(err, t('common.error')))
@@ -140,11 +256,11 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={!file || busy || !name.trim() || nothingToImport} disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2" className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
> >
{busy && <Loader2 size={14} className="animate-spin" />} {busy && <Loader2 size={14} className="animate-spin" />}
{t('collections.file.confirm')} {intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button> </button>
</div> </div>
} }
@@ -203,17 +319,45 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />} {leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
<label className="flex flex-col gap-1.5"> {canChoose && (
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span> <div className="grid grid-cols-2 gap-2">
<input <TargetCard
value={name} active={target === 'new'}
onChange={e => setName(e.target.value)} icon={Plus}
maxLength={120} title={t('collections.file.targetNew')}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent" hint={t('collections.file.targetNewHint')}
/> onClick={() => setTarget('new')}
</label> />
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>} {intoExisting ? (
<>
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className="text-[11.5px] text-content-faint">{t('collections.file.intoHint')}</p>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
</> </>
)} )}
+4 -1
View File
@@ -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.showImportFile && (
<ImportCollectionModal <ImportCollectionModal
onImport={c.handleImportFile} onImport={c.handleImportFile}
onImportInto={c.handleImportFileInto}
lists={c.writableLists}
defaultListId={typeof c.activeId === 'number' ? c.activeId : null}
onReadGpx={c.handleReadGpx} onReadGpx={c.handleReadGpx}
onClose={() => c.setShowImportFile(false)} onClose={() => c.setShowImportFile(false)}
t={t} t={t}
@@ -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 // The Collections page hook. The collection store is replaced by a fixture so
// every handler/branch can be driven directly; the categories request goes // every handler/branch can be driven directly; the categories request goes
// through MSW, and the websocket module is already mocked in tests/setup.ts. // 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) 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 () => { it('FE-PAGE-COLL-076: a renamed import carries the new name to the server', async () => {
const seen: unknown[] = [] const seen: unknown[] = []
server.use(http.post('/api/addons/collections/import', async ({ request }) => { server.use(http.post('/api/addons/collections/import', async ({ request }) => {
+40 -1
View File
@@ -170,6 +170,17 @@ export function useCollections() {
useEffect(() => { setShowShare(false) }, [activeId]) useEffect(() => { setShowShare(false) }, [activeId])
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections]) 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]) const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
// Labels are per-collection, so never apply them on the "All saved" union. // Labels are per-collection, so never apply them on the "All saved" union.
@@ -260,6 +271,33 @@ export function useCollections() {
setShowImportFile(false) setShowImportFile(false)
}, [loadAll, navigate, toast, t]) }, [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 () => { const handleDeleteList = useCallback(async () => {
if (confirmDeleteList == null) return if (confirmDeleteList == null) return
const id = confirmDeleteList const id = confirmDeleteList
@@ -467,7 +505,8 @@ export function useCollections() {
showAddPlace, setShowAddPlace, handlePlaceAdded, showAddPlace, setShowAddPlace, handlePlaceAdded,
showImport, setShowImport, showImport, setShowImport,
exporting, handleExportList, exporting, handleExportList,
showImportFile, setShowImportFile, handleImportFile, handleReadGpx, showImportFile, setShowImportFile, handleImportFile, handleImportFileInto, handleReadGpx,
writableLists,
confirmDeleteList, setConfirmDeleteList, confirmDeleteList, setConfirmDeleteList,
mobileRailOpen, setMobileRailOpen, mobileRailOpen, setMobileRailOpen,
showShare, setShowShare, handleAfterLeave, showShare, setShowShare, handleAfterLeave,
@@ -52,6 +52,7 @@ import {
CollectionLabelUpdateDto, CollectionLabelUpdateDto,
CollectionLabelAssignDto, CollectionLabelAssignDto,
CollectionImportDto, CollectionImportDto,
CollectionImportIntoDto,
CollectionGpxReadDto, CollectionGpxReadDto,
} from './collections.dto'; } from './collections.dto';
import { PlaceRatingDto } from '../places/places.dto'; import { PlaceRatingDto } from '../places/places.dto';
@@ -423,6 +424,24 @@ export class CollectionsController {
return this.collections.exportCollectionGpx(user.id, Number(id)); 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') @Get(':id/importable/:tripId')
importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
return this.collections.importablePlaces(user.id, Number(id), Number(tripId)); return this.collections.importablePlaces(user.id, Number(id), Number(tripId));
@@ -11,6 +11,7 @@ import {
collectionSetStatusRequestSchema, collectionSetStatusRequestSchema,
collectionSetStatusManyRequestSchema, collectionSetStatusManyRequestSchema,
collectionSetStatusFromTripRequestSchema, collectionSetStatusFromTripRequestSchema,
collectionImportIntoRequestSchema,
collectionCopyToTripRequestSchema, collectionCopyToTripRequestSchema,
collectionInviteRequestSchema, collectionInviteRequestSchema,
collectionInviteActionRequestSchema, collectionInviteActionRequestSchema,
@@ -42,6 +43,7 @@ export class CollectionPlaceUpdateDto extends createZodDto(collectionPlaceUpdate
export class CollectionSetStatusDto extends createZodDto(collectionSetStatusRequestSchema) {} export class CollectionSetStatusDto extends createZodDto(collectionSetStatusRequestSchema) {}
export class CollectionSetStatusManyDto extends createZodDto(collectionSetStatusManyRequestSchema) {} export class CollectionSetStatusManyDto extends createZodDto(collectionSetStatusManyRequestSchema) {}
export class CollectionSetStatusFromTripDto extends createZodDto(collectionSetStatusFromTripRequestSchema) {} export class CollectionSetStatusFromTripDto extends createZodDto(collectionSetStatusFromTripRequestSchema) {}
export class CollectionImportIntoDto extends createZodDto(collectionImportIntoRequestSchema) {}
export class CollectionCopyToTripDto extends createZodDto(collectionCopyToTripRequestSchema) {} export class CollectionCopyToTripDto extends createZodDto(collectionCopyToTripRequestSchema) {}
export class CollectionInviteDto extends createZodDto(collectionInviteRequestSchema) {} export class CollectionInviteDto extends createZodDto(collectionInviteRequestSchema) {}
export class CollectionInviteActionDto extends createZodDto(collectionInviteActionRequestSchema) {} export class CollectionInviteActionDto extends createZodDto(collectionInviteActionRequestSchema) {}
@@ -21,7 +21,9 @@ import {
type PlaceMatchCandidate, type PlaceMatchCandidate,
type CollectionFileLabel, type CollectionFileLabel,
type CollectionFilePlace, type CollectionFilePlace,
type CollectionFile,
type CollectionImportRequest, type CollectionImportRequest,
type CollectionImportIntoRequest,
type CollectionImportResult, type CollectionImportResult,
type CollectionGpxExport, type CollectionGpxExport,
type CollectionGpxReadRequest, type CollectionGpxReadRequest,
@@ -444,28 +446,20 @@ export class CollectionsService {
/** /**
* Read a file back as a new list of the caller's own. * 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, * 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 * inside one transaction, so a file that fails halfway leaves nothing
* behind. Each place is re-validated against the file contract on the way * 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 * 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. * 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 { importCollection(userId: number, body: CollectionImportRequest): CollectionImportResult {
const file = body.file; const file = body.file;
const name = (body.name ?? file.name).trim().slice(0, 120) || file.name; 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<string, number>();
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 result = this.db.transaction(() => {
const collection = this.createCollection(userId, { const collection = this.createCollection(userId, {
name, name,
@@ -473,46 +467,9 @@ export class CollectionsService {
color: file.color ?? undefined, color: file.color ?? undefined,
icon: file.icon ?? undefined, icon: file.icon ?? undefined,
}); });
const labels = this.labelIdsForFile(collection.id, file.labels);
const labelIdByName = new Map<string, number>(); const counts = this.writeFilePlaces(collection.id, userId, file, labels.byName, { skipDuplicates: false });
for (const label of (file.labels ?? []).slice(0, MAX_COLLECTION_FILE_LABELS)) { return { collectionId: collection.id, ...counts };
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 collection = this.getCollectionRow(result.collectionId); 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<string, number>; created: number } {
const byName = new Map<string, number>();
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<string, number>,
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<string, number>();
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. * A label straight from a file, without createLabel's duplicate check.
* *
* The caller already de-duplicates by name, the list is empty, and * The caller de-duplicates by name, and one notification for the whole
* createLabel would notify the (nonexistent) members once per label. * import is sent by the caller rather than one per label.
*/ */
private insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): number { private insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): number {
const res = this.db.run( const res = this.db.run(
+29
View File
@@ -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); 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 ─────────────────────────────────────────────────────────────── // ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => { 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; const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;
@@ -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) ───────────────────────────────────────────── // ── The same list as GPX (#2301) ─────────────────────────────────────────────
describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { describe('exportCollectionGpx / readCollectionGpx (#2301)', () => {
@@ -172,8 +172,8 @@ export type CollectionFile = z.infer<typeof collectionFileSchema>;
/** /**
* The import request: the file itself, plus the one thing the importer may * 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 * decide rather than the file. This one always makes a NEW list, so it carries
* is no target id here and no way for a file to write into an existing one. * no target id; the list to add to is named in the URL of the route below.
*/ */
export const collectionImportRequestSchema = z.object({ export const collectionImportRequestSchema = z.object({
file: collectionFileSchema, file: collectionFileSchema,
@@ -182,12 +182,29 @@ export const collectionImportRequestSchema = z.object({
}); });
export type CollectionImportRequest = z.infer<typeof collectionImportRequestSchema>; export type CollectionImportRequest = z.infer<typeof collectionImportRequestSchema>;
/**
* 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<typeof collectionImportIntoRequestSchema>;
export interface CollectionImportResult { 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; collection: unknown;
imported: number; imported: number;
/** Places the file carried that the contract refused. */ /** Places the file carried that the contract refused. */
skipped: number; 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;
} }
/* /*
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'استيراد قائمة', 'collections.file.importTitle': 'استيراد قائمة',
'collections.file.choose': 'اختر ملف قائمة', 'collections.file.choose': 'اختر ملف قائمة',
'collections.file.confirm': 'استيراد', '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.change': 'تغيير',
'collections.file.labelCount': '{count} تسمية', 'collections.file.labelCount': '{count} تسمية',
'collections.file.hint': 'تصل الأماكن كقائمة جديدة خاصة بك. أما التقييمات والأعضاء والصور المرفوعة فلا تأتي معها.', 'collections.file.hint': 'تصل الأماكن كقائمة جديدة خاصة بك. أما التقييمات والأعضاء والصور المرفوعة فلا تأتي معها.',
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importar lista', 'collections.file.importTitle': 'Importar lista',
'collections.file.choose': 'Escolher um arquivo de lista', 'collections.file.choose': 'Escolher um arquivo de lista',
'collections.file.confirm': 'Importar', '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.change': 'Trocar',
'collections.file.labelCount': '{count} etiquetas', 'collections.file.labelCount': '{count} etiquetas',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -142,6 +142,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importa una llista', 'collections.file.importTitle': 'Importa una llista',
'collections.file.choose': 'Tria un fitxer de llista', 'collections.file.choose': 'Tria un fitxer de llista',
'collections.file.confirm': 'Importa', '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.change': 'Canvia',
'collections.file.labelCount': '{count} etiquetes', 'collections.file.labelCount': '{count} etiquetes',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Import seznamu', 'collections.file.importTitle': 'Import seznamu',
'collections.file.choose': 'Vyberte soubor se seznamem', 'collections.file.choose': 'Vyberte soubor se seznamem',
'collections.file.confirm': 'Importovat', '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.change': 'Změnit',
'collections.file.labelCount': 'Štítky: {count}', 'collections.file.labelCount': 'Štítky: {count}',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -152,6 +152,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Liste importieren', 'collections.file.importTitle': 'Liste importieren',
'collections.file.choose': 'Listendatei auswählen', 'collections.file.choose': 'Listendatei auswählen',
'collections.file.confirm': 'Importieren', '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.change': 'Ändern',
'collections.file.labelCount': '{count} Labels', 'collections.file.labelCount': '{count} Labels',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Import a list', 'collections.file.importTitle': 'Import a list',
'collections.file.choose': 'Choose a list file', 'collections.file.choose': 'Choose a list file',
'collections.file.confirm': 'Import', '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.change': 'Change',
'collections.file.labelCount': '{count} labels', 'collections.file.labelCount': '{count} labels',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importar una lista', 'collections.file.importTitle': 'Importar una lista',
'collections.file.choose': 'Elegir un archivo de lista', 'collections.file.choose': 'Elegir un archivo de lista',
'collections.file.confirm': 'Importar', '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.change': 'Cambiar',
'collections.file.labelCount': '{count} etiquetas', 'collections.file.labelCount': '{count} etiquetas',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importer une liste', 'collections.file.importTitle': 'Importer une liste',
'collections.file.choose': 'Choisir un fichier de liste', 'collections.file.choose': 'Choisir un fichier de liste',
'collections.file.confirm': 'Importer', '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.change': 'Changer',
'collections.file.labelCount': '{count} étiquettes', 'collections.file.labelCount': '{count} étiquettes',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Εισαγωγή λίστας', 'collections.file.importTitle': 'Εισαγωγή λίστας',
'collections.file.choose': 'Επιλέξτε αρχείο λίστας', 'collections.file.choose': 'Επιλέξτε αρχείο λίστας',
'collections.file.confirm': 'Εισαγωγή', '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.change': 'Αλλαγή',
'collections.file.labelCount': '{count} ετικέτες', 'collections.file.labelCount': '{count} ετικέτες',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -152,6 +152,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Lista importálása', 'collections.file.importTitle': 'Lista importálása',
'collections.file.choose': 'Válassz listafájlt', 'collections.file.choose': 'Válassz listafájlt',
'collections.file.confirm': 'Importálás', '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.change': 'Csere',
'collections.file.labelCount': '{count} címke', 'collections.file.labelCount': '{count} címke',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Impor daftar', 'collections.file.importTitle': 'Impor daftar',
'collections.file.choose': 'Pilih berkas daftar', 'collections.file.choose': 'Pilih berkas daftar',
'collections.file.confirm': 'Impor', '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.change': 'Ganti',
'collections.file.labelCount': '{count} label', 'collections.file.labelCount': '{count} label',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importa un elenco', 'collections.file.importTitle': 'Importa un elenco',
'collections.file.choose': 'Scegli un file di elenco', 'collections.file.choose': 'Scegli un file di elenco',
'collections.file.confirm': 'Importa', '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.change': 'Cambia',
'collections.file.labelCount': '{count} etichette', 'collections.file.labelCount': '{count} etichette',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'リストのインポート', 'collections.file.importTitle': 'リストのインポート',
'collections.file.choose': 'リストファイルを選択', 'collections.file.choose': 'リストファイルを選択',
'collections.file.confirm': 'インポート', '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.change': '変更',
'collections.file.labelCount': 'ラベル {count} 件', 'collections.file.labelCount': 'ラベル {count} 件',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': '목록 가져오기', 'collections.file.importTitle': '목록 가져오기',
'collections.file.choose': '목록 파일 선택', 'collections.file.choose': '목록 파일 선택',
'collections.file.confirm': '가져오기', '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.change': '변경',
'collections.file.labelCount': '라벨 {count}개', 'collections.file.labelCount': '라벨 {count}개',
'collections.file.hint': '장소는 내 새 목록으로 들어옵니다. 평가, 멤버, 업로드한 사진은 함께 오지 않습니다.', 'collections.file.hint': '장소는 내 새 목록으로 들어옵니다. 평가, 멤버, 업로드한 사진은 함께 오지 않습니다.',
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Lijst importeren', 'collections.file.importTitle': 'Lijst importeren',
'collections.file.choose': 'Kies een lijstbestand', 'collections.file.choose': 'Kies een lijstbestand',
'collections.file.confirm': 'Importeren', '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.change': 'Wijzigen',
'collections.file.labelCount': '{count} labels', 'collections.file.labelCount': '{count} labels',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importuj listę', 'collections.file.importTitle': 'Importuj listę',
'collections.file.choose': 'Wybierz plik listy', 'collections.file.choose': 'Wybierz plik listy',
'collections.file.confirm': 'Importuj', '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.change': 'Zmień',
'collections.file.labelCount': 'Etykiety: {count}', 'collections.file.labelCount': 'Etykiety: {count}',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Импорт списка', 'collections.file.importTitle': 'Импорт списка',
'collections.file.choose': 'Выберите файл списка', 'collections.file.choose': 'Выберите файл списка',
'collections.file.confirm': 'Импортировать', '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.change': 'Изменить',
'collections.file.labelCount': 'Меток: {count}', 'collections.file.labelCount': 'Меток: {count}',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Importera lista', 'collections.file.importTitle': 'Importera lista',
'collections.file.choose': 'Välj en listfil', 'collections.file.choose': 'Välj en listfil',
'collections.file.confirm': 'Importera', '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.change': 'Byt',
'collections.file.labelCount': '{count} etiketter', 'collections.file.labelCount': '{count} etiketter',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Liste içe aktar', 'collections.file.importTitle': 'Liste içe aktar',
'collections.file.choose': 'Bir liste dosyası seç', 'collections.file.choose': 'Bir liste dosyası seç',
'collections.file.confirm': 'İçe aktar', '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.change': 'Değiştir',
'collections.file.labelCount': '{count} etiket', 'collections.file.labelCount': '{count} etiket',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Імпорт списку', 'collections.file.importTitle': 'Імпорт списку',
'collections.file.choose': 'Виберіть файл списку', 'collections.file.choose': 'Виберіть файл списку',
'collections.file.confirm': 'Імпортувати', '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.change': 'Змінити',
'collections.file.labelCount': 'Міток: {count}', 'collections.file.labelCount': 'Міток: {count}',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -151,6 +151,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': 'Nhập danh sách', 'collections.file.importTitle': 'Nhập danh sách',
'collections.file.choose': 'Chọn tệp danh sách', 'collections.file.choose': 'Chọn tệp danh sách',
'collections.file.confirm': 'Nhập', '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.change': 'Đổi',
'collections.file.labelCount': '{count} nhãn', 'collections.file.labelCount': '{count} nhãn',
'collections.file.hint': 'collections.file.hint':
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': '匯入清單', 'collections.file.importTitle': '匯入清單',
'collections.file.choose': '選擇清單檔案', 'collections.file.choose': '選擇清單檔案',
'collections.file.confirm': '匯入', '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.change': '更換',
'collections.file.labelCount': '{count} 個標籤', 'collections.file.labelCount': '{count} 個標籤',
'collections.file.hint': '這些地點會成為你自己的新清單。評分、成員與上傳的照片不會一併匯入。', 'collections.file.hint': '這些地點會成為你自己的新清單。評分、成員與上傳的照片不會一併匯入。',
+10
View File
@@ -150,6 +150,16 @@ const collection: TranslationStrings = {
'collections.file.importTitle': '导入列表', 'collections.file.importTitle': '导入列表',
'collections.file.choose': '选择列表文件', 'collections.file.choose': '选择列表文件',
'collections.file.confirm': '导入', '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.change': '更换',
'collections.file.labelCount': '{count} 个标签', 'collections.file.labelCount': '{count} 个标签',
'collections.file.hint': '这些地点会成为你自己的新列表。评分、成员和上传的照片不会一起导入。', 'collections.file.hint': '这些地点会成为你自己的新列表。评分、成员和上传的照片不会一起导入。',
+6 -1
View File
@@ -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. - **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. - **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. 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.