diff --git a/server/src/nest/atlas/atlas.controller.ts b/server/src/nest/atlas/atlas.controller.ts index 7b195c2d6..496b4f8ff 100644 --- a/server/src/nest/atlas/atlas.controller.ts +++ b/server/src/nest/atlas/atlas.controller.ts @@ -63,9 +63,21 @@ export class AtlasController { } @Get('countries/geo') - @Header('Cache-Control', 'public, max-age=86400') - countryGeo(): RegionGeo { - return this.atlas.countryGeo(); + countryGeo(@Res() res: Response): void { + // Serve the pre-gzipped admin-0 bundle straight from disk. The browser decompresses + // transparently, so the wire shape is identical to before, but the server never parses + // or holds the ~145MB FeatureCollection (#1576). Content-Encoding is set explicitly, so + // the compression middleware leaves the body untouched. + const gz = this.atlas.countryGeoGz(); + if (!gz) { + res.setHeader('Cache-Control', 'no-store'); + res.json({ type: 'FeatureCollection', features: [] }); + return; + } + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.send(gz); } @Get('country/:code') diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 36e56b3a4..4a8f89024 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -8,7 +8,7 @@ import { unmarkRegionVisited, getVisitedRegions, getRegionGeo, - getCountryGeo, + getCountryGeoGz, listBucketList, createBucketItem, updateBucketItem, @@ -38,8 +38,8 @@ export class AtlasService { return getRegionGeo(countries); } - countryGeo() { - return getCountryGeo(); + countryGeoGz(): Buffer | null { + return getCountryGeoGz(); } countryPlaces(userId: number, code: string) { diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 1c6f9d954..3959eaa4c 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -16,35 +16,160 @@ import { CONTINENT_MAP } from '@trek/shared'; // __dirname is server/dist/services at runtime and server/src/services under vitest; // both resolve ../../assets to server/assets. -const geoBundleCache = new Map(); +// Neither parsed bundle is cached. admin0 (~145MB) and admin1 (~260MB) parsed at once are +// what exhausted a 512MB host while using Atlas (#1576). Instead we retain only compact +// derivatives: the raw admin0 .gz bytes for direct serving, a Float64Array poly/box index +// for server-side point-in-polygon (see buildCountryIndexes), and admin1 pre-split per +// country into ready-to-serve GeoJSON strings, built by streaming the gz so the full +// bundle is never materialised in one piece. -function loadGeoBundle(name: 'admin0' | 'admin1'): any { - const cached = geoBundleCache.get(name); - if (cached) return cached; - const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`); - if (!fs.existsSync(file)) { - console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``); - const empty = { type: 'FeatureCollection', features: [] }; - geoBundleCache.set(name, empty); - return empty; - } - const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')); - geoBundleCache.set(name, geo); - console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`); - return geo; +function assetPath(name: 'admin0' | 'admin1'): string { + return path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`); } -/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */ +let admin0Gz: Buffer | null | undefined; +function loadAdmin0Gz(): Buffer | null { + if (admin0Gz !== undefined) return admin0Gz; + const file = assetPath('admin0'); + if (!fs.existsSync(file)) { + console.warn(`[Atlas] admin0.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``); + return (admin0Gz = null); + } + return (admin0Gz = fs.readFileSync(file)); +} + +/** admin-0 country borders as gzipped GeoJSON bytes, served to the client map with + * Content-Encoding: gzip so the server never holds the parsed FeatureCollection. */ +export function getCountryGeoGz(): Buffer | null { + return loadAdmin0Gz(); +} + +/** Parsed admin-0 FeatureCollection, parsed on demand (not cached). Not on the client hot + * path — the map is served the gz bytes via getCountryGeoGz — but kept for internal/test + * callers that need the objects. */ export function getCountryGeo(): any { - return loadGeoBundle('admin0'); + const gz = loadAdmin0Gz(); + if (!gz) return { type: 'FeatureCollection', features: [] }; + return JSON.parse(zlib.gunzipSync(gz).toString('utf8')); } export async function getRegionGeo(countryCodes: string[]): Promise { - const geo = loadGeoBundle('admin1'); - if (!geo) return { type: 'FeatureCollection', features: [] }; - const codes = new Set(countryCodes.map(c => c.toUpperCase())); - const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase())); - return { type: 'FeatureCollection', features }; + const store = await getAdmin1Store(); + const seen = new Set(); + const parts: string[] = []; + for (const code of countryCodes) { + const c = code.toUpperCase(); + if (seen.has(c)) continue; + seen.add(c); + const s = store.get(c); + if (s) parts.push(s); + } + if (parts.length === 0) return { type: 'FeatureCollection', features: [] }; + // Each stored value is that country's features as comma-joined GeoJSON text; wrap the + // requested subset into one FeatureCollection and parse only that (per-viewport, small). + return JSON.parse(`{"type":"FeatureCollection","features":[${parts.join(',')}]}`); +} + +// admin1 regions, pre-split per ISO_A2 into comma-joined feature text. Built once by +// streaming the gz through a brace-depth splitter that emits one Feature at a time, so the +// ~260MB full parse never happens (it OOMs a 512MB host). Concurrent first-callers share +// one in-flight build via admin1Building. +let admin1Store: Map | null = null; +let admin1Building: Promise> | null = null; + +function getAdmin1Store(): Promise> { + if (admin1Store) return Promise.resolve(admin1Store); + if (!admin1Building) { + admin1Building = buildAdmin1Store().then((s) => { admin1Store = s; admin1Building = null; return s; }); + } + return admin1Building; +} + +// Feed arbitrary gunzip chunks; invokes onFeature(text) once per top-level Feature object +// inside "features":[ … ]. `pending` holds only the unconsumed tail (at most one partial +// feature + the current chunk), keeping memory flat regardless of bundle size. +function createFeatureSplitter(onFeature: (text: string) => void): (chunk: string) => void { + let pending = ''; + let started = false; + // Scan progress is carried across chunks so each character is examined exactly once. + // A large Feature (e.g. Canada, ~5.5MB) spans hundreds of gunzip chunks; re-scanning the + // accumulated partial from the start on every chunk was O(n²) per feature and pushed the + // one-time admin1 build past the 15s test timeout under coverage/CI (#1576-followup). + let scanning = false; // currently inside a Feature object? + let depth = 0, inStr = false, esc = false; + let scanPos = 0, featStart = 0; // resume point + current feature start, in `pending` + return (chunk: string) => { + pending += chunk; + if (!started) { + const fi = pending.indexOf('"features"'); + if (fi === -1) return; + const br = pending.indexOf('[', fi); + if (br === -1) return; + pending = pending.slice(br + 1); + started = true; + scanPos = 0; + } + const n = pending.length; + while (scanPos < n) { + if (!scanning) { + const c = pending[scanPos]; + if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') { scanPos++; continue; } + if (c === ']') { scanPos = n; break; } + if (c !== '{') { scanPos++; continue; } + scanning = true; depth = 0; inStr = false; esc = false; featStart = scanPos; + } + let end = -1; + for (let j = scanPos; j < n; j++) { + const c = pending[j]; + if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; } + else if (c === '"') inStr = true; + else if (c === '{') depth++; + else if (c === '}') { if (--depth === 0) { end = j + 1; scanPos = j + 1; break; } } + } + if (end === -1) { scanPos = n; break; } // partial feature — resume from n next chunk + onFeature(pending.slice(featStart, end)); + scanning = false; + } + // Drop the fully-consumed prefix, keeping at most the current partial feature. + const keepFrom = scanning ? featStart : scanPos; + if (keepFrom > 0) { + pending = pending.slice(keepFrom); + scanPos -= keepFrom; + if (scanning) featStart -= keepFrom; + } + }; +} + +function buildAdmin1Store(): Promise> { + const file = assetPath('admin1'); + if (!fs.existsSync(file)) { + console.warn(`[Atlas] admin1.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``); + return Promise.resolve(new Map()); + } + // Concatenate each country's features straight into the store as we stream, rather than + // collecting arrays and joining at the end (that doubling, plus the source bundle's + // whitespace, peaked high enough to OOM a 512MB host on the first build). Re-serialising + // each feature via JSON drops the source formatting (~114MB → ~68MB retained) and the + // parse/stringify garbage is per-feature and short-lived. + const store = new Map(); + const split = createFeatureSplitter((text) => { + const f = JSON.parse(text); + const code = f.properties?.iso_a2?.toUpperCase(); + if (!code) return; // features with a null iso_a2 are skipped, matching the old filter + const compact = JSON.stringify(f); + const prev = store.get(code); + store.set(code, prev ? prev + ',' + compact : compact); + }); + return new Promise((resolve, reject) => { + fs.createReadStream(file) + .pipe(zlib.createGunzip()) + .on('data', (chunk: Buffer) => split(chunk.toString('utf8'))) + .on('end', () => { + console.log(`[Atlas] Indexed admin1 GeoJSON: ${store.size} countries`); + resolve(store); + }) + .on('error', reject); + }); } // ── Geocode cache ─────────────────────────────────────────────────────────── @@ -181,11 +306,15 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi)) { inside = !inside; } @@ -193,25 +322,32 @@ function pointInRing(lng: number, lat: number, ring: number[][]): boolean { return inside; } -// True when (lng,lat) falls inside a Polygon/MultiPolygon, honouring holes. -function pointInGeometry(lng: number, lat: number, geom: { type: string; coordinates: number[][][] | number[][][][] }): boolean { - const polygons = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) as number[][][][]; - for (const poly of polygons) { - if (!pointInRing(lng, lat, poly[0])) continue; - let inHole = false; - for (let h = 1; h < poly.length; h++) { - if (pointInRing(lng, lat, poly[h])) { inHole = true; break; } +// True when (lng,lat) falls inside a compact Polygon/MultiPolygon, honouring holes. +// `rings` is every ring flattened; `polyRingCounts[k]` is how many rings polygon k owns +// (its first ring is the outer boundary, the rest are holes). +function pointInGeometry(lng: number, lat: number, geom: CompactGeom): boolean { + let ri = 0; + for (const rc of geom.polyRingCounts) { + if (pointInFlatRing(lng, lat, geom.rings[ri])) { + let inHole = false; + for (let h = 1; h < rc; h++) { + if (pointInFlatRing(lng, lat, geom.rings[ri + h])) { inHole = true; break; } + } + if (!inHole) return true; } - if (!inHole) return true; + ri += rc; } return false; } -type Geometry = { type: string; coordinates: number[][][] | number[][][][] }; +// Compact polygon geometry: rings flattened into Float64Arrays, grouped into polygons by +// polyRingCounts. Replaces the retained parsed GeoJSON geometry. +type CompactGeom = { rings: Float64Array[]; polyRingCounts: number[] }; type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat] -// ISO_A2 → admin0 geometry + bounding boxes, both derived from the bundled admin0 -// borders on first use and cached. +// ISO_A2 → compact admin0 geometry + bounding boxes, derived from the bundled admin0 +// borders on first use. The parsed FeatureCollection is dropped after this runs; only the +// Float64Array rings and boxes are retained (≈1MB vs the ≈145MB parsed geometry, #1576). // // The boxes used to be a hand-maintained table, which drifted: 43 countries (NG, BY, // GL, KP, TD, SS, …) had no box at all, so their coordinates fell into a *neighbour's* @@ -222,32 +358,53 @@ type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat] // One box is stored PER GEOMETRY PART, not per country. A single box around a country // that straddles the antimeridian (RU, US, FJ, KI) would span nearly the whole globe; // per-part boxes keep Alaska and Chukotka separate and handle the ±180 wrap for free. -let countryPolyIndex: Map | null = null; +let countryPolyIndex: Map | null = null; let countryBoxIndex: Map | null = null; function buildCountryIndexes(): void { - const polys = new Map(); + const polys = new Map(); const boxes = new Map(); - for (const f of loadGeoBundle('admin0').features ?? []) { - const raw = f.properties?.ISO_A2; - if (!raw || raw === '-99' || !f.geometry) continue; - const code = String(raw).toUpperCase(); - polys.set(code, f.geometry); + const gz = loadAdmin0Gz(); + if (gz) { + // Parse ONE feature at a time off the gunzipped string rather than JSON.parse-ing the + // whole FeatureCollection: the full parse transiently allocates ~285MB (the 145MB + // object graph plus intermediates) and V8 keeps those pages, which alone exhausts a + // 512MB host before admin1 even loads (#1576). + const json = zlib.gunzipSync(gz).toString('utf8'); + const consume = createFeatureSplitter((text) => { + const f = JSON.parse(text); + const raw = f.properties?.ISO_A2; + if (!raw || raw === '-99' || !f.geometry) return; + const code = String(raw).toUpperCase(); - const parts = (f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates) as number[][][][]; - const codeBoxes = boxes.get(code) ?? []; - for (const part of parts) { - let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity; - for (const [lng, lat] of part[0]) { - if (lng < minLng) minLng = lng; - if (lng > maxLng) maxLng = lng; - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; + const parts = (f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates) as number[][][][]; + const rings: Float64Array[] = []; + const polyRingCounts: number[] = []; + const codeBoxes = boxes.get(code) ?? []; + for (const part of parts) { + polyRingCounts.push(part.length); + for (const ring of part) { + const flat = new Float64Array(ring.length * 2); + for (let i = 0; i < ring.length; i++) { flat[2 * i] = ring[i][0]; flat[2 * i + 1] = ring[i][1]; } + rings.push(flat); + } + // Bounding box from the part's outer ring (part[0]). + let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity; + for (const [lng, lat] of part[0]) { + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + codeBoxes.push([minLng, minLat, maxLng, maxLat]); } - codeBoxes.push([minLng, minLat, maxLng, maxLat]); - } - boxes.set(code, codeBoxes); + // Matches the previous index exactly: geometry is overwritten (last feature for a + // code wins) while boxes accumulate across a code's features. + polys.set(code, { rings, polyRingCounts }); + boxes.set(code, codeBoxes); + }); + consume(json); } // Micro-territories aren't in admin0 — give them their box, but no polygon. @@ -259,7 +416,7 @@ function buildCountryIndexes(): void { countryBoxIndex = boxes; } -function getCountryPolyIndex(): Map { +function getCountryPolyIndex(): Map { if (!countryPolyIndex) buildCountryIndexes(); return countryPolyIndex!; } diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index 686a98e3a..aac640351 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -5,6 +5,7 @@ * bespoke 400/404 bodies. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import zlib from 'zlib'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; @@ -33,7 +34,7 @@ const { mocks } = vi.hoisted(() => ({ unmarkRegionVisited: vi.fn(), getVisitedRegions: vi.fn(), getRegionGeo: vi.fn(), - getCountryGeo: vi.fn(), + getCountryGeoGz: vi.fn(), listBucketList: vi.fn(), createBucketItem: vi.fn(), updateBucketItem: vi.fn(), @@ -76,10 +77,13 @@ describe('Atlas e2e (real auth guard + temp SQLite)', () => { expect(res.status).toBe(401); }); - it('200 countries/geo returns the admin-0 FeatureCollection', async () => { - mocks.getCountryGeo.mockReturnValue({ type: 'FeatureCollection', features: [{ id: 'NO' }] }); + it('200 countries/geo serves gzipped admin-0 that the client decompresses to a FeatureCollection', async () => { + const gz = zlib.gzipSync(JSON.stringify({ type: 'FeatureCollection', features: [{ id: 'NO' }] })); + mocks.getCountryGeoGz.mockReturnValue(gz); const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); + expect(res.headers['content-encoding']).toBe('gzip'); + // superagent transparently decompresses, mirroring the browser. expect(res.body.type).toBe('FeatureCollection'); expect(res.headers['cache-control']).toContain('max-age=86400'); }); diff --git a/server/tests/unit/nest/atlas.controller.test.ts b/server/tests/unit/nest/atlas.controller.test.ts index f6ffb2ecf..4ddd4adc0 100644 --- a/server/tests/unit/nest/atlas.controller.test.ts +++ b/server/tests/unit/nest/atlas.controller.test.ts @@ -12,7 +12,11 @@ function makeController(svc: Partial) { } function makeRes() { - return { setHeader: vi.fn() } as unknown as Response & { setHeader: ReturnType }; + return { setHeader: vi.fn(), send: vi.fn(), json: vi.fn() } as unknown as Response & { + setHeader: ReturnType; + send: ReturnType; + json: ReturnType; + }; } async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { @@ -53,11 +57,22 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () }); }); - it('GET /countries/geo delegates to the service', () => { - const fc = { type: 'FeatureCollection', features: [{ id: 'NO' }] }; - const countryGeo = vi.fn().mockReturnValue(fc); - expect(makeController({ countryGeo }).countryGeo()).toBe(fc); - expect(countryGeo).toHaveBeenCalledWith(); + it('GET /countries/geo serves the gzipped admin-0 bundle with Content-Encoding', () => { + const gz = Buffer.from('gzipped-bytes'); + const countryGeoGz = vi.fn().mockReturnValue(gz); + const res = makeRes(); + makeController({ countryGeoGz }).countryGeo(res); + expect(res.setHeader).toHaveBeenCalledWith('Content-Encoding', 'gzip'); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400'); + expect(res.send).toHaveBeenCalledWith(gz); + }); + + it('GET /countries/geo falls back to an empty FeatureCollection when the bundle is missing', () => { + const countryGeoGz = vi.fn().mockReturnValue(null); + const res = makeRes(); + makeController({ countryGeoGz }).countryGeo(res); + expect(res.json).toHaveBeenCalledWith({ type: 'FeatureCollection', features: [] }); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); }); describe('country', () => { diff --git a/shared/src/i18n/ar/packing.ts b/shared/src/i18n/ar/packing.ts index 479ca6a68..f5bcbb3fa 100644 --- a/shared/src/i18n/ar/packing.ts +++ b/shared/src/i18n/ar/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'أمتعة', 'packing.noBag': 'غير معيّن', 'packing.totalWeight': 'الوزن الإجمالي', + 'packing.quantity': 'الكمية', 'packing.bagName': 'الاسم...', 'packing.addBag': 'إضافة أمتعة', 'packing.changeCategory': 'نقل إلى قائمة', diff --git a/shared/src/i18n/br/packing.ts b/shared/src/i18n/br/packing.ts index d7d2b9206..e8f13c5c7 100644 --- a/shared/src/i18n/br/packing.ts +++ b/shared/src/i18n/br/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Malas', 'packing.noBag': 'Sem mala', 'packing.totalWeight': 'Peso total', + 'packing.quantity': 'Qtd.', 'packing.bagName': 'Nome da mala...', 'packing.addBag': 'Adicionar mala', 'packing.changeCategory': 'Mover para lista', diff --git a/shared/src/i18n/cs/packing.ts b/shared/src/i18n/cs/packing.ts index d60765bc9..eed4bbfee 100644 --- a/shared/src/i18n/cs/packing.ts +++ b/shared/src/i18n/cs/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Zavazadla', 'packing.noBag': 'Nepřiřazeno', 'packing.totalWeight': 'Celková váha', + 'packing.quantity': 'Počet', 'packing.bagName': 'Název zavazadla...', 'packing.addBag': 'Přidat zavazadlo', 'packing.changeCategory': 'Přesunout do seznamu', diff --git a/shared/src/i18n/es/packing.ts b/shared/src/i18n/es/packing.ts index 729f66adc..4f74658ad 100644 --- a/shared/src/i18n/es/packing.ts +++ b/shared/src/i18n/es/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Equipaje', 'packing.noBag': 'Sin asignar', 'packing.totalWeight': 'Peso total', + 'packing.quantity': 'Cant.', 'packing.bagName': 'Nombre...', 'packing.addBag': 'Añadir equipaje', 'packing.changeCategory': 'Mover a lista', diff --git a/shared/src/i18n/fr/packing.ts b/shared/src/i18n/fr/packing.ts index 81d439df2..5c39d2f27 100644 --- a/shared/src/i18n/fr/packing.ts +++ b/shared/src/i18n/fr/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Bagages', 'packing.noBag': 'Non assigné', 'packing.totalWeight': 'Poids total', + 'packing.quantity': 'Qté', 'packing.bagName': 'Nom...', 'packing.addBag': 'Ajouter un bagage', 'packing.changeCategory': 'Déplacer vers une liste', diff --git a/shared/src/i18n/gr/packing.ts b/shared/src/i18n/gr/packing.ts index 5715a8b60..01c9efb16 100644 --- a/shared/src/i18n/gr/packing.ts +++ b/shared/src/i18n/gr/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Τσάντες', 'packing.noBag': 'Χωρίς ανάθεση', 'packing.totalWeight': 'Συνολικό βάρος', + 'packing.quantity': 'Ποσ.', 'packing.bagName': 'Όνομα τσάντας...', 'packing.addBag': 'Προσθήκη τσάντας', 'packing.changeCategory': 'Μετακίνηση σε λίστα', diff --git a/shared/src/i18n/hu/packing.ts b/shared/src/i18n/hu/packing.ts index f3a1c2d08..115594266 100644 --- a/shared/src/i18n/hu/packing.ts +++ b/shared/src/i18n/hu/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Táskák', 'packing.noBag': 'Nincs hozzárendelve', 'packing.totalWeight': 'Összsúly', + 'packing.quantity': 'Menny.', 'packing.bagName': 'Táska neve...', 'packing.addBag': 'Táska hozzáadása', 'packing.changeCategory': 'Áthelyezés listába', diff --git a/shared/src/i18n/id/packing.ts b/shared/src/i18n/id/packing.ts index 42e5b9dea..edfb7f029 100644 --- a/shared/src/i18n/id/packing.ts +++ b/shared/src/i18n/id/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Tas', 'packing.noBag': 'Belum ditugaskan', 'packing.totalWeight': 'Total berat', + 'packing.quantity': 'Jml', 'packing.bagName': 'Nama tas...', 'packing.addBag': 'Tambah tas', 'packing.changeCategory': 'Pindahkan ke daftar', diff --git a/shared/src/i18n/it/packing.ts b/shared/src/i18n/it/packing.ts index 619bbeea8..2b04dfd81 100644 --- a/shared/src/i18n/it/packing.ts +++ b/shared/src/i18n/it/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Valigie', 'packing.noBag': 'Non assegnato', 'packing.totalWeight': 'Peso totale', + 'packing.quantity': 'Qtà', 'packing.bagName': 'Nome valigia...', 'packing.addBag': 'Aggiungi valigia', 'packing.changeCategory': 'Sposta nella lista', diff --git a/shared/src/i18n/ja/packing.ts b/shared/src/i18n/ja/packing.ts index 9ff2c1270..6c8bdf267 100644 --- a/shared/src/i18n/ja/packing.ts +++ b/shared/src/i18n/ja/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'バッグ', 'packing.noBag': '未割り当て', 'packing.totalWeight': '総重量', + 'packing.quantity': '数量', 'packing.bagName': 'バッグ名...', 'packing.addBag': 'バッグを追加', 'packing.changeCategory': 'リストへ移動', diff --git a/shared/src/i18n/ko/packing.ts b/shared/src/i18n/ko/packing.ts index 7f1b31b77..c6a46dc9e 100644 --- a/shared/src/i18n/ko/packing.ts +++ b/shared/src/i18n/ko/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': '가방', 'packing.noBag': '미배정', 'packing.totalWeight': '총 무게', + 'packing.quantity': '수량', 'packing.bagName': '가방 이름...', 'packing.addBag': '가방 추가', 'packing.changeCategory': '목록으로 이동', diff --git a/shared/src/i18n/nl/packing.ts b/shared/src/i18n/nl/packing.ts index 01bd89cf2..a17fb21c1 100644 --- a/shared/src/i18n/nl/packing.ts +++ b/shared/src/i18n/nl/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Bagage', 'packing.noBag': 'Niet toegewezen', 'packing.totalWeight': 'Totaalgewicht', + 'packing.quantity': 'Aantal', 'packing.bagName': 'Naam...', 'packing.addBag': 'Bagage toevoegen', 'packing.changeCategory': 'Naar lijst verplaatsen', diff --git a/shared/src/i18n/pl/packing.ts b/shared/src/i18n/pl/packing.ts index f9d15abec..46d51d708 100644 --- a/shared/src/i18n/pl/packing.ts +++ b/shared/src/i18n/pl/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Torby', 'packing.noBag': 'Nieprzypisane', 'packing.totalWeight': 'Waga całkowita', + 'packing.quantity': 'Ilość', 'packing.bagName': 'Nazwa torby...', 'packing.addBag': 'Dodaj torbę', 'packing.changeCategory': 'Przenieś do listy', diff --git a/shared/src/i18n/ru/packing.ts b/shared/src/i18n/ru/packing.ts index 983ea8665..90777dda2 100644 --- a/shared/src/i18n/ru/packing.ts +++ b/shared/src/i18n/ru/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Багаж', 'packing.noBag': 'Не назначено', 'packing.totalWeight': 'Общий вес', + 'packing.quantity': 'Кол-во', 'packing.bagName': 'Название...', 'packing.addBag': 'Добавить багаж', 'packing.changeCategory': 'Переместить в список', diff --git a/shared/src/i18n/sv/packing.ts b/shared/src/i18n/sv/packing.ts index 9bf011cb3..77161c3b7 100644 --- a/shared/src/i18n/sv/packing.ts +++ b/shared/src/i18n/sv/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Väskor', 'packing.noBag': 'Ej tilldelad', 'packing.totalWeight': 'Totalvikt', + 'packing.quantity': 'Antal', 'packing.bagName': 'Väskans namn...', 'packing.addBag': 'Lägg till väska', 'packing.changeCategory': 'Flytta till lista', diff --git a/shared/src/i18n/tr/packing.ts b/shared/src/i18n/tr/packing.ts index b7662b0c2..08e2341de 100644 --- a/shared/src/i18n/tr/packing.ts +++ b/shared/src/i18n/tr/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Çantalar', 'packing.noBag': 'Atanmamış', 'packing.totalWeight': 'Toplam ağırlık', + 'packing.quantity': 'Adet', 'packing.bagName': 'Çanta adı...', 'packing.addBag': 'Çanta ekle', 'packing.changeCategory': 'Listeye taşı', diff --git a/shared/src/i18n/uk/packing.ts b/shared/src/i18n/uk/packing.ts index 4686826a1..7b607d4cb 100644 --- a/shared/src/i18n/uk/packing.ts +++ b/shared/src/i18n/uk/packing.ts @@ -47,6 +47,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Багаж', 'packing.noBag': 'Не призначено', 'packing.totalWeight': 'Загальна вага', + 'packing.quantity': 'К-сть', 'packing.bagName': 'Назва...', 'packing.addBag': 'Додати багаж', 'packing.changeCategory': 'Перемістити до списку', diff --git a/shared/src/i18n/vi/packing.ts b/shared/src/i18n/vi/packing.ts index bed37f3cf..b52b6b442 100644 --- a/shared/src/i18n/vi/packing.ts +++ b/shared/src/i18n/vi/packing.ts @@ -48,6 +48,7 @@ const packing: TranslationStrings = { 'packing.bags': 'Túi xách', 'packing.noBag': 'Chưa được chỉ định', 'packing.totalWeight': 'Tổng trọng lượng', + 'packing.quantity': 'SL', 'packing.bagName': 'Tên túi...', 'packing.addBag': 'Thêm túi', 'packing.changeCategory': 'Chuyển sang danh sách', diff --git a/shared/src/i18n/zh-TW/packing.ts b/shared/src/i18n/zh-TW/packing.ts index f4df5df51..8acb260af 100644 --- a/shared/src/i18n/zh-TW/packing.ts +++ b/shared/src/i18n/zh-TW/packing.ts @@ -46,6 +46,7 @@ const packing: TranslationStrings = { 'packing.bags': '行李', 'packing.noBag': '未分配', 'packing.totalWeight': '總重量', + 'packing.quantity': '數量', 'packing.bagName': '名稱...', 'packing.addBag': '新增行李', 'packing.changeCategory': '移動到清單', diff --git a/shared/src/i18n/zh/packing.ts b/shared/src/i18n/zh/packing.ts index c8169504b..bcc2237e4 100644 --- a/shared/src/i18n/zh/packing.ts +++ b/shared/src/i18n/zh/packing.ts @@ -46,6 +46,7 @@ const packing: TranslationStrings = { 'packing.bags': '行李', 'packing.noBag': '未分配', 'packing.totalWeight': '总重量', + 'packing.quantity': '数量', 'packing.bagName': '名称...', 'packing.addBag': '添加行李', 'packing.changeCategory': '移动到清单',