diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index da110e20c..9ed4a56af 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -10,6 +10,9 @@ import type { CollectionSavePlaceRequest, CollectionSaveFromTripRequest, CollectionImportablesResponse, + CollectionFile, + CollectionImportRequest, + CollectionImportResult, CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, @@ -53,6 +56,12 @@ export const collectionsApi = { ax.get(base).then((r: AxiosResponse) => r.data), get: (id: number): Promise => ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data), + // Export / import as a file (#2198). The export body IS the file; the import + // sends the parsed file back through the same contract. + exportFile: (id: number): Promise => + 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), // Both answer with the bare collection, not a { collection } envelope — the // controller returns the service's `Collection` straight through, and the e2e // suite pins that shape. Declaring the envelope made createCollection() resolve diff --git a/client/src/components/Collections/CollectionHero.test.tsx b/client/src/components/Collections/CollectionHero.test.tsx index a3a0be0ee..ea1ffb70a 100644 --- a/client/src/components/Collections/CollectionHero.test.tsx +++ b/client/src/components/Collections/CollectionHero.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011 +// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-015 import React from 'react'; import { render, screen, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -160,4 +160,39 @@ describe('CollectionHero', () => { expect(within(share).queryByText('3')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); }); + + // ── Export as a file (#2198) ─────────────────────────────────────────── + + it('FE-COMP-COLHERO-012: offers Export beside Edit and Share, in that order', async () => { + const onExport = vi.fn(); + renderHero({ canEdit: true, canShare: true, isOwner: true, onExport }); + + const actions = ['Edit', 'Export', 'Share'].map(name => screen.getByRole('button', { name })); + // Reading order in the DOM is the reading order on screen. + const all = [...document.querySelectorAll('.col-hero-actions button')]; + expect(all).toEqual(actions); + + await userEvent.click(actions[1]); + expect(onExport).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => { + renderHero({ onExport: undefined }); + expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-COLHERO-014: a viewer may export, since exporting reads and does not write', async () => { + const onExport = vi.fn(); + renderHero({ canEdit: false, canShare: false, isOwner: false, onExport }); + + await userEvent.click(screen.getByRole('button', { name: 'Export' })); + + expect(onExport).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-COLHERO-015: refuses a second click while the first export is still running', () => { + renderHero({ onExport: vi.fn(), exporting: true }); + expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled(); + }); }); diff --git a/client/src/components/Collections/CollectionHero.tsx b/client/src/components/Collections/CollectionHero.tsx index 1b43ad188..d5fb7e7a1 100644 --- a/client/src/components/Collections/CollectionHero.tsx +++ b/client/src/components/Collections/CollectionHero.tsx @@ -1,6 +1,6 @@ import React from 'react' import { avatarSrc } from '../../utils/avatarSrc' -import { Share2, Users, Link2, Pencil } from 'lucide-react' +import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react' import type { CollectionMember, CollectionLink } from '@trek/shared' import type { TranslationFn } from '../../types' @@ -26,14 +26,18 @@ interface CollectionHeroProps { onEdit: () => void shareMemberCount: number onShare: () => void + /** Absent on the "All saved" pseudo-list, which is a view rather than a list. */ + onExport?: () => void + exporting?: boolean t: TranslationFn } /** * The page header — a colour-washed (or cover-image) glass hero that gives the * active list an identity: an eyebrow with the sharing state + member avatars, - * the big list name, an optional description + link chips, and a Share action - * top-right. Filtering lives in the toolbar above the places, not here. + * the big list name, an optional description + link chips, and the Edit, + * Export and Share actions top-right. Filtering lives in the toolbar above the + * places, not here. * Modelled on the dashboard hero-trip. */ function linkHost(url: string): string { @@ -42,7 +46,7 @@ function linkHost(url: string): string { export default function CollectionHero({ eyebrow, title, color, coverImage, description, links, - members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t, + members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, onExport, exporting, t, }: CollectionHeroProps): React.ReactElement { const accepted = members.filter(m => m.status === 'accepted' || m.is_owner) const showAvatars = accepted.length > 1 @@ -94,6 +98,19 @@ export default function CollectionHero({ {t('common.edit')} )} + {onExport && ( + + )} {canShare && ( + + + } + > +
+ { void take(e.target.files?.[0]); e.target.value = '' }} + /> + + {!file ? ( + + ) : ( + <> +
+ + + + + {file.name} + + {t('collections.placeCount', { count: file.places.length })} + {file.labels && file.labels.length > 0 && ( + {t('collections.file.labelCount', { count: file.labels.length })} + )} + + + +
+ + {file.description && ( +

{file.description}

+ )} + + + +

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

+ + )} + + {errorText && ( +

+ + {errorText} +

+ )} +
+ + ) +} diff --git a/client/src/components/Collections/ListsRail.test.tsx b/client/src/components/Collections/ListsRail.test.tsx index 192317e67..58bf6f1ef 100644 --- a/client/src/components/Collections/ListsRail.test.tsx +++ b/client/src/components/Collections/ListsRail.test.tsx @@ -35,6 +35,7 @@ function renderRail(overrides: Partial = {}) { incomingInvites: [], onSelect: vi.fn(), onNewList: vi.fn(), + onImportList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(), ...overrides, @@ -93,7 +94,7 @@ describe('ListsRail', () => { it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => { const { unmount } = render(); expect(screen.queryByText('Shared')).not.toBeInTheDocument(); unmount(); @@ -127,4 +128,18 @@ describe('ListsRail', () => { expect(screen.queryByText('Invites')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument(); }); + + it('FE-COMP-LISTSRAIL-011: offers Import beside New list, and it opens the file dialog (#2198)', async () => { + const user = userEvent.setup(); + const props = renderRail(); + + const importBtn = screen.getByRole('button', { name: 'Import a list from a file' }); + await user.click(importBtn); + + expect(props.onImportList).toHaveBeenCalledTimes(1); + expect(props.onNewList).not.toHaveBeenCalled(); + // New list keeps its label; import is the icon beside it. + expect(screen.getByRole('button', { name: 'New list' })).toHaveTextContent('New list'); + expect(importBtn).toHaveTextContent(''); + }); }); diff --git a/client/src/components/Collections/ListsRail.tsx b/client/src/components/Collections/ListsRail.tsx index 94fbab693..286b6017c 100644 --- a/client/src/components/Collections/ListsRail.tsx +++ b/client/src/components/Collections/ListsRail.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Plus, Layers, Users } from 'lucide-react' +import { Plus, Layers, Users, Upload } from 'lucide-react' import type { Collection } from '@trek/shared' import type { TranslationFn } from '../../types' import { ALL_SAVED } from '../../store/collectionStore' @@ -12,6 +12,8 @@ interface ListsRailProps { incomingInvites: IncomingCollectionInvite[] onSelect: (id: ActiveCollectionId) => void onNewList: () => void + /** Read a list file as a new list (#2198). */ + onImportList: () => void onAcceptInvite: (id: number) => void onDeclineInvite: (id: number) => void t: TranslationFn @@ -30,7 +32,7 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean } /** - * Left rail of the user's lists: a "New list" action, the "All saved" union + * Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union * pseudo-list, owned lists (colour dot + count), a shared section, and an * incoming-invites block. Selecting a list makes it active; editing / deleting * happens from the Edit button in the hero of the active list. @@ -38,14 +40,27 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean export default function ListsRail(props: ListsRailProps): React.ReactElement { const { ownedLists, sharedLists, activeId, incomingInvites, - onSelect, onNewList, onAcceptInvite, onDeclineInvite, t, + onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t, } = props return ( <> - +
+ + {/* Import sits beside New rather than in the hero: a file becomes a NEW + list, so it belongs where lists are made, not on the one open. */} + +
@@ -341,6 +345,15 @@ function CollectionsPageDesktop(): React.ReactElement { /> )} + {/* A list file as a new list — no active list needed, it makes one (#2198) */} + {c.showImportFile && ( + c.setShowImportFile(false)} + t={t} + /> + )} + {/* Bulk import of a trip's places into the current list */} {typeof c.activeId === 'number' && c.activeCollection && ( ({ ALL_SAVED: 'all', useCollectionStore: () => hoisted.store, })) +// The download itself is the browser's job and is covered in collectionFile.test.ts; +// here the question is only whether the hook hands it the file it fetched. +vi.mock('../../components/Collections/collectionFile', () => ({ + downloadCollectionFile: vi.fn(), +})) +import { downloadCollectionFile } from '../../components/Collections/collectionFile' // ── Fixtures ───────────────────────────────────────────────────────────────── function makeStore() { @@ -146,6 +152,7 @@ beforeEach(() => { darkMedia = false navigate.mockClear() addToast.mockClear() + vi.mocked(downloadCollectionFile).mockClear() vi.mocked(addListener).mockClear() vi.mocked(removeListener).mockClear() installMatchMedia() @@ -969,3 +976,101 @@ describe('useCollections — local UI state', () => { expect(result.current.uploadPlaceImage).toBe(store.uploadPlaceImage) }) }) + +// ── Export / import as a file (#2198) ──────────────────────────────────────── + +describe('useCollections — export and import as a file', () => { + const listFile = { + format: 'trek.collection', version: 1, name: 'Lisbon', + places: [{ name: 'Time Out Market' }, { name: 'Miradouro' }], + } + + it('FE-PAGE-COLL-070: downloads the active list, and says so while it is fetching', async () => { + let release: ((v: unknown) => void) | null = null + server.use(http.get('/api/addons/collections/:id/export', async () => { + await new Promise(r => { release = r }) + return HttpResponse.json(listFile) + })) + store.activeId = 11 + const { result } = await setup() + expect(result.current.exporting).toBe(false) + + let finished: Promise + act(() => { finished = result.current.handleExportList() }) + await waitFor(() => expect(result.current.exporting).toBe(true)) + + act(() => { release!(null) }) + await act(async () => { await finished }) + await waitFor(() => expect(result.current.exporting).toBe(false)) + expect(downloadCollectionFile).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' })) + }) + + it('FE-PAGE-COLL-071: does not try to export the All-saved view, which is not a list', async () => { + store.activeId = 'all' + const { result } = await setup() + + await act(async () => { await result.current.handleExportList() }) + + expect(downloadCollectionFile).not.toHaveBeenCalled() + expect(result.current.exporting).toBe(false) + }) + + it('FE-PAGE-COLL-072: a failed export says so and leaves the button usable again', async () => { + server.use(http.get('/api/addons/collections/:id/export', () => HttpResponse.json({ error: 'Nope' }, { status: 500 }))) + store.activeId = 11 + const { result } = await setup() + + await act(async () => { await result.current.handleExportList() }) + + expect(downloadCollectionFile).not.toHaveBeenCalled() + expect(addToast).toHaveBeenCalledWith('Nope', 'error', undefined) + expect(result.current.exporting).toBe(false) + }) + + it('FE-PAGE-COLL-073: an import reloads the rail, opens the new list and reports the count', async () => { + server.use(http.post('/api/addons/collections/import', () => + HttpResponse.json({ collection: { id: 42, name: 'Lisbon' }, imported: 2, skipped: 0 }))) + const { result } = await setup() + + await act(async () => { await result.current.handleImportFile(listFile as never) }) + + expect(store.loadAll).toHaveBeenCalled() + expect(navigate).toHaveBeenCalledWith('/collections/42') + expect(addToast).toHaveBeenCalledWith('2 places imported', 'success', undefined) + expect(result.current.showImportFile).toBe(false) + }) + + it('FE-PAGE-COLL-074: an import that had to drop places says how many', async () => { + server.use(http.post('/api/addons/collections/import', () => + HttpResponse.json({ collection: { id: 43, name: 'Lisbon' }, imported: 8, skipped: 2 }))) + const { result } = await setup() + + await act(async () => { await result.current.handleImportFile(listFile as never) }) + + expect(addToast).toHaveBeenCalledWith('8 places imported, 2 skipped', 'info', undefined) + }) + + it('FE-PAGE-COLL-075: a failed import leaves the dialog open for the modal to explain', async () => { + server.use(http.post('/api/addons/collections/import', () => HttpResponse.json({ error: 'Nope' }, { status: 500 }))) + const { result } = await setup() + act(() => { result.current.setShowImportFile(true) }) + + await expect(act(async () => { await result.current.handleImportFile(listFile as never) })).rejects.toBeTruthy() + + expect(navigate).not.toHaveBeenCalledWith(expect.stringContaining('/collections/')) + expect(result.current.showImportFile).toBe(true) + }) + + 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 }) => { + seen.push(await request.json()) + return HttpResponse.json({ collection: { id: 44, name: 'Lisbon (from Ana)' }, imported: 2, skipped: 0 }) + })) + const { result } = await setup() + + await act(async () => { await result.current.handleImportFile(listFile as never, 'Lisbon (from Ana)') }) + + expect(seen[0]).toEqual({ file: listFile, name: 'Lisbon (from Ana)' }) + }) +}) diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index 70e7730ca..df231b9d9 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -6,12 +6,14 @@ import { useElementRect } from '../../hooks/useElementRect' import { useSettingsStore } from '../../store/settingsStore' import { useAuthStore } from '../../store/authStore' import { useToast } from '../../components/shared/Toast' +import { collectionsApi } from '../../api/collections' +import { downloadCollectionFile } from '../../components/Collections/collectionFile' import { getApiErrorMessage } from '../../types' import { addListener, removeListener } from '../../api/websocket' import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore' import type { ActiveCollectionId } from '../../store/collectionStore' import { categoriesApi } from '../../api/client' -import type { Collection, CollectionStatus } from '@trek/shared' +import type { Collection, CollectionStatus, CollectionFile } from '@trek/shared' import type { Category, Place } from '../../types' import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel' import type { CollectionLabelUpdateRequest } from '@trek/shared' @@ -69,6 +71,10 @@ export function useCollections() { const [showShare, setShowShare] = useState(false) const [showAddPlace, setShowAddPlace] = useState(false) const [showImport, setShowImport] = useState(false) + // Export / import as a file (#2198) — distinct from showImport above, which + // is the "pull places out of one of my trips" dialog. + const [exporting, setExporting] = useState(false) + const [showImportFile, setShowImportFile] = useState(false) // The place ids the Copy-to-trip modal is open for (null = closed). Single // place from the detail panel, or the select-mode set for a bulk copy. const [copyIds, setCopyIds] = useState(null) @@ -199,6 +205,43 @@ export function useCollections() { const handlePlaceAdded = useCallback(() => { refreshActive() }, [refreshActive]) + /** + * Download the active list as a file. + * + * The file is fetched rather than built from what this page holds: the page + * has the places for display, the server decides what may leave the instance. + */ + const handleExportList = useCallback(async () => { + if (typeof activeId !== 'number') return + setExporting(true) + try { + downloadCollectionFile(await collectionsApi.exportFile(activeId)) + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setExporting(false) + } + }, [activeId, toast, t]) + + /** + * Read a chosen file and create the list it describes. + * + * Lands on the new list, because that is the thing the person was after and + * an import that leaves you where you were reads as one that did nothing. + */ + const handleImportFile = useCallback(async (file: CollectionFile, name?: string) => { + const result = await collectionsApi.importFile({ file, name }) + await loadAll() + const created = result.collection as Collection + navigate(`/collections/${created.id}`) + if (result.skipped > 0) { + toast.info(t('collections.file.doneSkipped', { count: result.imported, skipped: result.skipped })) + } else { + toast.success(t('collections.file.done', { count: result.imported })) + } + setShowImportFile(false) + }, [loadAll, navigate, toast, t]) + const handleDeleteList = useCallback(async () => { if (confirmDeleteList == null) return const id = confirmDeleteList @@ -405,6 +448,8 @@ export function useCollections() { editorTarget, setEditorTarget, handleEditorCreated, showAddPlace, setShowAddPlace, handlePlaceAdded, showImport, setShowImport, + exporting, handleExportList, + showImportFile, setShowImportFile, handleImportFile, confirmDeleteList, setConfirmDeleteList, mobileRailOpen, setMobileRailOpen, showShare, setShowShare, handleAfterLeave, diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index a58272f3d..8171c8e2b 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -27,13 +27,16 @@ padding: 12px; display: flex; flex-direction: column; gap: 2px; max-height: calc(100vh - var(--nav-h) - 44px); overflow-y: auto; overscroll-behavior: contain; } +.trek-dash .col-rail-newrow { display: flex; align-items: stretch; gap: 6px; margin-bottom: 6px; } .trek-dash .col-rail-new { display: flex; align-items: center; gap: 9px; width: 100%; - padding: 10px 12px; margin-bottom: 6px; border-radius: var(--r-sm); + padding: 10px 12px; border-radius: var(--r-sm); border: 1px dashed var(--line-2); color: var(--ink-2); font-size: 13px; font-weight: 500; transition: background .15s, color .15s, border-color .15s; } .trek-dash .col-rail-new:hover { background: var(--surface-2); color: var(--ink); border-color: var(--ink-3); } +/* Square, icon-only: "New list" is the primary action and keeps the label. */ +.trek-dash .col-rail-import { width: auto; flex: 0 0 auto; padding: 10px; justify-content: center; } .trek-dash .col-rail-label { display: flex; align-items: center; gap: 6px; padding: 14px 11px 5px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .13em; color: var(--ink-3); font-weight: 600; @@ -539,6 +542,7 @@ .trek-dash .col-detail-icon-btn { min-width: 40px; min-height: 40px; } .trek-dash .col-drawer .col-row-btn { min-height: 44px; } .trek-dash .col-drawer .col-rail-new { min-height: 44px; } + .trek-dash .col-drawer .col-rail-import { min-width: 44px; } /* enlarge the interactive status badge's tap area without changing its look */ .trek-dash .col-lrow-end > [role="button"] { position: relative; } .trek-dash .col-lrow-end > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; } diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index 98de918c1..80008f967 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -51,6 +51,7 @@ import { CollectionLabelCreateDto, CollectionLabelUpdateDto, CollectionLabelAssignDto, + CollectionImportDto, } from './collections.dto'; import { PlaceRatingDto } from '../places/places.dto'; @@ -199,6 +200,17 @@ export class CollectionsController { } // ── Copy to trip ──────────────────────────────────────────────────────────── + /** + * Read a list file back as a new list of the caller's own (#2198). + * + * 201, like POST / above, because it creates a list. The other POSTs here + * are @HttpCode(200) because they act on one that already exists. + */ + @Post('import') + importCollection(@CurrentUser() user: User, @Body() body: CollectionImportDto) { + return this.collections.importCollection(user.id, body); + } + @Post('copy-to-trip') @HttpCode(200) copyToTrip(@CurrentUser() user: User, @Body() body: CollectionCopyToTripDto) { @@ -370,6 +382,16 @@ export class CollectionsController { /** Preview for the bulk trip import: the trip's places plus, per place, the same * duplicate verdict the import itself applies. Read-only, so the dialog can grey out * what would be skipped instead of reporting it afterwards. */ + /** + * The list as a file (#2198). A plain JSON body: the browser turns it into a + * download, so there is no Content-Disposition to get wrong here and no + * filename decided by the server. + */ + @Get(':id/export') + exportCollection(@CurrentUser() user: User, @Param('id') id: string) { + return this.collections.exportCollection(user.id, Number(id)); + } + @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 db1fb8a60..ecfe1da06 100644 --- a/server/src/nest/collections/collections.dto.ts +++ b/server/src/nest/collections/collections.dto.ts @@ -20,6 +20,7 @@ import { collectionLabelCreateRequestSchema, collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema, + collectionImportRequestSchema, } from '@trek/shared'; /** @@ -49,3 +50,4 @@ export class CollectionSetMemberRoleDto extends createZodDto(collectionSetMember export class CollectionLabelCreateDto extends createZodDto(collectionLabelCreateRequestSchema) {} export class CollectionLabelUpdateDto extends createZodDto(collectionLabelUpdateRequestSchema) {} export class CollectionLabelAssignDto extends createZodDto(collectionLabelAssignRequestSchema) {} +export class CollectionImportDto extends createZodDto(collectionImportRequestSchema) {} diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 2c8b651e4..5bd5574d8 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -12,7 +12,19 @@ import { trackInsertedInDedupSet, type DedupSet, } from '../places/places.helpers'; -import { placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared'; +import { + placeMatchStrategies, + collectionFilePlaceSchema, + COLLECTION_FILE_FORMAT, + COLLECTION_FILE_VERSION, + MAX_COLLECTION_FILE_LABELS, + type PlaceMatchCandidate, + type CollectionFile, + type CollectionFileLabel, + type CollectionFilePlace, + type CollectionImportRequest, + type CollectionImportResult, +} from '@trek/shared'; import type { Collection, CollectionDetailResponse, @@ -339,6 +351,170 @@ export class CollectionsService { }; } + // ------------------------------------------------------------------------- + // Export / import as a file (#2198) + // ------------------------------------------------------------------------- + + /** + * A list as a portable file. + * + * Built here rather than in the browser from what the page happens to hold, + * for two reasons: the read is access-checked like every other read, and the + * decision about what may leave the instance is one decision in one place + * instead of whatever the client forgot to strip. The contract in + * `collection-file.schema.ts` says what travels and why. + * + * Any member may export. A list is shared with somebody so they can use it, + * and a viewer who can read all of this on screen loses nothing by having it + * as a file; what a viewer must not do is write, which no export does. + */ + exportCollection(userId: number, id: number): CollectionFile { + this.assertAccess(userId, id); + const collection = this.getCollectionRow(id); + const labels = this.loadLabelsByCollection(id); + const labelNameById = new Map(labels.map(l => [l.id, l.name])); + + const rows = this.db.all(` + SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon + FROM collection_places cp + LEFT JOIN categories c ON cp.category_id = c.id + WHERE cp.collection_id = ? + ORDER BY cp.sort_order, cp.created_at + `, id); + const labelIdsByPlace = this.loadLabelIdsByPlaceIds(rows.map(r => r.id)); + + const places: CollectionFilePlace[] = rows.map(row => ({ + name: row.name, + description: row.description ?? null, + lat: row.lat ?? null, + lng: row.lng ?? null, + address: row.address ?? null, + notes: row.notes ?? null, + price: row.price ?? null, + currency: row.currency ?? null, + website: row.website ?? null, + phone: row.phone ?? null, + // Only an absolute https URL survives: a /uploads path resolves on this + // server, not the reader's. See the note in the file contract. + image_url: typeof row.image_url === 'string' && /^https:\/\//i.test(row.image_url) ? row.image_url : null, + google_place_id: row.google_place_id ?? null, + google_ftid: row.google_ftid ?? null, + osm_id: row.osm_id ?? null, + status: row.status, + links: parseLinks((row as { links?: unknown }).links), + category: row.category_name ?? null, + labels: (labelIdsByPlace[row.id] || []).map(lid => labelNameById.get(lid)).filter((n): n is string => !!n), + })); + + return { + format: COLLECTION_FILE_FORMAT, + version: COLLECTION_FILE_VERSION, + name: collection.name, + description: collection.description ?? null, + color: collection.color ?? null, + icon: collection.icon ?? null, + exported_at: new Date().toISOString(), + labels: labels.map(l => ({ name: l.name, color: l.color ?? null })), + places, + }; + } + + /** + * 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. + */ + 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, + description: file.description ?? null, + 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 collection = this.getCollectionRow(result.collectionId); + return { + collection: { ...collection, is_owner: true, labels: this.loadLabelsByCollection(result.collectionId) }, + imported: result.imported, + skipped: result.skipped, + }; + } + + /** + * 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. + */ + private insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): number { + const res = this.db.run( + 'INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)', + collectionId, label.name.trim(), label.color ?? '#6366f1', sortOrder, + ); + return Number(res.lastInsertRowid); + } + createCollection(userId: number, body: CollectionCreateRequest): Collection { const max = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?', userId)!.m; const result = this.db.run(` diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 2ec3c1f38..82415680e 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -1050,3 +1050,236 @@ describe('bulk status', () => { expect(svc.findMembership(owner.id, { google_place_id: 'gp-9' }).lists[0].can_edit).toBe(true); }); }); + +// ── Export / import as a file (#2198) ──────────────────────────────────────── + +describe('exportCollection / importCollection (#2198)', () => { + /** A list with two labels, a category and one place carrying both. */ + function seedList(ownerId: number) { + const cat = createCategory(testDb, { name: 'Restaurant' }); + const col = svc.createCollection(ownerId, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); + const must = svc.createLabel(ownerId, col.id, 'Must see', '#ff0000'); + const rain = svc.createLabel(ownerId, col.id, 'Rainy day', '#00ff00'); + const market = svc.savePlace(ownerId, { + collection_id: col.id, name: 'Time Out Market', description: 'Market hall', + lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', + website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', + status: 'want', category_id: cat.id, price: 12, currency: 'EUR', + }).place!; + const view = svc.savePlace(ownerId, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327 }).place!; + svc.assignLabels(ownerId, [must.id], [market.id], false); + svc.assignLabels(ownerId, [rain.id], [view.id], false); + return { col, cat, must, rain, market, view }; + } + + it('COLLECTIONS-SVC-100: a file carries the list, its labels and each place with its label names', () => { + const owner = createUser(testDb).user; + const { col, market } = seedList(owner.id); + + const file = svc.exportCollection(owner.id, col.id); + + expect(file.format).toBe('trek.collection'); + expect(file.version).toBe(1); + expect(file).toMatchObject({ name: 'Lisbon', description: 'Three days', color: '#ef4444' }); + expect(file.labels).toEqual([ + { name: 'Must see', color: '#ff0000' }, + { name: 'Rainy day', color: '#00ff00' }, + ]); + expect(file.places).toHaveLength(2); + const first = file.places[0] as Record; + expect(first).toMatchObject({ + name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, + address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', + phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', price: 12, currency: 'EUR', + category: 'Restaurant', labels: ['Must see'], + }); + expect(market.id).toBeGreaterThan(0); + }); + + it('COLLECTIONS-SVC-101: a file carries no ids, no members and no ratings', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const { col, market } = seedList(owner.id); + addMember(col.id, member.id, 'editor'); + svc.setRating(member.id, market.id, 5); + + const file = svc.exportCollection(owner.id, col.id); + const serialised = JSON.stringify(file); + + for (const forbidden of ['"id"', 'collection_id', 'owner_id', 'saved_by', 'source_trip_id', 'source_place_id', 'rating', 'user_id', 'category_id']) { + expect(serialised, forbidden).not.toContain(forbidden); + } + }); + + it('COLLECTIONS-SVC-102: an instance-local image path does not leave the instance', () => { + const owner = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Pictures' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Uploaded', image_url: '/uploads/places/secret.jpg' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Proxied', image_url: '/api/maps/place-photo/abc' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'Remote', image_url: 'https://example.com/photo.jpg' }); + + const images = svc.exportCollection(owner.id, col.id).places.map(p => (p as { image_url?: string | null }).image_url); + + expect(images).toEqual([null, null, 'https://example.com/photo.jpg']); + }); + + it('COLLECTIONS-SVC-103: a member may export, a stranger may not', () => { + const owner = createUser(testDb).user; + const viewer = createUser(testDb).user; + const stranger = createUser(testDb).user; + const { col } = seedList(owner.id); + addMember(col.id, viewer.id, 'viewer'); + + expect(svc.exportCollection(viewer.id, col.id).places).toHaveLength(2); + try { + svc.exportCollection(stranger.id, col.id); + throw new Error('should have thrown'); + } catch (e) { + expect((e as { status: number }).status).toBe(404); + } + }); + + it('COLLECTIONS-SVC-104: a file round-trips into an equivalent list of the importer', () => { + const owner = createUser(testDb).user; + const other = createUser(testDb).user; + const { col } = seedList(owner.id); + const file = svc.exportCollection(owner.id, col.id); + + const result = svc.importCollection(other.id, { file }); + const created = result.collection as { id: number; owner_id: number; name: string }; + + expect(result).toMatchObject({ imported: 2, skipped: 0 }); + expect(created.owner_id).toBe(other.id); + expect(created.name).toBe('Lisbon'); + // Re-exporting the copy gives the same file, bar the timestamp. + const again = svc.exportCollection(other.id, created.id); + expect({ ...again, exported_at: undefined }).toEqual({ ...file, exported_at: undefined }); + }); + + it('COLLECTIONS-SVC-105: the importer may rename the list on the way in', () => { + const owner = createUser(testDb).user; + const { col } = seedList(owner.id); + const file = svc.exportCollection(owner.id, col.id); + + const created = svc.importCollection(owner.id, { file, name: 'Lisbon (from Ana)' }).collection as { id: number; name: string }; + + expect(created.name).toBe('Lisbon (from Ana)'); + // And the original is untouched. + expect(svc.getCollection(owner.id, col.id).collection.name).toBe('Lisbon'); + }); + + it('COLLECTIONS-SVC-106: a category arrives by name, matched case-insensitively, unknown names drop', () => { + const owner = createUser(testDb).user; + createCategory(testDb, { name: 'Restaurant' }); + + const created = svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Categories', places: [ + { name: 'Exact', category: 'Restaurant' }, + { name: 'Sloppy', category: ' rEsTaUrAnT ' }, + { name: 'Unknown', category: 'Not a category here' }, + { name: 'None' }, + ], + } }).collection as { id: number }; + + const places = svc.getCollection(owner.id, created.id).places; + expect(places.map(p => p.category?.name ?? null)).toEqual(['Restaurant', 'Restaurant', null, null]); + }); + + it('COLLECTIONS-SVC-107: labels are recreated and only names the file defines are assigned', () => { + const owner = createUser(testDb).user; + + const created = svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Labelled', + labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Must see', color: '#00ff00' }], + places: [ + { name: 'Tagged', labels: ['Must see'] }, + { name: 'Sloppy case', labels: [' MUST SEE '] }, + { name: 'Ghost label', labels: ['Never defined'] }, + ], + } }).collection as { id: number }; + + const detail = svc.getCollection(owner.id, created.id); + // The duplicate name collapses to one label. + expect(detail.collection.labels).toHaveLength(1); + const labelId = detail.collection.labels![0].id; + expect(detail.places.map(p => p.label_ids)).toEqual([[labelId], [labelId], []]); + }); + + it('COLLECTIONS-SVC-108: a place the contract refuses is skipped, the rest still arrive', () => { + const owner = createUser(testDb).user; + + const result = svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Mixed', places: [ + { name: 'Good' }, + { noName: true }, + { name: '' }, + { name: 'Also good' }, + null, + ], + } }); + + expect(result).toMatchObject({ imported: 2, skipped: 3 }); + const created = result.collection as { id: number }; + expect(svc.getCollection(owner.id, created.id).places.map(p => p.name)).toEqual(['Good', 'Also good']); + }); + + it('COLLECTIONS-SVC-109: a file cannot hand the importer ids, provenance or other people', () => { + const owner = createUser(testDb).user; + const victim = createUser(testDb).user; + const trip = createTrip(testDb, victim.id); + const place = createPlace(testDb, trip.id, { name: 'Private place' }); + + const created = svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Hostile', places: [{ + name: 'Claims things', + id: 9999, collection_id: 1, owner_id: victim.id, saved_by: victim.id, + source_trip_id: trip.id, source_place_id: place.id, + } as never], + } }).collection as { id: number }; + + const row = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(created.id) as Record; + expect(row.owner_id).toBe(owner.id); + expect(row.saved_by).toBe(owner.id); + expect(row.source_trip_id).toBeNull(); + expect(row.source_place_id).toBeNull(); + expect(row.id).not.toBe(9999); + }); + + it('COLLECTIONS-SVC-110: a link the browser must not follow arrives as nothing, the place still does', () => { + const owner = createUser(testDb).user; + + const created = svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Links', places: [ + { name: 'Script', website: 'javascript:alert(1)' as never }, + { name: 'Local image', image_url: '/uploads/x.jpg' as never }, + { name: 'Fine', website: 'https://example.com', image_url: 'https://example.com/p.jpg' }, + ], + } }).collection as { id: number }; + + const places = svc.getCollection(owner.id, created.id).places; + expect(places.map(p => p.name)).toEqual(['Script', 'Local image', 'Fine']); + expect(places[0].website).toBeNull(); + expect(places[1].image_url).toBeNull(); + expect(places[2]).toMatchObject({ website: 'https://example.com', image_url: 'https://example.com/p.jpg' }); + }); + + it('COLLECTIONS-SVC-111: an import writes nothing at all when one of its writes fails', () => { + const owner = createUser(testDb).user; + const before = svc.listCollections(owner.id).collections.length; + const insert = 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 insert(sql); + }); + + try { + expect(() => svc.importCollection(owner.id, { file: { + format: 'trek.collection', version: 1, name: 'Doomed', places: [{ name: 'Never arrives' }], + } })).toThrow('disk is full'); + } finally { + spy.mockRestore(); + } + + expect(svc.listCollections(owner.id).collections).toHaveLength(before); + }); +}); diff --git a/shared/src/collection/collection-file.schema.ts b/shared/src/collection/collection-file.schema.ts new file mode 100644 index 000000000..a1279c606 --- /dev/null +++ b/shared/src/collection/collection-file.schema.ts @@ -0,0 +1,190 @@ +import { placeWebsiteSchema } from '../place/place.schema'; +import { collectionStatusSchema, collectionLinksSchema } from './collection.schema'; + +import { z } from 'zod'; + +/** + * A list as a file, so a list of places can leave the instance it was saved on. + * + * ── Why this is worth having ───────────────────────────────────────────── + * + * Somebody who has been to a place knows which twenty of its restaurants are + * worth the walk, and that knowledge currently exists in one account on one + * server. Sharing a list already works between members of the same instance; + * this is the other case, where the person you want to send it to runs their + * own TREK, or none yet (#2198). + * + * ── What travels ───────────────────────────────────────────────────────── + * + * What somebody wrote down: the list's own name, colour and description, and + * for each place its name, where it is, what was noted about it, how to reach + * it, and which of the list's labels it carries. Deliberately dull JSON, so a + * file can be read, edited and diffed by a person. + * + * ── What does not ──────────────────────────────────────────────────────── + * + * **Ids of any kind.** A collection id, a place id, a category id and a user + * id all mean something only on the instance that issued them. The receiving + * instance issues its own; a category comes across as its name and is matched + * against the palette on the other side. + * + * **Other people.** Members, invitations, and the star ratings members gave + * (#1435). A rating is an opinion somebody expressed to the people on that + * list, not something the list's owner may forward. + * + * **Provenance into trips.** `source_trip_id` / `source_place_id` point at + * rows the receiver cannot see and must not be handed a reference to. + * + * **Instance-local image paths.** `/uploads/...` and the photo-proxy path + * resolve to a file on the *sender's* server, so on the receiver's screen they + * would either 404 or, behind the same reverse proxy, show a stranger's + * upload. Only an absolute https URL survives, and even that is rendered as an + * image rather than fetched by the server. A cover image is instance-local by + * definition, so a list arrives without one. + * + * **Tags.** A tag row carries a `user_id`; there is no meaningful way to give + * one to somebody else's account. + * + * ── What an imported file is trusted for ───────────────────────────────── + * + * Nothing. It is a stranger's JSON. It is read in the browser that chose it, + * never uploaded as a file, and what is sent to the server goes through this + * contract on both sides. Text is rendered as text; the two fields that would + * otherwise reach a browser API (`website` and a link's `url`) are pinned to + * http(s) by the schemas they borrow, so neither can be a `javascript:` value. + * The counts below are the other half: a file cannot ask the server for more + * rows than the UI that created it could. + * + * Strict where it must refuse, forgiving where it can drop. The envelope is + * checked hard — a file that does not say what it is never reaches the + * importer — while a single place inside it that does not parse is skipped and + * counted, and one unreadable field on an otherwise good place is dropped + * rather than taking the place with it. + */ + +/** What the file says it is, so a stray JSON is refused rather than half-read. */ +export const COLLECTION_FILE_FORMAT = 'trek.collection'; +export const COLLECTION_FILE_VERSION = 1; + +/** + * As many places as `collectionSaveFromTripManyRequestSchema` accepts in one + * go, which is the largest bulk write the list UI can already produce. + */ +export const MAX_COLLECTION_FILE_PLACES = 1000; +/** `MAX_LABELS_PER_COLLECTION` in collections.service.ts. */ +export const MAX_COLLECTION_FILE_LABELS = 50; + +/** + * A megabyte holds a thousand places with room to spare. The point of the + * limit is to refuse a hostile or mistaken file before parsing it. + */ +export const MAX_COLLECTION_FILE_BYTES = 1024 * 1024; + +const hexColor = z + .string() + .regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) + .nullable() + .optional(); + +/** + * An image the receiver may show: an absolute https URL and nothing else. + * + * Narrower than `placeImageUrlSchema`, which also admits an instance-local + * path and an inline data URI. Neither belongs in a file: the path resolves on + * the wrong server, and a data URI turns a list of a thousand places into a + * file nobody can send. + */ +export const collectionFileImageSchema = z + .string() + .max(2048) + .refine((v) => /^https:\/\//i.test(v), { message: 'must be an https URL' }); + +/** + * A value the reader keeps, or nothing. + * + * `.catch(null)` rather than a hard refusal, on the two fields that carry a + * URL: a place whose website is a `javascript:` value, or whose image points + * at the sender's own disk, is still a place worth having, and it arrives + * without that field. Refusing the whole file over one of them would lose a + * hundred good places to a single hand-edited line, and the failure would read + * as "the import is broken" rather than "that link was dropped". + */ +const droppable = (schema: T) => schema.nullable().optional().catch(null); + +export const collectionFileLabelSchema = z.object({ + name: z.string().min(1).max(60), + color: hexColor, +}); +export type CollectionFileLabel = z.infer; + +export const collectionFilePlaceSchema = z.object({ + name: z.string().min(1).max(500), + description: z.string().max(5000).nullable().optional(), + lat: z.number().min(-90).max(90).nullable().optional(), + lng: z.number().min(-180).max(180).nullable().optional(), + address: z.string().max(1000).nullable().optional(), + notes: z.string().max(5000).nullable().optional(), + price: z.number().nullable().optional(), + currency: z.string().max(10).nullable().optional(), + website: droppable(placeWebsiteSchema), + phone: z.string().max(60).nullable().optional(), + image_url: droppable(collectionFileImageSchema), + /** Provider ids identify the place itself, not a row on the sender's server. */ + google_place_id: z.string().max(255).nullable().optional(), + google_ftid: z.string().max(255).nullable().optional(), + osm_id: z.string().max(255).nullable().optional(), + status: collectionStatusSchema.optional(), + links: collectionLinksSchema.optional(), + /** The category's NAME; the receiving instance matches it against its own palette. */ + category: z.string().max(100).nullable().optional(), + /** Label names, matched against `labels` above. Unknown names are dropped. */ + labels: z.array(z.string().min(1).max(60)).max(MAX_COLLECTION_FILE_LABELS).optional(), +}); +export type CollectionFilePlace = z.infer; + +export const collectionFileSchema = z.object({ + format: z.literal(COLLECTION_FILE_FORMAT), + /** + * Read but not yet branched on: version 1 is the only shape there is. A file + * from a later version still parses when its additions are optional, which + * is the point of keeping every field below optional. + */ + version: z.number().int().min(1), + name: z.string().min(1).max(120), + description: z.string().max(2000).nullable().optional(), + color: hexColor, + icon: z.string().max(40).nullable().optional(), + /** ISO timestamp, for a person reading the file. Nothing branches on it. */ + exported_at: z.string().max(40).optional(), + labels: z.array(collectionFileLabelSchema).max(MAX_COLLECTION_FILE_LABELS).optional(), + /** + * Unknown at the envelope, checked one by one where they are written. + * + * A single unreadable place must not cost the other nine hundred: the + * envelope only counts them, and `collectionFilePlaceSchema` is applied per + * entry by the importer, which reports how many it had to drop. Same shape + * as the studio's spread file, for the same reason. + */ + places: z.array(z.unknown()).max(MAX_COLLECTION_FILE_PLACES), +}); +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. + */ +export const collectionImportRequestSchema = z.object({ + file: collectionFileSchema, + /** Overrides the file's name when the importer typed one. */ + name: z.string().min(1).max(120).optional(), +}); +export type CollectionImportRequest = z.infer; + +export interface CollectionImportResult { + /** The list as created, so the client can select it without a refetch. */ + collection: unknown; + imported: number; + /** Places the file carried that the contract refused. */ + skipped: number; +} diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index 4ec42b5e1..c89663472 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -144,6 +144,22 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'اختر مكانًا من مجموعاتك', 'collections.picker.use': 'استخدام', + // Export / import a list as a file (#2198) + 'collections.file.export': 'تصدير', + 'collections.file.exportTitle': 'تنزيل هذه القائمة كملف', + 'collections.file.importButton': 'استيراد قائمة من ملف', + 'collections.file.importTitle': 'استيراد قائمة', + 'collections.file.choose': 'اختر ملف قائمة', + 'collections.file.confirm': 'استيراد', + 'collections.file.change': 'تغيير', + 'collections.file.labelCount': '{count} تسمية', + 'collections.file.hint': 'تصل الأماكن كقائمة جديدة خاصة بك. أما التقييمات والأعضاء والصور المرفوعة فلا تأتي معها.', + 'collections.file.done': 'تم استيراد {count} مكانًا', + 'collections.file.doneSkipped': 'تم استيراد {count} مكانًا وتخطّي {skipped}', + 'collections.file.errorTooLarge': 'هذا الملف أكبر من أن يكون قائمة.', + 'collections.file.errorUnreadable': 'تعذّرت قراءة هذا الملف.', + 'collections.file.errorNotACollection': 'هذا ليس ملف قائمة من TREK.', + 'collections.share.title': 'مشاركة القائمة', 'collections.share.titleNamed': 'مشاركة «{name}»', 'collections.share.button': 'مشاركة', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index 989657463..7fc1166b3 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Escolha um lugar das suas coleções', 'collections.picker.use': 'Usar', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportar', + 'collections.file.exportTitle': 'Baixar esta lista como arquivo', + 'collections.file.importButton': 'Importar uma lista de um arquivo', + 'collections.file.importTitle': 'Importar lista', + 'collections.file.choose': 'Escolher um arquivo de lista', + 'collections.file.confirm': 'Importar', + 'collections.file.change': 'Trocar', + 'collections.file.labelCount': '{count} etiquetas', + 'collections.file.hint': + 'Os lugares chegam como uma nova lista sua. Avaliações, membros e fotos enviadas ficam para trás.', + 'collections.file.done': '{count} lugares importados', + 'collections.file.doneSkipped': '{count} lugares importados, {skipped} ignorados', + 'collections.file.errorTooLarge': 'Esse arquivo é grande demais para ser uma lista.', + 'collections.file.errorUnreadable': 'Não foi possível ler esse arquivo.', + 'collections.file.errorNotACollection': 'Esse não é um arquivo de lista do TREK.', + 'collections.share.title': 'Compartilhar lista', 'collections.share.titleNamed': 'Compartilhar “{name}”', 'collections.share.button': 'Compartilhar', diff --git a/shared/src/i18n/ca/collection.ts b/shared/src/i18n/ca/collection.ts index 9023b9b3c..0eb7669fd 100644 --- a/shared/src/i18n/ca/collection.ts +++ b/shared/src/i18n/ca/collection.ts @@ -135,6 +135,23 @@ const collection: TranslationStrings = { 'collections.picker.empty': 'No hi ha llocs desats per afegir', 'collections.picker.hint': 'Tria un lloc de les teves col·leccions', 'collections.picker.use': 'Utilitza', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exporta', + 'collections.file.exportTitle': 'Baixa aquesta llista com a fitxer', + 'collections.file.importButton': 'Importa una llista des d’un fitxer', + 'collections.file.importTitle': 'Importa una llista', + 'collections.file.choose': 'Tria un fitxer de llista', + 'collections.file.confirm': 'Importa', + 'collections.file.change': 'Canvia', + 'collections.file.labelCount': '{count} etiquetes', + 'collections.file.hint': + 'Els llocs arriben com una llista nova teva. Les valoracions, els membres i les fotos pujades es queden enrere.', + 'collections.file.done': '{count} llocs importats', + 'collections.file.doneSkipped': '{count} llocs importats, {skipped} omesos', + 'collections.file.errorTooLarge': 'Aquest fitxer és massa gran per ser una llista.', + 'collections.file.errorUnreadable': 'No s’ha pogut llegir aquest fitxer.', + 'collections.file.errorNotACollection': 'Això no és un fitxer de llista del TREK.', + 'collections.share.title': 'Compartir llista', 'collections.share.titleNamed': 'Compartir «{name}»', 'collections.share.button': 'Compartir', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index 6e4efc1a5..a6f2782da 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -143,6 +143,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Vyberte místo ze svých sbírek', 'collections.picker.use': 'Použít', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportovat', + 'collections.file.exportTitle': 'Stáhnout tento seznam jako soubor', + 'collections.file.importButton': 'Importovat seznam ze souboru', + 'collections.file.importTitle': 'Import seznamu', + 'collections.file.choose': 'Vyberte soubor se seznamem', + 'collections.file.confirm': 'Importovat', + 'collections.file.change': 'Změnit', + 'collections.file.labelCount': 'Štítky: {count}', + 'collections.file.hint': + 'Místa se objeví jako váš nový seznam. Hodnocení, členové a nahrané fotky zůstanou tam, kde byly.', + 'collections.file.done': 'Importováno míst: {count}', + 'collections.file.doneSkipped': 'Importováno míst: {count}, přeskočeno: {skipped}', + 'collections.file.errorTooLarge': 'Tento soubor je na seznam příliš velký.', + 'collections.file.errorUnreadable': 'Tento soubor se nepodařilo přečíst.', + 'collections.file.errorNotACollection': 'Toto není soubor se seznamem TREK.', + 'collections.share.title': 'Sdílet seznam', 'collections.share.titleNamed': 'Sdílet „{name}“', 'collections.share.button': 'Sdílet', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index accbae404..4338e6b3d 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -145,6 +145,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Wähle einen Ort aus deinen Sammlungen', 'collections.picker.use': 'Übernehmen', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportieren', + 'collections.file.exportTitle': 'Diese Liste als Datei herunterladen', + 'collections.file.importButton': 'Liste aus einer Datei importieren', + 'collections.file.importTitle': 'Liste importieren', + 'collections.file.choose': 'Listendatei auswählen', + 'collections.file.confirm': 'Importieren', + 'collections.file.change': 'Ändern', + 'collections.file.labelCount': '{count} Labels', + 'collections.file.hint': + 'Die Orte landen in einer neuen eigenen Liste. Bewertungen, Mitglieder und hochgeladene Fotos bleiben zurück.', + 'collections.file.done': '{count} Orte importiert', + 'collections.file.doneSkipped': '{count} Orte importiert, {skipped} übersprungen', + 'collections.file.errorTooLarge': 'Diese Datei ist zu groß für eine Liste.', + 'collections.file.errorUnreadable': 'Diese Datei konnte nicht gelesen werden.', + 'collections.file.errorNotACollection': 'Das ist keine TREK-Listendatei.', + 'collections.share.title': 'Liste teilen', 'collections.share.titleNamed': '„{name}“ teilen', 'collections.share.button': 'Teilen', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index f4cc63657..2c10c3a1b 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Pick a place from your collections', 'collections.picker.use': 'Use', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Export', + 'collections.file.exportTitle': 'Download this list as a file', + 'collections.file.importButton': 'Import a list from a file', + 'collections.file.importTitle': 'Import a list', + 'collections.file.choose': 'Choose a list file', + 'collections.file.confirm': 'Import', + 'collections.file.change': 'Change', + 'collections.file.labelCount': '{count} labels', + 'collections.file.hint': + 'The places arrive as a new list of your own. Ratings, members and uploaded photos stay behind.', + 'collections.file.done': '{count} places imported', + 'collections.file.doneSkipped': '{count} places imported, {skipped} skipped', + 'collections.file.errorTooLarge': 'That file is too large to be a list.', + 'collections.file.errorUnreadable': 'That file could not be read.', + 'collections.file.errorNotACollection': 'That is not a TREK list file.', + 'collections.share.title': 'Share list', 'collections.share.titleNamed': 'Share “{name}”', 'collections.share.button': 'Share', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index 296fc1e45..e8a91b189 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Elige un lugar de tus colecciones', 'collections.picker.use': 'Usar', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportar', + 'collections.file.exportTitle': 'Descargar esta lista como archivo', + 'collections.file.importButton': 'Importar una lista desde un archivo', + 'collections.file.importTitle': 'Importar una lista', + 'collections.file.choose': 'Elegir un archivo de lista', + 'collections.file.confirm': 'Importar', + 'collections.file.change': 'Cambiar', + 'collections.file.labelCount': '{count} etiquetas', + 'collections.file.hint': + 'Los lugares llegan como una lista nueva tuya. Las valoraciones, los miembros y las fotos subidas se quedan atrás.', + 'collections.file.done': '{count} lugares importados', + 'collections.file.doneSkipped': '{count} lugares importados, {skipped} omitidos', + 'collections.file.errorTooLarge': 'Ese archivo es demasiado grande para ser una lista.', + 'collections.file.errorUnreadable': 'No se ha podido leer ese archivo.', + 'collections.file.errorNotACollection': 'Ese no es un archivo de lista de TREK.', + 'collections.share.title': 'Compartir lista', 'collections.share.titleNamed': 'Compartir «{name}»', 'collections.share.button': 'Compartir', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index 764b07c70..7eeb77b10 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Choisissez un lieu dans vos collections', 'collections.picker.use': 'Utiliser', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exporter', + 'collections.file.exportTitle': 'Télécharger cette liste sous forme de fichier', + 'collections.file.importButton': 'Importer une liste depuis un fichier', + 'collections.file.importTitle': 'Importer une liste', + 'collections.file.choose': 'Choisir un fichier de liste', + 'collections.file.confirm': 'Importer', + 'collections.file.change': 'Changer', + 'collections.file.labelCount': '{count} étiquettes', + 'collections.file.hint': + 'Les lieux arrivent dans une nouvelle liste qui vous appartient. Les notes, les membres et les photos envoyées restent en arrière.', + 'collections.file.done': '{count} lieux importés', + 'collections.file.doneSkipped': '{count} lieux importés, {skipped} ignorés', + 'collections.file.errorTooLarge': 'Ce fichier est trop volumineux pour être une liste.', + 'collections.file.errorUnreadable': 'Ce fichier n’a pas pu être lu.', + 'collections.file.errorNotACollection': 'Ce n’est pas un fichier de liste TREK.', + 'collections.share.title': 'Partager la liste', 'collections.share.titleNamed': 'Partager « {name} »', 'collections.share.button': 'Partager', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 126b64de9..7d90c14a4 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Διάλεξε ένα μέρος από τις συλλογές σου', 'collections.picker.use': 'Χρήση', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Εξαγωγή', + 'collections.file.exportTitle': 'Λήψη αυτής της λίστας ως αρχείο', + 'collections.file.importButton': 'Εισαγωγή λίστας από αρχείο', + 'collections.file.importTitle': 'Εισαγωγή λίστας', + 'collections.file.choose': 'Επιλέξτε αρχείο λίστας', + 'collections.file.confirm': 'Εισαγωγή', + 'collections.file.change': 'Αλλαγή', + 'collections.file.labelCount': '{count} ετικέτες', + 'collections.file.hint': + 'Τα μέρη έρχονται ως νέα δική σας λίστα. Οι βαθμολογίες, τα μέλη και οι φωτογραφίες που ανέβηκαν μένουν πίσω.', + 'collections.file.done': 'Εισήχθησαν {count} μέρη', + 'collections.file.doneSkipped': 'Εισήχθησαν {count} μέρη, παραλείφθηκαν {skipped}', + 'collections.file.errorTooLarge': 'Αυτό το αρχείο είναι πολύ μεγάλο για λίστα.', + 'collections.file.errorUnreadable': 'Δεν ήταν δυνατή η ανάγνωση αυτού του αρχείου.', + 'collections.file.errorNotACollection': 'Αυτό δεν είναι αρχείο λίστας του TREK.', + 'collections.share.title': 'Κοινή χρήση λίστας', 'collections.share.titleNamed': 'Κοινή χρήση «{name}»', 'collections.share.button': 'Κοινή χρήση', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index c9fc35d77..f1a6729f9 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -145,6 +145,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Válassz egy helyet a gyűjteményeidből', 'collections.picker.use': 'Használat', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportálás', + 'collections.file.exportTitle': 'A lista letöltése fájlként', + 'collections.file.importButton': 'Lista importálása fájlból', + 'collections.file.importTitle': 'Lista importálása', + 'collections.file.choose': 'Válassz listafájlt', + 'collections.file.confirm': 'Importálás', + 'collections.file.change': 'Csere', + 'collections.file.labelCount': '{count} címke', + 'collections.file.hint': + 'A helyek új, saját listaként érkeznek. Az értékelések, a tagok és a feltöltött fotók nem jönnek át.', + 'collections.file.done': '{count} hely importálva', + 'collections.file.doneSkipped': '{count} hely importálva, {skipped} kihagyva', + 'collections.file.errorTooLarge': 'Ez a fájl túl nagy ahhoz, hogy lista legyen.', + 'collections.file.errorUnreadable': 'Ezt a fájlt nem sikerült beolvasni.', + 'collections.file.errorNotACollection': 'Ez nem TREK-listafájl.', + 'collections.share.title': 'Lista megosztása', 'collections.share.titleNamed': '„{name}” megosztása', 'collections.share.button': 'Megosztás', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index 3775f83c4..fa9daba34 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Pilih tempat dari koleksimu', 'collections.picker.use': 'Gunakan', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Ekspor', + 'collections.file.exportTitle': 'Unduh daftar ini sebagai berkas', + 'collections.file.importButton': 'Impor daftar dari berkas', + 'collections.file.importTitle': 'Impor daftar', + 'collections.file.choose': 'Pilih berkas daftar', + 'collections.file.confirm': 'Impor', + 'collections.file.change': 'Ganti', + 'collections.file.labelCount': '{count} label', + 'collections.file.hint': + 'Tempat-tempat itu masuk sebagai daftar baru milikmu. Penilaian, anggota, dan foto yang diunggah tidak ikut.', + 'collections.file.done': '{count} tempat diimpor', + 'collections.file.doneSkipped': '{count} tempat diimpor, {skipped} dilewati', + 'collections.file.errorTooLarge': 'Berkas ini terlalu besar untuk sebuah daftar.', + 'collections.file.errorUnreadable': 'Berkas ini tidak bisa dibaca.', + 'collections.file.errorNotACollection': 'Ini bukan berkas daftar TREK.', + 'collections.share.title': 'Bagikan daftar', 'collections.share.titleNamed': 'Bagikan “{name}”', 'collections.share.button': 'Bagikan', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index 37818e910..d6055e142 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Scegli un luogo dalle tue raccolte', 'collections.picker.use': 'Usa', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Esporta', + 'collections.file.exportTitle': 'Scarica questo elenco come file', + 'collections.file.importButton': 'Importa un elenco da un file', + 'collections.file.importTitle': 'Importa un elenco', + 'collections.file.choose': 'Scegli un file di elenco', + 'collections.file.confirm': 'Importa', + 'collections.file.change': 'Cambia', + 'collections.file.labelCount': '{count} etichette', + 'collections.file.hint': + 'I luoghi arrivano come un nuovo elenco tuo. Valutazioni, membri e foto caricate restano indietro.', + 'collections.file.done': '{count} luoghi importati', + 'collections.file.doneSkipped': '{count} luoghi importati, {skipped} saltati', + 'collections.file.errorTooLarge': 'Questo file è troppo grande per essere un elenco.', + 'collections.file.errorUnreadable': 'Non è stato possibile leggere questo file.', + 'collections.file.errorNotACollection': 'Questo non è un file di elenco TREK.', + 'collections.share.title': 'Condividi lista', 'collections.share.titleNamed': 'Condividi «{name}»', 'collections.share.button': 'Condividi', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index b6c14805d..019e71a80 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -143,6 +143,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'コレクションから場所を選択', 'collections.picker.use': '使用', + // Export / import a list as a file (#2198) + 'collections.file.export': 'エクスポート', + 'collections.file.exportTitle': 'このリストをファイルとしてダウンロード', + 'collections.file.importButton': 'ファイルからリストをインポート', + 'collections.file.importTitle': 'リストのインポート', + 'collections.file.choose': 'リストファイルを選択', + 'collections.file.confirm': 'インポート', + 'collections.file.change': '変更', + 'collections.file.labelCount': 'ラベル {count} 件', + 'collections.file.hint': + '場所は自分の新しいリストとして追加されます。評価、メンバー、アップロードした写真は引き継がれません。', + 'collections.file.done': '{count} 件の場所をインポートしました', + 'collections.file.doneSkipped': '{count} 件の場所をインポートし、{skipped} 件をスキップしました', + 'collections.file.errorTooLarge': 'このファイルはリストとしては大きすぎます。', + 'collections.file.errorUnreadable': 'このファイルを読み取れませんでした。', + 'collections.file.errorNotACollection': 'これは TREK のリストファイルではありません。', + 'collections.share.title': 'リストを共有', 'collections.share.titleNamed': '「{name}」を共有', 'collections.share.button': '共有', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index 933a47a45..7f7e0dab0 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -143,6 +143,22 @@ const collection: TranslationStrings = { 'collections.picker.hint': '컬렉션에서 장소를 선택하세요', 'collections.picker.use': '사용', + // Export / import a list as a file (#2198) + 'collections.file.export': '내보내기', + 'collections.file.exportTitle': '이 목록을 파일로 다운로드', + 'collections.file.importButton': '파일에서 목록 가져오기', + 'collections.file.importTitle': '목록 가져오기', + 'collections.file.choose': '목록 파일 선택', + 'collections.file.confirm': '가져오기', + 'collections.file.change': '변경', + 'collections.file.labelCount': '라벨 {count}개', + 'collections.file.hint': '장소는 내 새 목록으로 들어옵니다. 평가, 멤버, 업로드한 사진은 함께 오지 않습니다.', + 'collections.file.done': '장소 {count}개를 가져왔습니다', + 'collections.file.doneSkipped': '장소 {count}개를 가져오고 {skipped}개를 건너뛰었습니다', + 'collections.file.errorTooLarge': '이 파일은 목록이라고 하기에 너무 큽니다.', + 'collections.file.errorUnreadable': '이 파일을 읽을 수 없습니다.', + 'collections.file.errorNotACollection': 'TREK 목록 파일이 아닙니다.', + 'collections.share.title': '목록 공유', 'collections.share.titleNamed': '“{name}” 공유', 'collections.share.button': '공유', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index 93d132a26..d77635e20 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Kies een plek uit je collecties', 'collections.picker.use': 'Gebruiken', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exporteren', + 'collections.file.exportTitle': 'Deze lijst als bestand downloaden', + 'collections.file.importButton': 'Een lijst uit een bestand importeren', + 'collections.file.importTitle': 'Lijst importeren', + 'collections.file.choose': 'Kies een lijstbestand', + 'collections.file.confirm': 'Importeren', + 'collections.file.change': 'Wijzigen', + 'collections.file.labelCount': '{count} labels', + 'collections.file.hint': + 'De plekken komen binnen als een nieuwe eigen lijst. Beoordelingen, leden en geüploade foto’s blijven achter.', + 'collections.file.done': '{count} plekken geïmporteerd', + 'collections.file.doneSkipped': '{count} plekken geïmporteerd, {skipped} overgeslagen', + 'collections.file.errorTooLarge': 'Dit bestand is te groot om een lijst te zijn.', + 'collections.file.errorUnreadable': 'Dit bestand kon niet worden gelezen.', + 'collections.file.errorNotACollection': 'Dit is geen TREK-lijstbestand.', + 'collections.share.title': 'Lijst delen', 'collections.share.titleNamed': '“{name}” delen', 'collections.share.button': 'Delen', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 2cca3b0f3..9cdd49198 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Wybierz miejsce ze swoich kolekcji', 'collections.picker.use': 'Użyj', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Eksportuj', + 'collections.file.exportTitle': 'Pobierz tę listę jako plik', + 'collections.file.importButton': 'Zaimportuj listę z pliku', + 'collections.file.importTitle': 'Importuj listę', + 'collections.file.choose': 'Wybierz plik listy', + 'collections.file.confirm': 'Importuj', + 'collections.file.change': 'Zmień', + 'collections.file.labelCount': 'Etykiety: {count}', + 'collections.file.hint': + 'Miejsca trafią do nowej, własnej listy. Oceny, członkowie i wgrane zdjęcia zostaną pominięte.', + 'collections.file.done': 'Zaimportowano miejsca: {count}', + 'collections.file.doneSkipped': 'Zaimportowano miejsca: {count}, pominięto: {skipped}', + 'collections.file.errorTooLarge': 'Ten plik jest za duży, aby był listą.', + 'collections.file.errorUnreadable': 'Nie udało się odczytać tego pliku.', + 'collections.file.errorNotACollection': 'To nie jest plik listy TREK.', + 'collections.share.title': 'Udostępnij listę', 'collections.share.titleNamed': 'Udostępnij „{name}”', 'collections.share.button': 'Udostępnij', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index db4d8f435..d3e2bfe17 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -143,6 +143,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Выберите место из своих коллекций', 'collections.picker.use': 'Использовать', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Экспорт', + 'collections.file.exportTitle': 'Скачать этот список файлом', + 'collections.file.importButton': 'Импортировать список из файла', + 'collections.file.importTitle': 'Импорт списка', + 'collections.file.choose': 'Выберите файл списка', + 'collections.file.confirm': 'Импортировать', + 'collections.file.change': 'Изменить', + 'collections.file.labelCount': 'Меток: {count}', + 'collections.file.hint': + 'Места появятся в новом собственном списке. Оценки, участники и загруженные фотографии остаются на прежнем месте.', + 'collections.file.done': 'Импортировано мест: {count}', + 'collections.file.doneSkipped': 'Импортировано мест: {count}, пропущено: {skipped}', + 'collections.file.errorTooLarge': 'Этот файл слишком велик для списка.', + 'collections.file.errorUnreadable': 'Не удалось прочитать этот файл.', + 'collections.file.errorNotACollection': 'Это не файл списка TREK.', + 'collections.share.title': 'Поделиться списком', 'collections.share.titleNamed': 'Поделиться «{name}»', 'collections.share.button': 'Поделиться', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index cc1ec3a96..42b2f51b0 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -143,6 +143,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Välj en plats från dina samlingar', 'collections.picker.use': 'Använd', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Exportera', + 'collections.file.exportTitle': 'Ladda ner den här listan som en fil', + 'collections.file.importButton': 'Importera en lista från en fil', + 'collections.file.importTitle': 'Importera lista', + 'collections.file.choose': 'Välj en listfil', + 'collections.file.confirm': 'Importera', + 'collections.file.change': 'Byt', + 'collections.file.labelCount': '{count} etiketter', + 'collections.file.hint': + 'Platserna kommer in som en ny egen lista. Betyg, medlemmar och uppladdade foton följer inte med.', + 'collections.file.done': '{count} platser importerade', + 'collections.file.doneSkipped': '{count} platser importerade, {skipped} överhoppade', + 'collections.file.errorTooLarge': 'Filen är för stor för att vara en lista.', + 'collections.file.errorUnreadable': 'Filen gick inte att läsa.', + 'collections.file.errorNotACollection': 'Det här är ingen TREK-listfil.', + 'collections.share.title': 'Dela lista', 'collections.share.titleNamed': 'Dela ”{name}”', 'collections.share.button': 'Dela', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index 344831ff5..61514b162 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Koleksiyonlarından bir yer seç', 'collections.picker.use': 'Kullan', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Dışa aktar', + 'collections.file.exportTitle': 'Bu listeyi dosya olarak indir', + 'collections.file.importButton': 'Dosyadan liste içe aktar', + 'collections.file.importTitle': 'Liste içe aktar', + 'collections.file.choose': 'Bir liste dosyası seç', + 'collections.file.confirm': 'İçe aktar', + 'collections.file.change': 'Değiştir', + 'collections.file.labelCount': '{count} etiket', + 'collections.file.hint': + 'Yerler size ait yeni bir liste olarak gelir. Puanlar, üyeler ve yüklenen fotoğraflar geride kalır.', + 'collections.file.done': '{count} yer içe aktarıldı', + 'collections.file.doneSkipped': '{count} yer içe aktarıldı, {skipped} tanesi atlandı', + 'collections.file.errorTooLarge': 'Bu dosya liste olamayacak kadar büyük.', + 'collections.file.errorUnreadable': 'Bu dosya okunamadı.', + 'collections.file.errorNotACollection': 'Bu bir TREK liste dosyası değil.', + 'collections.share.title': 'Listeyi paylaş', 'collections.share.titleNamed': '“{name}” listesini paylaş', 'collections.share.button': 'Paylaş', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 68d703213..63e7a4a84 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -143,6 +143,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Виберіть місце зі своїх колекцій', 'collections.picker.use': 'Використати', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Експортувати', + 'collections.file.exportTitle': 'Завантажити цей список файлом', + 'collections.file.importButton': 'Імпортувати список із файлу', + 'collections.file.importTitle': 'Імпорт списку', + 'collections.file.choose': 'Виберіть файл списку', + 'collections.file.confirm': 'Імпортувати', + 'collections.file.change': 'Змінити', + 'collections.file.labelCount': 'Міток: {count}', + 'collections.file.hint': + 'Місця з’являться як ваш новий список. Оцінки, учасники та завантажені фото не переносяться.', + 'collections.file.done': 'Імпортовано місць: {count}', + 'collections.file.doneSkipped': 'Імпортовано місць: {count}, пропущено: {skipped}', + 'collections.file.errorTooLarge': 'Цей файл завеликий, щоб бути списком.', + 'collections.file.errorUnreadable': 'Не вдалося прочитати цей файл.', + 'collections.file.errorNotACollection': 'Це не файл списку TREK.', + 'collections.share.title': 'Поділитися списком', 'collections.share.titleNamed': 'Поділитися «{name}»', 'collections.share.button': 'Поділитися', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index 3f8a80624..aa43be0fe 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -144,6 +144,23 @@ const collection: TranslationStrings = { 'collections.picker.hint': 'Chọn một địa điểm từ bộ sưu tập của bạn', 'collections.picker.use': 'Dùng', + // Export / import a list as a file (#2198) + 'collections.file.export': 'Xuất', + 'collections.file.exportTitle': 'Tải danh sách này về dưới dạng tệp', + 'collections.file.importButton': 'Nhập danh sách từ tệp', + '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.change': 'Đổi', + 'collections.file.labelCount': '{count} nhãn', + 'collections.file.hint': + 'Các địa điểm sẽ vào một danh sách mới của bạn. Đánh giá, thành viên và ảnh đã tải lên không đi kèm.', + 'collections.file.done': 'Đã nhập {count} địa điểm', + 'collections.file.doneSkipped': 'Đã nhập {count} địa điểm, bỏ qua {skipped}', + 'collections.file.errorTooLarge': 'Tệp này quá lớn để là một danh sách.', + 'collections.file.errorUnreadable': 'Không đọc được tệp này.', + 'collections.file.errorNotACollection': 'Đây không phải tệp danh sách của TREK.', + 'collections.share.title': 'Chia sẻ danh sách', 'collections.share.titleNamed': 'Chia sẻ “{name}”', 'collections.share.button': 'Chia sẻ', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index 4027978d2..7cf16aa85 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -143,6 +143,22 @@ const collection: TranslationStrings = { 'collections.picker.hint': '從你的收藏中選擇一個地點', 'collections.picker.use': '使用', + // Export / import a list as a file (#2198) + 'collections.file.export': '匯出', + 'collections.file.exportTitle': '將此清單下載為檔案', + 'collections.file.importButton': '從檔案匯入清單', + 'collections.file.importTitle': '匯入清單', + 'collections.file.choose': '選擇清單檔案', + 'collections.file.confirm': '匯入', + 'collections.file.change': '更換', + 'collections.file.labelCount': '{count} 個標籤', + 'collections.file.hint': '這些地點會成為你自己的新清單。評分、成員與上傳的照片不會一併匯入。', + 'collections.file.done': '已匯入 {count} 個地點', + 'collections.file.doneSkipped': '已匯入 {count} 個地點,略過 {skipped} 個', + 'collections.file.errorTooLarge': '這個檔案太大,不像是一份清單。', + 'collections.file.errorUnreadable': '無法讀取這個檔案。', + 'collections.file.errorNotACollection': '這不是 TREK 清單檔案。', + 'collections.share.title': '分享清單', 'collections.share.titleNamed': '分享「{name}」', 'collections.share.button': '分享', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index 7a8fbfb62..11b04d690 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -143,6 +143,22 @@ const collection: TranslationStrings = { 'collections.picker.hint': '从你的收藏中选择一个地点', 'collections.picker.use': '使用', + // Export / import a list as a file (#2198) + 'collections.file.export': '导出', + 'collections.file.exportTitle': '将此列表下载为文件', + 'collections.file.importButton': '从文件导入列表', + 'collections.file.importTitle': '导入列表', + 'collections.file.choose': '选择列表文件', + 'collections.file.confirm': '导入', + 'collections.file.change': '更换', + 'collections.file.labelCount': '{count} 个标签', + 'collections.file.hint': '这些地点会成为你自己的新列表。评分、成员和上传的照片不会一起导入。', + 'collections.file.done': '已导入 {count} 个地点', + 'collections.file.doneSkipped': '已导入 {count} 个地点,跳过 {skipped} 个', + 'collections.file.errorTooLarge': '该文件太大,不像是一个列表。', + 'collections.file.errorUnreadable': '无法读取该文件。', + 'collections.file.errorNotACollection': '这不是 TREK 列表文件。', + 'collections.share.title': '共享列表', 'collections.share.titleNamed': '共享“{name}”', 'collections.share.button': '共享', diff --git a/shared/src/index.ts b/shared/src/index.ts index 32f7b5712..867b4290f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -41,6 +41,7 @@ export * from './place/place-match'; export * from './roadtrip/roadtrip.schema'; export * from './place/track-colors'; export * from './collection/collection.schema'; +export * from './collection/collection-file.schema'; export * from './trip/trip.schema'; export * from './trip-invite/trip-invite.schema'; export * from './collab/collab.schema'; diff --git a/wiki/Collections.md b/wiki/Collections.md index e1f57b305..ab4a0b140 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -96,6 +96,22 @@ When sharing, the owner assigns each member a permission role, and can change it The owner always has full control. The owner can also remove a member, and a member can leave a shared list themselves. Permissions are enforced on the server, so a role can only ever do what it is allowed to. +## Sending a list to someone else + +Sharing works between people on the same TREK. To give a list to somebody who runs their own, or to keep a copy of your own, export it as a file. + +**Export** sits next to Edit and Share in the list header, on desktop. It 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. Any member of a shared list may export it. + +**Import** sits next to **New list** in the lists rail, as the button with the upload arrow. Pick a 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. + +A few things deliberately stay behind, because they belong to the instance the file came from rather than to the list: + +- **Star ratings and members.** A rating is an opinion somebody gave the people on that list. +- **Uploaded photos and cover images.** They live on the sender's server; a place keeps an image only when it is an ordinary `https://` address. +- **Ids of any kind**, including which trip a place was originally saved from. The receiving instance issues its own; a category comes across by name and is matched against the palette on the other side, or left empty when there is no match. + +A file that is not a TREK list is refused with a reason. A single place inside a file that cannot be read is skipped and reported, so one bad line does not cost you the rest of the list. + ## See also - [Addons-Overview](Addons-Overview)