From 9d72af5238a0ff2a0d026879b91bd5f8e47f6018 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Sat, 18 Jul 2026 09:59:55 +0200 Subject: [PATCH] fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585) * fix(atlas): stream boundary GeoJSON instead of caching parsed bundles * fix(atlas): scan admin1 features in one pass to avoid O(n^2) build createFeatureSplitter re-scanned each partial Feature from the start on every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made the one-time admin1 store build O(n^2). That pushed the streaming build (~4.2s) past the 15s test timeout under CI coverage/fork contention and added the same latency to the first live region request. Carry scan state (position, brace depth, string state) across chunks so each character is examined once. Output is byte-identical (3228 features, 197 countries) and peak build memory is unchanged (~192MB RSS under a hard 512MB cap); build time drops to ~1.7s. * fix(i18n): add packing.quantity to all locales The packing.quantity key existed only in en (Qty) and de (Menge), so i18n:parity:strict and the client parity test failed for the other 20 locales. Add the key everywhere; en/de are unchanged. --------- Co-authored-by: jubnl --- server/src/nest/atlas/atlas.controller.ts | 18 +- server/src/nest/atlas/atlas.service.ts | 6 +- server/src/services/atlasService.ts | 273 ++++++++++++++---- server/tests/e2e/atlas.e2e.test.ts | 10 +- .../tests/unit/nest/atlas.controller.test.ts | 27 +- shared/src/i18n/ar/packing.ts | 1 + shared/src/i18n/br/packing.ts | 1 + shared/src/i18n/cs/packing.ts | 1 + shared/src/i18n/es/packing.ts | 1 + shared/src/i18n/fr/packing.ts | 1 + shared/src/i18n/gr/packing.ts | 1 + shared/src/i18n/hu/packing.ts | 1 + shared/src/i18n/id/packing.ts | 1 + shared/src/i18n/it/packing.ts | 1 + shared/src/i18n/ja/packing.ts | 1 + shared/src/i18n/ko/packing.ts | 1 + shared/src/i18n/nl/packing.ts | 1 + shared/src/i18n/pl/packing.ts | 1 + shared/src/i18n/ru/packing.ts | 1 + shared/src/i18n/sv/packing.ts | 1 + shared/src/i18n/tr/packing.ts | 1 + shared/src/i18n/uk/packing.ts | 1 + shared/src/i18n/vi/packing.ts | 1 + shared/src/i18n/zh-TW/packing.ts | 1 + shared/src/i18n/zh/packing.ts | 1 + 25 files changed, 281 insertions(+), 73 deletions(-) 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': '移动到清单',