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 <jgunther021@gmail.com>
This commit is contained in:
Konstantinos Thermos
2026-07-18 09:59:55 +02:00
committed by GitHub
parent 20ca5b52bc
commit 9d72af5238
25 changed files with 281 additions and 73 deletions
+15 -3
View File
@@ -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')
+3 -3
View File
@@ -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) {
+215 -58
View File
@@ -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<string, any>();
// 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<any> {
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<string>();
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<string, string> | null = null;
let admin1Building: Promise<Map<string, string>> | null = null;
function getAdmin1Store(): Promise<Map<string, string>> {
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<Map<string, string>> {
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<string, string>();
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<s
// ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
function pointInRing(lng: number, lat: number, ring: number[][]): boolean {
// Ray-cast on a flat [lng,lat,lng,lat,…] ring. Same algorithm as the classic number[][]
// version, but the coordinates live in a Float64Array so the parsed admin0 geometry (with
// its millions of tiny [lng,lat] arrays, ~145MB) need not be retained — only these rings.
function pointInFlatRing(lng: number, lat: number, ring: Float64Array): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
const n = ring.length / 2;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = ring[2 * i], yi = ring[2 * i + 1];
const xj = ring[2 * j], yj = ring[2 * j + 1];
if (((yi > 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<string, Geometry> | null = null;
let countryPolyIndex: Map<string, CompactGeom> | null = null;
let countryBoxIndex: Map<string, Box[]> | null = null;
function buildCountryIndexes(): void {
const polys = new Map<string, Geometry>();
const polys = new Map<string, CompactGeom>();
const boxes = new Map<string, Box[]>();
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<string, Geometry> {
function getCountryPolyIndex(): Map<string, CompactGeom> {
if (!countryPolyIndex) buildCountryIndexes();
return countryPolyIndex!;
}
+7 -3
View File
@@ -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');
});
@@ -12,7 +12,11 @@ function makeController(svc: Partial<AtlasService>) {
}
function makeRes() {
return { setHeader: vi.fn() } as unknown as Response & { setHeader: ReturnType<typeof vi.fn> };
return { setHeader: vi.fn(), send: vi.fn(), json: vi.fn() } as unknown as Response & {
setHeader: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
};
}
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', () => {
+1
View File
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
'packing.bags': 'أمتعة',
'packing.noBag': 'غير معيّن',
'packing.totalWeight': 'الوزن الإجمالي',
'packing.quantity': 'الكمية',
'packing.bagName': 'الاسم...',
'packing.addBag': 'إضافة أمتعة',
'packing.changeCategory': 'نقل إلى قائمة',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
'packing.bags': 'Τσάντες',
'packing.noBag': 'Χωρίς ανάθεση',
'packing.totalWeight': 'Συνολικό βάρος',
'packing.quantity': 'Ποσ.',
'packing.bagName': 'Όνομα τσάντας...',
'packing.addBag': 'Προσθήκη τσάντας',
'packing.changeCategory': 'Μετακίνηση σε λίστα',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
'packing.bags': 'バッグ',
'packing.noBag': '未割り当て',
'packing.totalWeight': '総重量',
'packing.quantity': '数量',
'packing.bagName': 'バッグ名...',
'packing.addBag': 'バッグを追加',
'packing.changeCategory': 'リストへ移動',
+1
View File
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
'packing.bags': '가방',
'packing.noBag': '미배정',
'packing.totalWeight': '총 무게',
'packing.quantity': '수량',
'packing.bagName': '가방 이름...',
'packing.addBag': '가방 추가',
'packing.changeCategory': '목록으로 이동',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
'packing.bags': 'Багаж',
'packing.noBag': 'Не назначено',
'packing.totalWeight': 'Общий вес',
'packing.quantity': 'Кол-во',
'packing.bagName': 'Название...',
'packing.addBag': 'Добавить багаж',
'packing.changeCategory': 'Переместить в список',
+1
View File
@@ -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',
+1
View File
@@ -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şı',
+1
View File
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
'packing.bags': 'Багаж',
'packing.noBag': 'Не призначено',
'packing.totalWeight': 'Загальна вага',
'packing.quantity': 'К-сть',
'packing.bagName': 'Назва...',
'packing.addBag': 'Додати багаж',
'packing.changeCategory': 'Перемістити до списку',
+1
View File
@@ -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',
+1
View File
@@ -46,6 +46,7 @@ const packing: TranslationStrings = {
'packing.bags': '行李',
'packing.noBag': '未分配',
'packing.totalWeight': '總重量',
'packing.quantity': '數量',
'packing.bagName': '名稱...',
'packing.addBag': '新增行李',
'packing.changeCategory': '移動到清單',
+1
View File
@@ -46,6 +46,7 @@ const packing: TranslationStrings = {
'packing.bags': '行李',
'packing.noBag': '未分配',
'packing.totalWeight': '总重量',
'packing.quantity': '数量',
'packing.bagName': '名称...',
'packing.addBag': '添加行李',
'packing.changeCategory': '移动到清单',