Release branch for 4.0.0.
This commit is contained in:
jubnl
2026-08-27 13:22:39 +02:00
committed by GitHub
parent e60427f813
commit 77dddd5e8a
3355 changed files with 403744 additions and 67215 deletions
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Scope: the **`@trek/shared`** workspace. See the repo-root `CLAUDE.md` for the monorepo picture, and `server/CLAUDE.md` / `client/CLAUDE.md` for the consumers. This package is the **single source of truth** for API contracts (Zod) and all i18n strings; both server and client import from it.
## Commands (run from `shared/`)
```bash
npm run build # tsdown → dist/ (CJS + ESM + .d.ts). REQUIRED before server/client typecheck or run
npm run build:watch # tsdown --watch --no-clean (the root `npm run dev` runs this)
npm run typecheck # tsc --noEmit
npm run lint # eslint --fix src/**/*.ts
npm run test # vitest run
npm run i18n:parity # audit locale drift, exits 0 (prints a per-locale report)
npm run i18n:parity:strict # CI gate — exits 1 on any drift
```
Single test:
```bash
npx vitest run src/sanitize/sanitize.spec.ts
npx vitest run src/i18n/i18n-parity.spec.ts
npx vitest run -t "rejects extra keys"
```
**Always rebuild after changing a schema or locale** — the consumers import the compiled `dist/`, not `src/`. If a server/client typecheck fails to see a new export, you probably forgot `npm run build` here.
## Build & exports
`tsdown.config.ts` emits **three kinds of entry**: the root barrel (`src/index.ts`), the i18n metadata barrel (`src/i18n/index.ts`), and **one entry per locale** (`src/i18n/*/index.ts`) so each locale becomes its own lazy-loadable chunk (the client dynamic-imports them). `zod` is never bundled (`deps.neverBundle`). Both CJS and ESM are emitted with `.d.ts`. The `exports` map in `package.json` exposes `@trek/shared` (contracts), `@trek/shared/i18n` (metadata/types), and `@trek/shared/i18n/<locale>` (a single locale).
## Contracts (`src/<domain>/<domain>.schema.ts`)
One folder per domain; each exports Zod schemas plus their inferred types (`export type X = z.infer<typeof xSchema>`). The root barrel `src/index.ts` re-exports every domain. Domain-agnostic primitives live in `src/common/` (`primitives.schema.ts`, `pagination.schema.ts`).
**`src/plugin-permissions.ts` is machine-generated** (by `server/scripts/gen-plugin-facts.ts` from the server's plugin protocol) — never hand-edit it; regenerate with `npm run gen:plugin-facts` from `server/`, where `check:plugin-facts` is the CI drift gate.
A route is considered "migrated" only once its contract lives here and **both** sides import the inferred types. Key convention seen across schemas (e.g. `weather/weather.schema.ts`): **schemas mirror the exact legacy wire behavior** — strings stay strings if the legacy route never coerced them, optional fields reflect partial response subsets, and **bespoke 4xx error strings are reproduced in the server controller, not derived from the schema**, so the error body stays byte-identical. Don't "tidy up" a schema to be stricter than the contract it documents.
## Direction (from the 2026 audit)
The parity rule above governs **existing** routes; it is not a license to mint new debt. For anything new:
- **The contract describes the wire, not the storage engine.** New boolean fields are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a new `z.union([z.boolean(), z.number()])`. New id fields use `idSchema`/`idParamSchema`, never `number | string` unions. A field's request and response types must agree (a write→read round trip must not change a type).
- **Use the shared primitives** in `src/common/` (`idSchema`, `nonEmptyString`, pagination, …) instead of re-declaring bare `z.number()`/`z.string()` per domain. Prefer narrowing an existing open object over adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection.
- **Add a schema, add a spec** — especially for lenient parsers fed by untrusted or LLM input.
- **This package stays lean and isomorphic**: zero imports from client/server, no `node:` APIs in `src/`, and effectively no new runtime dependencies (long-term the contract layer approaches `zod`-only; i18n is a separable concern with its own release cadence — don't couple new contract code to it).
- **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `languages.ts` is the one registry — derive everything (barrels, loaders, spec maps) from it; adding a locale should be one edit, not four.
- **`shared` is the monorepo's strict-TypeScript template** (`strict` + `noUncheckedIndexedAccess`). Hold that line: no new `any`, no new suppressions.
## i18n (`src/i18n/`)
- **`languages.ts`** — `SUPPORTED_LANGUAGES` (the `{ value, label, locale }` tuples) is the canonical language registry and the source of `SupportedLanguageCode`. Adding a language starts here.
- **`<locale>/`** — one folder per language, with one file per UI domain (`common.ts`, `admin.ts`, `budget.ts`, …) plus an `index.ts` barrel. Every file exports a flat map of **dot-namespaced string keys** typed as `TranslationStrings` (e.g. `'common.save': 'Save'`). The runtime `t(key)` only resolves these top-level keys.
- **`en/` is canonical.** Every other locale must have the **identical file set and identical top-level keys** — this is enforced by `i18n:parity:strict` (and `i18n-parity.spec.ts`) and is a CI gate (`chore: enforce i18n parity`). When you add or rename a key, update **every** locale, or parity fails.
- `types.ts` defines `TranslationStrings`; `index.ts` re-exports types + language metadata (not the strings themselves — those load per-locale).
## Sanitization (`src/sanitize/sanitize.ts`)
`isomorphic-dompurify` (same code in browser and Node, tree-shakes so the client bundle doesn't pull jsdom). Minimal inline-only allow-list today; it guards the few surfaces that interpolate user strings into markup (currently the Journey banner) and is the designated home if rich-text/Markdown ships later.
+2
View File
@@ -54,6 +54,7 @@
"build:watch": "tsdown --watch --no-clean",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
@@ -68,6 +69,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.3.0",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
+84
View File
@@ -0,0 +1,84 @@
/**
* Reads the canonical locale and reports every line that calls TREK self-hosted.
*
* Plain .mjs and outside `src/` for the same reason as i18n-parity.mjs next to
* it: shared's typecheck runs with only shared's own dependencies installed
* (`npm ci --workspace shared`), so there is no @types/node and a `node:fs`
* import inside `src/` fails to compile. The spec imports this the same way the
* parity spec imports its script.
*
* Runnable on its own — `node scripts/i18n-selfhost-wording.mjs` prints the hits
* — which is what you want when a CI failure names a line you have to find.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const EN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n', 'en');
/** Case-insensitive, and covers the hyphenated, spaced and joined spellings. */
export const FORBIDDEN = /self[-\s]?hosted/i;
/**
* The strings that keep the phrase, each with the reason it is right where it is.
*
* Keyed on the translation key rather than the file, so an exemption covers one
* sentence instead of everything that happens to live beside it. Keep the list
* short: it is the only way this rule can be worn down.
*/
export const ALLOWED = [
{
key: 'settings.about.description',
because:
'the self-description shown to somebody who set TREK up themselves, and it is ' +
'accurate for them. A centrally administered install renders ' +
'settings.about.descriptionManaged instead, so neither reader is told the ' +
'wrong thing and self-hosters lose nothing.',
},
{
key: 'system_notice.v3_thankyou.body',
because:
'a dated message from the author about the project, pinned to one release, ' +
'and shown only where the managed condition is false',
},
];
/**
* Walks the canonical locale and reports every line carrying the phrase,
* attributed to the key it belongs to.
*
* A value can span several lines, so the key is the last one seen at or above
* the hit rather than something parsed out of the hit itself.
*/
export function collectSelfhostHits(dir = EN_DIR) {
const hits = [];
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.ts')) continue;
const lines = fs.readFileSync(path.join(dir, name), 'utf8').split('\n');
let key = '';
lines.forEach((text, i) => {
const declared = /^\s*'([^']+)'\s*:/.exec(text);
if (declared) key = declared[1];
if (FORBIDDEN.test(text)) hits.push({ file: name, line: i + 1, key, text: text.trim() });
});
}
return hits;
}
/** Hits that no exemption covers — the ones that fail the build. */
export function findOffenders(dir = EN_DIR) {
const allowed = new Set(ALLOWED.map((a) => a.key));
return collectSelfhostHits(dir).filter((h) => !allowed.has(h.key));
}
// pathToFileURL rather than a `file://` template: on Windows argv[1] arrives with
// backslashes and a drive letter, so the plain comparison never matches and the
// script silently does nothing when run by hand.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const offenders = findOffenders();
for (const h of offenders) {
console.log(`${h.file}:${h.line} [${h.key}] ${h.text.slice(0, 80)}`);
}
console.log(offenders.length === 0 ? 'Wording: OK' : `Wording: ${offenders.length} offender(s)`);
process.exit(offenders.length === 0 ? 0 : 1);
}
+118 -16
View File
@@ -1,47 +1,71 @@
import {
adminUserCreateRequestSchema,
adminUserUpdateRequestSchema,
adminPermissionsRequestSchema,
adminInviteCreateRequestSchema,
adminFeatureToggleRequestSchema,
adminTemplateNameRequestSchema,
adminOidcUpdateRequestSchema,
adminAddonUpdateRequestSchema,
adminCollabFeaturesRequestSchema,
adminNotificationPreferencesRequestSchema,
adminDefaultUserSettingsRequestSchema,
adminTestNotificationRequestSchema,
} from './admin.schema';
import { describe, it, expect } from 'vitest';
describe('adminUserCreateRequestSchema', () => {
it('requires an email; role limited to user/admin', () => {
it('accepts the full body the client sends', () => {
expect(
adminUserCreateRequestSchema.safeParse({
username: 'u',
email: 'a@b.c',
password: 'p',
role: 'admin',
}).success,
).toBe(true);
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
expect(adminUserCreateRequestSchema.safeParse({ password: 'p' }).success).toBe(false);
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(false);
});
it('leaves the missing-field and invalid-role 400s to the service', () => {
// All fields optional: the service answers 'Username, email and password are required'.
expect(adminUserCreateRequestSchema.safeParse({}).success).toBe(true);
// role is z.string(), not an enum: the service answers 'Invalid role'.
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(true);
});
it('rejects wrongly-typed fields and strips unknown keys', () => {
expect(adminUserCreateRequestSchema.safeParse({ email: 42 }).success).toBe(false);
expect(adminUserCreateRequestSchema.parse({ email: 'a@b.c', extra: 1 })).toEqual({ email: 'a@b.c' });
});
});
describe('adminUserUpdateRequestSchema', () => {
it('accepts partial updates, including an empty body', () => {
expect(adminUserUpdateRequestSchema.safeParse({ role: 'user' }).success).toBe(true);
expect(adminUserUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(adminUserUpdateRequestSchema.safeParse({ username: 5 }).success).toBe(false);
});
});
describe('adminPermissionsRequestSchema', () => {
it('requires a permissions record', () => {
expect(
adminPermissionsRequestSchema.safeParse({
permissions: { trip_edit: { user: true } },
}).success,
).toBe(true);
expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: 'admin' } }).success).toBe(true);
expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false);
expect(adminPermissionsRequestSchema.safeParse({ permissions: null }).success).toBe(false);
});
it('keeps values unknown so bad levels land in the service `skipped` list, not a 400', () => {
expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: 'nonsense' } }).success).toBe(true);
});
});
describe('adminInviteCreateRequestSchema', () => {
it('accepts optional uses/expiry/role', () => {
expect(
adminInviteCreateRequestSchema.safeParse({
max_uses: 5,
expires_in_days: 7,
}).success,
).toBe(true);
it('accepts optional uses/expiry/role and both number and string ids', () => {
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: 5, expires_in_days: 7 }).success).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: '5', trip_id: '3' }).success).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({ trip_id: null }).success).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
});
});
@@ -50,5 +74,83 @@ describe('adminFeatureToggleRequestSchema', () => {
it('requires a boolean enabled', () => {
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
expect(adminFeatureToggleRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('adminTemplateNameRequestSchema', () => {
it('keeps name optional so the service owns the "required" 400, and strips extras', () => {
expect(adminTemplateNameRequestSchema.safeParse({ name: 'Beach' }).success).toBe(true);
expect(adminTemplateNameRequestSchema.safeParse({}).success).toBe(true);
expect(adminTemplateNameRequestSchema.parse({ name: 'Beach', description: 'x' })).toEqual({ name: 'Beach' });
expect(adminTemplateNameRequestSchema.safeParse({ name: 7 }).success).toBe(false);
});
});
describe('adminOidcUpdateRequestSchema', () => {
it('accepts the client body and strips the extra oidc_only key', () => {
const body = { issuer: 'https://i', client_id: 'c', display_name: 'd', discovery_url: 'u' };
expect(adminOidcUpdateRequestSchema.safeParse(body).success).toBe(true);
expect(adminOidcUpdateRequestSchema.parse({ ...body, oidc_only: false })).toEqual(body);
// Empty strings must survive — they are the "clear SSO config" signal.
expect(adminOidcUpdateRequestSchema.parse({ issuer: '', client_id: '' })).toEqual({ issuer: '', client_id: '' });
});
});
describe('adminAddonUpdateRequestSchema', () => {
it('accepts either shape and keeps config free-form', () => {
expect(adminAddonUpdateRequestSchema.safeParse({ enabled: true }).success).toBe(true);
const config = { provider: 'openai', model: 'm', apiKey: '••••', multimodal: true, nested: { a: 1 } };
expect(adminAddonUpdateRequestSchema.parse({ config })).toEqual({ config });
expect(adminAddonUpdateRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
});
});
describe('adminCollabFeaturesRequestSchema', () => {
it('accepts a single computed key', () => {
expect(adminCollabFeaturesRequestSchema.safeParse({ chat: false }).success).toBe(true);
expect(adminCollabFeaturesRequestSchema.safeParse({}).success).toBe(true);
expect(adminCollabFeaturesRequestSchema.safeParse({ polls: 'no' }).success).toBe(false);
});
});
describe('adminNotificationPreferencesRequestSchema', () => {
it('accepts arbitrary event types and channel ids, including plugin channels', () => {
expect(
adminNotificationPreferencesRequestSchema.safeParse({
trip_reminder: { email: true, 'plugin:acme': false },
}).success,
).toBe(true);
expect(adminNotificationPreferencesRequestSchema.safeParse({}).success).toBe(true);
expect(adminNotificationPreferencesRequestSchema.safeParse({ x: { email: 'yes' } }).success).toBe(false);
});
});
describe('adminDefaultUserSettingsRequestSchema', () => {
it('keeps null as the reset sentinel and rejects non-objects', () => {
expect(adminDefaultUserSettingsRequestSchema.parse({ theme: 'dark', density: null, compact: true })).toEqual({
theme: 'dark',
density: null,
compact: true,
});
expect(adminDefaultUserSettingsRequestSchema.safeParse([]).success).toBe(false);
expect(adminDefaultUserSettingsRequestSchema.safeParse(null).success).toBe(false);
});
});
describe('adminTestNotificationRequestSchema', () => {
it('accepts an empty body and both inApp shapes', () => {
expect(adminTestNotificationRequestSchema.safeParse({}).success).toBe(true);
expect(adminTestNotificationRequestSchema.safeParse({ event: 'trip_reminder', inApp: true }).success).toBe(true);
expect(
adminTestNotificationRequestSchema.safeParse({
event: 'x',
scope: 'admin',
targetId: 1,
params: { a: 'b' },
inApp: { type: 'boolean', positiveCallback: { action: 'test_approve', payload: {} } },
}).success,
).toBe(true);
expect(adminTestNotificationRequestSchema.safeParse({ targetId: 'one' }).success).toBe(false);
});
});
+85 -10
View File
@@ -3,31 +3,49 @@ import { z } from 'zod';
/**
* Admin API contract for /api/admin (admin-only).
*
* The admin service validates most bodies itself (returning {error,status}), so
* these schemas pin the well-defined ones: user create/update, the permission
* matrix, invites and the boolean feature toggles. Free-form bodies (OIDC
* settings, addon config, default user settings) stay with the service.
* These schemas exist to satisfy the body-contract ratchet without changing
* behavior, so they are deliberately permissive: wherever AdminService already
* emits a bespoke 400 of its own ('Invalid role', 'Name is required',
* 'Username, email and password are required'), the field stays optional and
* loosely typed so that error survives byte-identically. Every object schema is
* non-strict — several clients and tests send extra keys and expect success.
*/
export const adminUserCreateRequestSchema = z.object({
email: z.string(),
password: z.string().optional(),
username: z.string().optional(),
role: z.enum(['user', 'admin']).optional(),
email: z.string().optional(),
password: z.string().optional(),
// Deliberately z.string(), not z.enum: the service's own 'Invalid role' 400
// is the pinned contract for a bad role.
role: z.string().optional(),
});
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
export const adminUserUpdateRequestSchema = z.object({
username: z.string().optional(),
email: z.string().optional(),
password: z.string().optional(),
role: z.string().optional(),
});
export type AdminUserUpdateRequest = z.infer<typeof adminUserUpdateRequestSchema>;
// Values stay z.unknown(): PermissionsService.savePermissions reports unknown
// levels through its `skipped` list (a 200 response field the admin UI shows),
// so narrowing to z.string() would turn that into a 400.
export const adminPermissionsRequestSchema = z.object({
permissions: z.record(z.string(), z.unknown()),
});
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
export const adminInviteCreateRequestSchema = z.object({
max_uses: z.number().optional(),
expires_in_days: z.number().optional(),
// string | number: the service parses with parseInt(String(x)), and the
// admin UI sends numbers while older clients sent strings.
max_uses: z.union([z.number(), z.string()]).optional(),
expires_in_days: z.union([z.number(), z.string()]).optional(),
role: z.enum(['user', 'admin']).optional(),
// Optional trip binding (#1402): a user who registers via the link is
// auto-added to this trip. Nullable/absent = a plain registration invite.
trip_id: z.number().int().positive().nullable().optional(),
trip_id: z.union([z.number(), z.string()]).nullable().optional(),
});
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
@@ -35,3 +53,60 @@ export const adminFeatureToggleRequestSchema = z.object({
enabled: z.boolean(),
});
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
// Shared by all six packing-template create/update routes. `name` is optional so
// the service's 'Name is required' / 'Category name is required' / 'Item name is
// required' 400s stay the contract, and so the update routes keep treating a
// blank name as a no-op.
export const adminTemplateNameRequestSchema = z.object({
name: z.string().optional(),
});
export type AdminTemplateNameRequest = z.infer<typeof adminTemplateNameRequestSchema>;
export const adminOidcUpdateRequestSchema = z.object({
issuer: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
display_name: z.string().optional(),
discovery_url: z.string().optional(),
});
export type AdminOidcUpdateRequest = z.infer<typeof adminOidcUpdateRequestSchema>;
// `config` must stay free-form: each addon stores its own keys, and the AI addon
// round-trips an apiKey mask sentinel that a narrower shape would strip.
export const adminAddonUpdateRequestSchema = z.object({
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export type AdminAddonUpdateRequest = z.infer<typeof adminAddonUpdateRequestSchema>;
// Fully partial — the client PUTs one computed key at a time.
export const adminCollabFeaturesRequestSchema = z.object({
chat: z.boolean().optional(),
notes: z.boolean().optional(),
polls: z.boolean().optional(),
whatsnext: z.boolean().optional(),
});
export type AdminCollabFeaturesRequest = z.infer<typeof adminCollabFeaturesRequestSchema>;
// event type -> channel id -> enabled. Anything narrower would silently drop
// plugin-contributed channels, since the client echoes back the whole matrix.
export const adminNotificationPreferencesRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean()));
export type AdminNotificationPreferencesRequest = z.infer<typeof adminNotificationPreferencesRequestSchema>;
// Heterogeneous values, and `null` is meaningful — SettingsService treats it as
// "reset to the built-in default". z.record also rejects arrays and null bodies,
// preserving the route's object-only guard.
export const adminDefaultUserSettingsRequestSchema = z.record(z.string(), z.unknown());
export type AdminDefaultUserSettingsRequest = z.infer<typeof adminDefaultUserSettingsRequestSchema>;
// Dev-only test sender: every field optional (callers POST {}), and `inApp` is a
// boolean OR the in-app action descriptor the dev panel sends.
export const adminTestNotificationRequestSchema = z.object({
event: z.string().optional(),
scope: z.string().optional(),
targetId: z.number().optional(),
params: z.record(z.string(), z.unknown()).optional(),
inApp: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(),
});
export type AdminTestNotificationRequest = z.infer<typeof adminTestNotificationRequestSchema>;
@@ -79,4 +79,43 @@ describe('normalizeAppearance', () => {
const out = normalizeAppearance({ dashboard: 'nope' });
expect(out.dashboard).toEqual(DEFAULT_APPEARANCE.dashboard);
});
it('defaults a legacy blob without mobileNav to empty bar/more', () => {
const out = normalizeAppearance({ schemeId: 'teal' });
expect(out.mobileNav).toEqual({ bar: [], more: [] });
});
it('round-trips a valid mobileNav split in order', () => {
const out = normalizeAppearance({ mobileNav: { bar: ['vacay', 'atlas'], more: ['journey', 'plugin:foo'] } });
expect(out.mobileNav).toEqual({ bar: ['vacay', 'atlas'], more: ['journey', 'plugin:foo'] });
});
it('collapses a bogus mobileNav to the default without throwing', () => {
expect(normalizeAppearance({ mobileNav: 'nope' }).mobileNav).toEqual({ bar: [], more: [] });
expect(normalizeAppearance({ mobileNav: { bar: 42 } }).mobileNav).toEqual({ bar: [], more: [] });
});
it('strips the pinned dashboard id, de-dupes, and de-overlaps bar/more', () => {
const out = normalizeAppearance({
mobileNav: { bar: ['dashboard', 'vacay', 'vacay', 'atlas'], more: ['atlas', 'journey', 'journey'] },
});
// 'dashboard' dropped + 'vacay' de-duped in bar
expect(out.mobileNav.bar).toEqual(['vacay', 'atlas']);
// 'atlas' already in bar → removed from more; 'journey' de-duped
expect(out.mobileNav.more).toEqual(['journey']);
});
it('defaults a legacy blob without mobileOrder to an empty order', () => {
expect(normalizeAppearance({ schemeId: 'teal' }).dashboard.mobileOrder).toEqual([]);
});
it('round-trips a valid mobile dashboard order', () => {
const out = normalizeAppearance({ dashboard: { mobileOrder: ['currency', 'trips', 'timezones'] } });
expect(out.dashboard.mobileOrder).toEqual(['currency', 'trips', 'timezones']);
});
it('drops an unknown token / bogus mobileOrder without throwing', () => {
expect(normalizeAppearance({ dashboard: { mobileOrder: ['currency', 'nope'] } }).dashboard.mobileOrder).toEqual([]);
expect(normalizeAppearance({ dashboard: { mobileOrder: 'x' } }).dashboard.mobileOrder).toEqual([]);
});
});
@@ -82,9 +82,16 @@ const DEFAULT_DASHBOARD_MOBILE = {
timezones: true,
upcomingReservations: true,
};
// Orderable blocks of the MOBILE dashboard: the trip list + the inline widgets.
// The featured (spotlight) trip is not orderable — it always stays on top.
export const MOBILE_DASH_TOKENS = ['trips', 'currency', 'collections', 'timezones', 'upcomingReservations'] as const;
export type MobileDashToken = (typeof MOBILE_DASH_TOKENS)[number];
export const DEFAULT_DASHBOARD_WIDGETS = {
desktop: DEFAULT_DASHBOARD_DESKTOP,
mobile: DEFAULT_DASHBOARD_MOBILE,
// Ordered mobile blocks; empty = the built-in order (trips first, then widgets).
mobileOrder: [] as MobileDashToken[],
};
const dashboardWidgetsSchema = z
@@ -114,10 +121,34 @@ const dashboardWidgetsSchema = z
})
.catch(DEFAULT_DASHBOARD_MOBILE)
.default(DEFAULT_DASHBOARD_MOBILE),
// Per-user order of the mobile dashboard blocks. Empty = built-in order;
// unknown/duplicate tokens are reconciled by the renderer at display time.
mobileOrder: z.array(z.enum(MOBILE_DASH_TOKENS)).catch([]).default([]),
})
.catch(DEFAULT_DASHBOARD_WIDGETS)
.default(DEFAULT_DASHBOARD_WIDGETS);
// ── Mobile bottom-nav layout ────────────────────────────────────────────────
// The user's per-account arrangement of the mobile bottom navbar. `bar` = the
// ids shown directly in the dock (left→right); `more` = the ids demoted under
// the "More" overflow popover. Both are ORDERED. Ids are addon keys
// (vacay|atlas|journey|collections|future global addons) or page-plugin ids
// ("plugin:<id>"). 'dashboard' is PINNED first/immovable and is NEVER stored
// here — it is derived at render time. Two empty arrays = "not customised yet",
// so the navbar falls back to its built-in automatic split (non-breaking).
const navItemId = z.string().max(64);
const navItemList = z.array(navItemId).catch([]).default([]);
export const DEFAULT_MOBILE_NAV = { bar: [] as string[], more: [] as string[] };
const mobileNavSchema = z
.object({
bar: navItemList,
more: navItemList,
})
.catch(DEFAULT_MOBILE_NAV)
.default(DEFAULT_MOBILE_NAV);
export const appearanceConfigSchema = z.object({
version: z.literal(1).catch(1).default(1),
schemeId: z.enum(APPEARANCE_SCHEME_IDS).catch('default').default('default'),
@@ -132,6 +163,8 @@ export const appearanceConfigSchema = z.object({
reduceMotion: z.boolean().catch(false).default(false),
// Per-device dashboard widget visibility (not a DOM token — read by the Dashboard).
dashboard: dashboardWidgetsSchema,
// Per-user mobile bottom-nav split/order (not a DOM token — read by MBottomNav).
mobileNav: mobileNavSchema,
});
export type AppearanceConfig = z.infer<typeof appearanceConfigSchema>;
@@ -147,6 +180,7 @@ export const DEFAULT_APPEARANCE: AppearanceConfig = {
density: 'comfortable',
reduceMotion: false,
dashboard: DEFAULT_DASHBOARD_WIDGETS,
mobileNav: DEFAULT_MOBILE_NAV,
};
/**
@@ -159,6 +193,13 @@ export function normalizeAppearance(raw: unknown): AppearanceConfig {
const input = raw && typeof raw === 'object' ? raw : {};
const parsed = appearanceConfigSchema.safeParse(input);
const cfg = parsed.success ? parsed.data : { ...DEFAULT_APPEARANCE };
// Keep the mobile-nav lists purely structural: drop the pinned 'dashboard'
// id, de-duplicate, and ensure an id can't sit in both bar and more. Whether
// an id still maps to an enabled addon/plugin is reconciled at render time.
const dedupe = (xs: string[]): string[] => [...new Set(xs)].filter((id) => id !== 'dashboard');
const bar = dedupe(cfg.mobileNav.bar);
const barSet = new Set(bar);
const more = dedupe(cfg.mobileNav.more).filter((id) => !barSet.has(id));
return {
...cfg,
fontScale: clampScale(cfg.fontScale),
@@ -168,5 +209,6 @@ export function normalizeAppearance(raw: unknown): AppearanceConfig {
body: clampScale(cfg.typeScale.body),
caption: clampScale(cfg.typeScale.caption),
},
mobileNav: { bar, more },
};
}
@@ -2,6 +2,7 @@ import {
assignmentCreateRequestSchema,
assignmentMoveRequestSchema,
assignmentParticipantsRequestSchema,
assignmentTransportRequestSchema,
} from './assignment.schema';
import { describe, it, expect } from 'vitest';
@@ -15,13 +16,24 @@ describe('assignmentCreateRequestSchema', () => {
});
describe('assignmentMoveRequestSchema', () => {
it('requires new_day_id; order_index optional', () => {
it('requires new_day_id; order_index optional/nullable', () => {
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true);
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true);
// The client api sends `order_index: null` when no insert position is given.
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: null }).success).toBe(true);
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('assignmentTransportRequestSchema', () => {
it('accepts a mode, an explicit null, and an absent key (legacy `?? null`)', () => {
expect(assignmentTransportRequestSchema.safeParse({ transport_mode: 'cycling' }).success).toBe(true);
expect(assignmentTransportRequestSchema.safeParse({ transport_mode: null }).success).toBe(true);
expect(assignmentTransportRequestSchema.safeParse({}).success).toBe(true);
expect(assignmentTransportRequestSchema.safeParse({ transport_mode: 5 }).success).toBe(false);
});
});
describe('assignmentParticipantsRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
+26 -5
View File
@@ -7,10 +7,11 @@ import { z } from 'zod';
* endpoints under /api/trips/:tripId/days/:dayId/assignments and
* /api/trips/:tripId/assignments/:id/*.
*
* Trip-scoped; mutations use the 'day_edit' permission. The legacy route
* (server/src/routes/assignments.ts, mounted on /api) wraps assignmentService.
* Assignment rows carry joined place data and are kept open in responses; the
* request schemas + the bespoke 404/400 controller messages pin the rest.
* Trip-scoped; mutations use the 'day_edit' permission. The server side is the
* DI-native AssignmentsService (server/src/nest/assignments/), validated via
* the createZodDto wrappers in assignments.dto.ts. Assignment rows carry
* joined place data and are kept open in responses; the request schemas + the
* bespoke 404 controller messages pin the rest.
*/
/**
@@ -39,6 +40,13 @@ export const assignmentSchema = z.object({
notes: z.string().nullable().optional(),
assignment_time: z.string().nullable().optional(),
assignment_end_time: z.string().nullable().optional(),
// Per-segment travel mode (#1281): the transport mode of the leg LEAVING this
// stop for the next one. null = inherit the day's default_transport_mode.
leg_transport_mode: z.string().nullable().optional(),
// Per-segment travel mode of the leg ENTERING this stop when its origin is
// not a place (booking arrival / morning hotel). null = inherit the day default.
// Inert when the previous timeline element is a place.
incoming_leg_transport_mode: z.string().nullable().optional(),
participants: z.array(assignmentParticipantSchema).optional(),
created_at: z.string().optional(),
place: assignmentPlaceSchema,
@@ -58,7 +66,9 @@ export type AssignmentReorderRequest = z.infer<typeof assignmentReorderRequestSc
export const assignmentMoveRequestSchema = z.object({
new_day_id: z.union([z.number(), z.string()]),
order_index: z.number().optional(),
// The client api types this `number | null` (assignmentsApi.move) and the
// legacy route accepted null (`orderIndex || 0`) — keep null on the wire.
order_index: z.number().nullable().optional(),
});
export type AssignmentMoveRequest = z.infer<typeof assignmentMoveRequestSchema>;
@@ -68,6 +78,17 @@ export const assignmentTimeRequestSchema = z.object({
});
export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
/** Set the leg's travel mode (a RouteProfileKey, or null to inherit the day default). */
export const assignmentTransportRequestSchema = z.object({
// The legacy route read `body.transport_mode ?? null`, so an absent key
// behaves like an explicit null — keep it optional on the wire.
transport_mode: z.string().nullable().optional(),
// Which leg this write targets: the one leaving this stop (default, and the
// legacy behaviour) or the one arriving at it (#1281 boundary legs).
direction: z.enum(['outgoing', 'incoming']).default('outgoing'),
});
export type AssignmentTransportRequest = z.infer<typeof assignmentTransportRequestSchema>;
export const assignmentParticipantsRequestSchema = z.object({
user_ids: z.array(z.number()),
});
+133 -2
View File
@@ -1,6 +1,29 @@
import { markRegionRequestSchema, createBucketItemRequestSchema, regionGeoSchema } from './atlas.schema';
import {
markRegionRequestSchema,
createBucketItemRequestSchema,
regionGeoSchema,
visitStatusSchema,
todayUtc,
tripVisitStatus,
VISIT_STATUS_RANK,
strongerVisitStatus,
} from './atlas.schema';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
/**
* The date helpers below read the wall clock, so every test that exercises the
* default `today` pins it first — otherwise the suite flips its answers when it
* happens to run across midnight UTC.
*/
function freezeClock(iso: string): void {
vi.useFakeTimers();
vi.setSystemTime(new Date(iso));
}
afterEach(() => {
vi.useRealTimers();
});
describe('markRegionRequestSchema', () => {
it('requires both name and country_code', () => {
@@ -36,3 +59,111 @@ describe('regionGeoSchema', () => {
expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false);
});
});
describe('visitStatusSchema', () => {
it('accepts the three buckets and nothing else', () => {
expect(visitStatusSchema.safeParse('visited').success).toBe(true);
expect(visitStatusSchema.safeParse('planned').success).toBe(true);
expect(visitStatusSchema.safeParse('idea').success).toBe(true);
expect(visitStatusSchema.safeParse('wishlist').success).toBe(false);
expect(visitStatusSchema.safeParse('').success).toBe(false);
});
});
describe('tripVisitStatus', () => {
// Passed explicitly wherever the clock is not the thing under test.
const TODAY = '2026-08-03';
it('counts a trip that already ended as visited', () => {
expect(tripVisitStatus('2026-01-10', '2026-01-20', TODAY)).toBe('visited');
});
it('counts a running trip as visited — you are there right now', () => {
expect(tripVisitStatus('2026-08-01', '2026-08-10', TODAY)).toBe('visited');
});
it('counts a trip that has not started yet as planned', () => {
expect(tripVisitStatus('2026-09-01', '2026-09-10', TODAY)).toBe('planned');
});
it('flips to visited on the start day itself', () => {
expect(tripVisitStatus(TODAY, '2026-08-09', TODAY)).toBe('visited');
expect(tripVisitStatus(TODAY, TODAY, TODAY)).toBe('visited');
// ...and the very next day is still only planned
expect(tripVisitStatus('2026-08-04', '2026-08-09', TODAY)).toBe('planned');
});
it('works off start_date alone', () => {
expect(tripVisitStatus('2026-01-10', null, TODAY)).toBe('visited');
expect(tripVisitStatus('2026-12-01', null, TODAY)).toBe('planned');
});
it('falls back to end_date as the effective start', () => {
expect(tripVisitStatus(null, '2026-01-20', TODAY)).toBe('visited');
expect(tripVisitStatus(null, '2026-12-20', TODAY)).toBe('planned');
});
it('treats a trip without any date as an idea, not a plan', () => {
expect(tripVisitStatus(null, null, TODAY)).toBe('idea');
});
it('treats null, undefined and empty strings alike', () => {
expect(tripVisitStatus(undefined, undefined, TODAY)).toBe('idea');
expect(tripVisitStatus(null, undefined, TODAY)).toBe('idea');
expect(tripVisitStatus(undefined, null, TODAY)).toBe('idea');
expect(tripVisitStatus('', '', TODAY)).toBe('idea');
expect(tripVisitStatus(undefined, '2026-01-20', TODAY)).toBe(tripVisitStatus(null, '2026-01-20', TODAY));
expect(tripVisitStatus('', '2026-01-20', TODAY)).toBe('visited');
});
it('defaults to the UTC clock, right up to the last second of the day', () => {
freezeClock('2026-08-03T23:59:59.999Z');
expect(tripVisitStatus('2026-08-03', null)).toBe('visited');
expect(tripVisitStatus('2026-08-04', null)).toBe('planned');
});
});
describe('strongerVisitStatus', () => {
it('ranks visited above planned above idea', () => {
expect(VISIT_STATUS_RANK.visited).toBeLessThan(VISIT_STATUS_RANK.planned);
expect(VISIT_STATUS_RANK.planned).toBeLessThan(VISIT_STATUS_RANK.idea);
});
it('picks the stronger status regardless of argument order', () => {
expect(strongerVisitStatus('visited', 'planned')).toBe('visited');
expect(strongerVisitStatus('planned', 'visited')).toBe('visited');
expect(strongerVisitStatus('visited', 'idea')).toBe('visited');
expect(strongerVisitStatus('idea', 'visited')).toBe('visited');
expect(strongerVisitStatus('planned', 'idea')).toBe('planned');
expect(strongerVisitStatus('idea', 'planned')).toBe('planned');
});
it('returns the shared status on a tie', () => {
expect(strongerVisitStatus('visited', 'visited')).toBe('visited');
expect(strongerVisitStatus('planned', 'planned')).toBe('planned');
expect(strongerVisitStatus('idea', 'idea')).toBe('idea');
});
});
describe('todayUtc', () => {
it('formats as YYYY-MM-DD', () => {
freezeClock('2026-08-03T12:00:00.000Z');
expect(todayUtc()).toBe('2026-08-03');
expect(todayUtc()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('zero-pads single-digit months and days', () => {
freezeClock('2026-01-05T08:30:00.000Z');
expect(todayUtc()).toBe('2026-01-05');
});
it('does not tip into the next day late in the evening UTC', () => {
freezeClock('2026-08-03T23:59:59.999Z');
expect(todayUtc()).toBe('2026-08-03');
});
it('rolls over at midnight UTC', () => {
freezeClock('2026-08-04T00:00:00.000Z');
expect(todayUtc()).toBe('2026-08-04');
});
});
+57
View File
@@ -56,6 +56,63 @@ export const regionGeoSchema = z.object({
});
export type RegionGeo = z.infer<typeof regionGeoSchema>;
/**
* Where a coordinate lands on the atlas map (#1115). Both fields are nullable: the
* point may sit outside every bundled polygon (open sea, a simplification gap at a
* coastline), and plenty of countries have no admin1 coverage in the bundle at all,
* in which case the country resolves and the region does not.
*/
export const atlasLocateResponseSchema = z.object({
country_code: z.string().nullable(),
region_code: z.string().nullable(),
region_name: z.string().nullable(),
});
export type AtlasLocateResponse = z.infer<typeof atlasLocateResponseSchema>;
/**
* How a country/region ended up on the atlas map. Before this, every trip counted as a
* visit, so a booked-but-not-taken trip painted the map as if you had already been there.
* Requested in discussion #1048 — note that's a discussion, not an issue:
* https://github.com/liketrek/TREK/discussions/1048
*
* - visited: at least one source is a trip that has already started, or a manual mark
* - planned: every source is a trip starting in the future
* - idea: every source is a trip with no dates at all
*/
export const visitStatusSchema = z.enum(['visited', 'planned', 'idea']);
export type VisitStatus = z.infer<typeof visitStatusSchema>;
/** Today as 'YYYY-MM-DD' in UTC — the comparison base the rest of TREK uses. */
export function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Bucket a trip by its dates. A trip that has started but not ended still counts as
* visited — you are there right now. A trip with neither date is an idea, not a plan,
* and must stay out of the visit stats.
*/
export function tripVisitStatus(
startDate: string | null | undefined,
endDate: string | null | undefined,
today: string = todayUtc(),
): VisitStatus {
const effectiveStart = startDate || endDate || null;
if (!effectiveStart) return 'idea';
return effectiveStart <= today ? 'visited' : 'planned';
}
/** visited beats planned beats idea — a country takes its strongest source. */
export const VISIT_STATUS_RANK: Record<VisitStatus, number> = {
visited: 0,
planned: 1,
idea: 2,
};
export function strongerVisitStatus(a: VisitStatus, b: VisitStatus): VisitStatus {
return VISIT_STATUS_RANK[a] <= VISIT_STATUS_RANK[b] ? a : b;
}
/**
* ISO 3166-1 alpha-2 country code → continent. Single source of truth for the
* Atlas continent breakdown, used by the server (stats aggregation) and the
+75
View File
@@ -6,7 +6,18 @@ import {
changePasswordRequestSchema,
mfaVerifyLoginRequestSchema,
mfaEnableRequestSchema,
mfaDisableRequestSchema,
mcpTokenCreateRequestSchema,
mapsKeyUpdateRequestSchema,
apiKeysUpdateRequestSchema,
settingsUpdateRequestSchema,
appSettingsUpdateRequestSchema,
resourceTokenRequestSchema,
passkeyRegisterOptionsRequestSchema,
passkeyRegisterVerifyRequestSchema,
passkeyLoginVerifyRequestSchema,
passkeyRenameRequestSchema,
passkeyDeleteRequestSchema,
} from './auth.schema';
import { describe, it, expect } from 'vitest';
@@ -59,7 +70,71 @@ describe('mfa + mcp-token schemas', () => {
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' }).success).toBe(true);
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success).toBe(false);
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(true);
expect(mfaDisableRequestSchema.safeParse({ password: 'pw', code: '123456' }).success).toBe(true);
expect(mfaDisableRequestSchema.safeParse({ code: '123456' }).success).toBe(false);
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(true);
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
});
});
describe('api-key / settings schemas', () => {
it('mapsKey accepts a string, an explicit null (clear) and omission', () => {
expect(mapsKeyUpdateRequestSchema.safeParse({ maps_api_key: 'k' }).success).toBe(true);
expect(mapsKeyUpdateRequestSchema.safeParse({ maps_api_key: null }).success).toBe(true);
expect(mapsKeyUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(mapsKeyUpdateRequestSchema.safeParse({ maps_api_key: 7 }).success).toBe(false);
});
it('apiKeys is a per-key partial with nullable values', () => {
expect(apiKeysUpdateRequestSchema.safeParse({ unsplash_api_key: null }).success).toBe(true);
expect(apiKeysUpdateRequestSchema.safeParse({ maps_api_key: 'k', openweather_api_key: 'w' }).success).toBe(true);
expect(apiKeysUpdateRequestSchema.safeParse({}).success).toBe(true);
});
it('settings accepts profile + key fields, all optional', () => {
expect(settingsUpdateRequestSchema.safeParse({ username: 'alice' }).success).toBe(true);
expect(settingsUpdateRequestSchema.safeParse({ email: 'a@b.c', maps_api_key: null }).success).toBe(true);
expect(settingsUpdateRequestSchema.safeParse({ username: 42 }).success).toBe(false);
});
it('appSettings is an open string-keyed map (the server owns the key allow-list)', () => {
expect(appSettingsUpdateRequestSchema.safeParse({ require_mfa: true, smtp_host: 'mail' }).success).toBe(true);
expect(appSettingsUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(appSettingsUpdateRequestSchema.safeParse('nope').success).toBe(false);
});
it('resourceToken keeps purpose an optional string (the 400 is a service rule)', () => {
expect(resourceTokenRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
expect(resourceTokenRequestSchema.safeParse({}).success).toBe(true);
expect(resourceTokenRequestSchema.safeParse({ purpose: 5 }).success).toBe(false);
});
});
describe('passkey schemas', () => {
it('registerOptions keeps password optional (the 401 is a service rule)', () => {
expect(passkeyRegisterOptionsRequestSchema.safeParse({ password: 'pw' }).success).toBe(true);
expect(passkeyRegisterOptionsRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyRegisterOptionsRequestSchema.safeParse({ password: 5 }).success).toBe(false);
});
it('ceremony payloads stay permissive — the WebAuthn verifier owns validation', () => {
expect(
passkeyRegisterVerifyRequestSchema.safeParse({ attestationResponse: { id: 'x' }, name: 'Key' }).success,
).toBe(true);
expect(passkeyRegisterVerifyRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyLoginVerifyRequestSchema.safeParse({ assertionResponse: { id: 'x' } }).success).toBe(true);
expect(passkeyLoginVerifyRequestSchema.safeParse({}).success).toBe(true);
});
it("rename keeps name unknown (the 'Name is required' 400 is a service rule)", () => {
expect(passkeyRenameRequestSchema.safeParse({ name: 'My Key' }).success).toBe(true);
expect(passkeyRenameRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyRenameRequestSchema.safeParse({ name: 42 }).success).toBe(true); // service-side sanitize owns the 400
});
it('delete rejects a non-string password (used to 500 in bcrypt) but keeps omission a service rule', () => {
expect(passkeyDeleteRequestSchema.safeParse({ password: 'pw' }).success).toBe(true);
expect(passkeyDeleteRequestSchema.safeParse({}).success).toBe(true);
expect(passkeyDeleteRequestSchema.safeParse({ password: 123 }).success).toBe(false);
});
});
+83
View File
@@ -64,3 +64,86 @@ export const mcpTokenCreateRequestSchema = z.object({
name: z.string().optional(),
});
export type McpTokenCreateRequest = z.infer<typeof mcpTokenCreateRequestSchema>;
// The client sends an explicit `null` to clear the stored key; optional keeps
// the legacy accept-anything omission path (omitting also clears — service rule).
export const mapsKeyUpdateRequestSchema = z.object({
maps_api_key: z.string().nullable().optional(),
});
export type MapsKeyUpdateRequest = z.infer<typeof mapsKeyUpdateRequestSchema>;
// Per-key partial update: an absent key keeps the stored value, `null` clears it.
export const apiKeysUpdateRequestSchema = z.object({
maps_api_key: z.string().nullable().optional(),
openweather_api_key: z.string().nullable().optional(),
unsplash_api_key: z.string().nullable().optional(),
});
export type ApiKeysUpdateRequest = z.infer<typeof apiKeysUpdateRequestSchema>;
export const settingsUpdateRequestSchema = z.object({
maps_api_key: z.string().nullable().optional(),
openweather_api_key: z.string().nullable().optional(),
unsplash_api_key: z.string().nullable().optional(),
username: z.string().optional(),
email: z.string().optional(),
});
export type SettingsUpdateRequest = z.infer<typeof settingsUpdateRequestSchema>;
// Deliberately an open map: the accepted keys are the server's ADMIN_SETTINGS_KEYS
// allow-list (unknown keys are ignored there), and values arrive as strings or
// booleans (`require_mfa` toggles) — the service owns the per-key coercion and
// the lockout/self-MFA guards with their bespoke 400 strings.
export const appSettingsUpdateRequestSchema = z.record(z.string(), z.unknown());
export type AppSettingsUpdateRequest = z.infer<typeof appSettingsUpdateRequestSchema>;
export const mfaDisableRequestSchema = z.object({
password: z.string(),
code: z.string(),
});
export type MfaDisableRequest = z.infer<typeof mfaDisableRequestSchema>;
// `purpose` must be the literal 'download' today, but the 400 'Invalid purpose'
// answer is a service rule (kept bespoke), so the wire type stays a plain string.
export const resourceTokenRequestSchema = z.object({
purpose: z.string().optional(),
});
export type ResourceTokenRequest = z.infer<typeof resourceTokenRequestSchema>;
// ── Passkeys (/api/auth/passkey) ─────────────────────────────────────────────
// The WebAuthn ceremony payloads are raw @simplewebauthn/browser outputs whose
// real validation is the server-side ceremony verifier, and the bespoke 4xx
// strings ('Incorrect password' 401, 'Invalid registration response',
// 'Name is required', the uniform 'Authentication failed' 401) are service
// rules — so every field stays permissive and the service owns the semantics.
// Password re-auth: a missing password must reach the service (401), not 400.
export const passkeyRegisterOptionsRequestSchema = z.object({
password: z.string().optional(),
});
export type PasskeyRegisterOptionsRequest = z.infer<typeof passkeyRegisterOptionsRequestSchema>;
// `name` is omitted entirely when the user leaves it blank; the service
// sanitizes non-strings itself.
export const passkeyRegisterVerifyRequestSchema = z.object({
attestationResponse: z.unknown().optional(),
name: z.unknown().optional(),
});
export type PasskeyRegisterVerifyRequest = z.infer<typeof passkeyRegisterVerifyRequestSchema>;
export const passkeyLoginVerifyRequestSchema = z.object({
assertionResponse: z.unknown().optional(),
});
export type PasskeyLoginVerifyRequest = z.infer<typeof passkeyLoginVerifyRequestSchema>;
export const passkeyRenameRequestSchema = z.object({
name: z.unknown().optional(),
});
export type PasskeyRenameRequest = z.infer<typeof passkeyRenameRequestSchema>;
// DELETE /credentials/:id — outside the POST/PUT/PATCH boot gate, but typed
// anyway: a non-string password used to reach bcrypt.compareSync and 500.
// Optional keeps the missing-password 401 a service rule.
export const passkeyDeleteRequestSchema = z.object({
password: z.string().optional(),
});
export type PasskeyDeleteRequest = z.infer<typeof passkeyDeleteRequestSchema>;
+69
View File
@@ -0,0 +1,69 @@
import { z } from 'zod';
import { bookDocumentSchema } from './book.schema';
/**
* Storing a book.
*
* ── Why a version rather than a lock ──────────────────────────────────────
*
* Everyone on a journey can open its book, and TREK's whole premise is that
* they are editing at once. A lock would mean one person at a time; a
* last-write-wins save would mean the second person silently erases the first.
*
* So every write carries the version it was made against. The server increments
* on success and refuses a save whose base version has moved — which turns
* "your work vanished" into "someone else changed this", a problem the client
* can actually do something about. It is the same shape as an HTTP ETag, and
* for the same reason.
*
* ── Why the whole document ───────────────────────────────────────────────
*
* Not a patch. A book is a few hundred kilobytes of JSON at most — the schema
* caps it at 150 spreads of 60 elements — and a patch protocol would need an
* ordering guarantee, a merge rule per field, and a way to recover when a
* client misses one. Sending the document costs bandwidth that autosave already
* amortises and removes an entire class of bug.
*/
export const bookSummarySchema = z.object({
id: z.number().int().positive(),
journeyId: z.number().int().positive(),
title: z.string(),
version: z.number().int().nonnegative(),
updatedAt: z.string().nullable(),
/** Who saved last, for "edited by" rather than for permission. */
updatedBy: z.number().int().nullable(),
});
export type BookSummary = z.infer<typeof bookSummarySchema>;
export const bookRecordSchema = bookSummarySchema.extend({
document: bookDocumentSchema,
});
export type BookRecord = z.infer<typeof bookRecordSchema>;
export const bookSaveRequestSchema = z.object({
title: z.string().max(200).default(''),
document: bookDocumentSchema,
/**
* The version this edit was made against.
*
* Omitted only when creating the first book for a journey. Sending a stale
* one is answered with a conflict and the current record, so the client can
* show what happened instead of guessing.
*/
baseVersion: z.number().int().nonnegative().optional(),
});
export type BookSaveRequest = z.infer<typeof bookSaveRequestSchema>;
/**
* What a conflict answers with.
*
* The current record travels with the error, so the client can reconcile
* without a second round trip — and, more importantly, so it can show the other
* version rather than only announcing that one exists.
*/
export const bookConflictSchema = z.object({
error: z.literal('Book was changed by someone else'),
current: bookRecordSchema,
});
export type BookConflict = z.infer<typeof bookConflictSchema>;
+239
View File
@@ -0,0 +1,239 @@
import {
MAX_SPREAD_ELEMENTS,
bookBadgeElementSchema,
bookIconElementSchema,
bookSpreadSchema,
bookStatsElementSchema,
bookTextElementSchema,
normalizeBookDocument,
} from './book.schema';
import { describe, expect, it } from 'vitest';
/**
* What a stored book is allowed to be, checked from the side that has to cope
* with somebody else's data.
*
* Every book is read back through `normalizeBookDocument`: the editor calls it
* when it opens one, and the server calls it on the way in and again on the way
* out. So an element this contract cannot read is not a decoration that quietly
* goes missing, it is a book that opens blank and is then saved back blank.
* That is precisely what a mood mark did while badge codes were capped at two
* characters, which is why most of what follows asks what happened to the rest
* of the document rather than whether the bad element parsed.
*
* The narrower cases pin the fields the entry chips and the icon element added,
* in the shape old documents turn up in, which is without them.
*/
const frame = { x: 12, y: 18, w: 60, h: 40 };
const text = (over: Record<string, unknown> = {}) => ({ id: 't1', kind: 'text', frame, ...over });
const badge = (over: Record<string, unknown> = {}) => ({ id: 'b1', kind: 'badge', frame, ...over });
const icon = (over: Record<string, unknown> = {}) => ({ id: 'i1', kind: 'icon', frame, name: 'Compass', ...over });
/** An element with a `kind` no version of the contract has ever known. */
const unreadable = { id: 'x1', kind: 'sticker', frame };
const doc = (elements: unknown[], parked: unknown[] = []) => ({
version: 1,
title: 'Iceland, end to end',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{ id: 's1', role: 'inner', background: null, entryId: 4, elements, parked }],
});
describe('a text element bound to the journey', () => {
it('carries the notation a coordinate chip was set in, and the value it read', () => {
const dms = bookTextElementSchema.parse(
text({ binding: { source: 'entry.location', entryId: 7, format: 'dms', value: '64.1466,-21.9426' } }),
);
expect(dms.binding!.format).toBe('dms');
expect(dms.binding!.value).toBe('64.1466,-21.9426');
const decimal = bookTextElementSchema.parse(
text({ binding: { source: 'entry.location', entryId: 7, format: 'decimal' } }),
);
expect(decimal.binding!.format).toBe('decimal');
});
it('refuses a notation nothing knows how to set the coordinates in', () => {
const parsed = bookTextElementSchema.safeParse(text({ binding: { source: 'entry.location', format: 'utm' } }));
expect(parsed.success).toBe(false);
});
it('leaves both keys off a binding written before they existed', () => {
const parsed = bookTextElementSchema.parse(text({ binding: { source: 'entry.date', entryId: 7 } }));
expect(parsed.binding!.source).toBe('entry.date');
// Absent has to stay absent, because absent is the answer to both
// questions: the place as the journal writes it, and a date the resolver
// must leave exactly as somebody set it.
expect('format' in parsed.binding!).toBe(false);
expect('value' in parsed.binding!).toBe(false);
});
});
describe('the icon element', () => {
it('defaults its colour and its weight so a name is enough to place one', () => {
const parsed = bookIconElementSchema.parse(icon());
expect(parsed.name).toBe('Compass');
expect(parsed.color).toBe('#111827');
expect(parsed.lineWidth).toBe(2);
});
it('keeps a chosen weight and holds it to the drawable range', () => {
expect(bookIconElementSchema.parse(icon({ lineWidth: 0.75 })).lineWidth).toBe(0.75);
expect(bookIconElementSchema.safeParse(icon({ lineWidth: 0.1 })).success).toBe(false);
expect(bookIconElementSchema.safeParse(icon({ lineWidth: 6 })).success).toBe(false);
});
it('takes a lucide export name and nothing that could not be one', () => {
expect(bookIconElementSchema.safeParse(icon({ name: 'MountainSnow' })).success).toBe(true);
expect(bookIconElementSchema.safeParse(icon({ name: 'compass' })).success).toBe(false);
expect(bookIconElementSchema.safeParse(icon({ name: 'mountain-snow' })).success).toBe(false);
expect(bookIconElementSchema.safeParse(icon({ name: '' })).success).toBe(false);
});
});
describe('a badge code', () => {
it('takes the journal key a mood or weather mark is drawn from', () => {
expect(bookBadgeElementSchema.parse(badge({ variant: 'mood', code: 'amazing' })).code).toBe('amazing');
expect(bookBadgeElementSchema.parse(badge({ variant: 'weather', code: 'partly' })).code).toBe('partly');
});
it('still takes a two-letter country code, which is what it was for', () => {
expect(bookBadgeElementSchema.parse(badge({ variant: 'flag', code: 'IS' })).code).toBe('IS');
expect(bookBadgeElementSchema.parse(badge({ variant: 'flag' })).code).toBeNull();
});
it('stops short of a code no mark could be keyed by', () => {
expect(bookBadgeElementSchema.safeParse(badge({ code: 'x'.repeat(25) })).success).toBe(false);
});
it('keeps a whole book that has a mood mark on a page', () => {
// The regression itself: this document refused to save, and the refusal
// was answered with an empty book rather than with an error.
const out = normalizeBookDocument(doc([badge({ variant: 'mood', code: 'amazing', text: 'Amazing' })]));
expect(out.spreads[0]!.elements).toHaveLength(1);
const mark = out.spreads[0]!.elements[0]!;
expect(mark.kind === 'badge' && mark.code).toBe('amazing');
});
it('draws its icon and its words for a badge written before the switches existed', () => {
const parsed = bookBadgeElementSchema.parse(badge({ variant: 'mood', code: 'good' }));
expect(parsed.showIcon).toBe(true);
expect(parsed.showLabel).toBe(true);
expect(parsed.autoIconColor).toBe(true);
expect(parsed.iconColor).toBe('#111111');
});
it('remembers a mark set to icon only in a chosen colour', () => {
const parsed = bookBadgeElementSchema.parse(
badge({ variant: 'mood', showLabel: false, autoIconColor: false, iconColor: '#c2410c' }),
);
expect(parsed.showIcon).toBe(true);
expect(parsed.showLabel).toBe(false);
expect(parsed.autoIconColor).toBe(false);
expect(parsed.iconColor).toBe('#c2410c');
});
});
describe('the figures a stats element carries', () => {
const stats = (over: Record<string, unknown> = {}) => ({ id: 's1', kind: 'stats', frame, ...over });
it('keeps the metrics it is drawn from', () => {
const parsed = bookStatsElementSchema.parse(stats({ values: { distance: 1420000, days: 12 } }));
expect(parsed.values).toEqual({ distance: 1420000, days: 12 });
});
it('drops a key no reader could ever index by, without failing the element', () => {
// Dropped rather than rejected: a rejected stats element is stripped as
// unreadable, which loses the page the figures were on.
const parsed = bookStatsElementSchema.parse(stats({ values: { distance: 12, sneaky: 1, ['x'.repeat(5000)]: 2 } }));
expect(parsed.values).toEqual({ distance: 12 });
});
});
describe('normalizeBookDocument', () => {
it('keeps the nine elements it can read when the tenth is from another version', () => {
const nine = Array.from({ length: 9 }, (_, i) => text({ id: `t${i + 1}`, text: `line ${i + 1}` }));
const out = normalizeBookDocument(doc([...nine, unreadable]));
expect(out.title).toBe('Iceland, end to end');
expect(out.spreads).toHaveLength(1);
expect(out.spreads[0]!.entryId).toBe(4);
expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8', 't9']);
});
it('salvages the parked elements the same way it salvages the placed ones', () => {
const out = normalizeBookDocument(doc([text({ id: 'kept' })], [text({ id: 'parked-kept' }), unreadable]));
expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['kept']);
expect(out.spreads[0]!.parked.map((el) => el.id)).toEqual(['parked-kept']);
});
it('trims a spread of seventy elements to the cap rather than losing the spread', () => {
const seventy = Array.from({ length: 70 }, (_, i) => text({ id: `t${i}` }));
const out = normalizeBookDocument(doc(seventy));
expect(out.spreads[0]!.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
// Back to front, so the ones that go are the ones on top.
expect(out.spreads[0]!.elements[0]!.id).toBe('t0');
expect(out.title).toBe('Iceland, end to end');
});
it('leaves a document it can read alone', () => {
const input = doc([text({ id: 'a' }), badge({ id: 'b', variant: 'day', text: '5' })]);
const out = normalizeBookDocument(input);
expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a', 'b']);
expect(out.page.pageWidth).toBe(210);
});
it('falls back to the preset for a page dimension nothing could be printed at', () => {
// A page block that fails takes the whole document with it, and the empty
// book that comes back is what the server then writes down. So each of
// these degrades to its default and the spread survives.
for (const bad of [0, -5, 1e12]) {
const out = normalizeBookDocument({
...doc([text({ id: 'a' })]),
page: { preset: 'square-210', pageWidth: bad, pageHeight: 210, bleed: 3, safe: 5 },
});
expect(out.page.pageWidth).toBe(210);
expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a']);
}
const negativeBleed = normalizeBookDocument({
...doc([text({ id: 'a' })]),
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: -3, safe: 5 },
});
expect(negativeBleed.page.bleed).toBe(3);
expect(negativeBleed.spreads[0]!.elements).toHaveLength(1);
});
it('drops an element parked a kilometre off the spread rather than the book', () => {
const out = normalizeBookDocument(doc([text({ id: 'a' }), text({ id: 'far', frame: { x: 1e9, y: 0, w: 60, h: 40 } })]));
expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a']);
expect(out.title).toBe('Iceland, end to end');
});
it('hands back an empty book for something that is not a document at all', () => {
for (const junk of [undefined, null, 'a book, honestly', 42, [], { spreads: 'nope' }]) {
const out = normalizeBookDocument(junk);
expect(out.spreads).toEqual([]);
expect(out.title).toBe('');
expect(out.version).toBe(1);
}
});
});
describe('MAX_SPREAD_ELEMENTS', () => {
it('is the cap both element arrays are held to, so neither can drift from it', () => {
const many = (n: number) => Array.from({ length: n }, (_, i) => text({ id: `t${i}` }));
expect(bookSpreadSchema.safeParse({ id: 's1', elements: many(MAX_SPREAD_ELEMENTS) }).success).toBe(true);
expect(bookSpreadSchema.safeParse({ id: 's1', elements: many(MAX_SPREAD_ELEMENTS + 1) }).success).toBe(false);
expect(bookSpreadSchema.safeParse({ id: 's1', parked: many(MAX_SPREAD_ELEMENTS) }).success).toBe(true);
expect(bookSpreadSchema.safeParse({ id: 's1', parked: many(MAX_SPREAD_ELEMENTS + 1) }).success).toBe(false);
});
});
+832
View File
@@ -0,0 +1,832 @@
import { z } from 'zod';
/**
* The TREK Studio book document — what a photo book *is*, independent of how it
* is edited or rendered.
*
* Two decisions carry the rest of the format:
*
* 1. **Everything geometric is in millimetres.** Not pixels: a pixel means
* nothing to a print shop, and it changes meaning with the device pixel
* ratio and the browser zoom. CSS maps 1in onto 96px, 25.4mm and 72pt with a
* fixed ratio, so the editor can render the document at `transform: scale()`
* and the print renderer at 1:1 and land on the same page. Values are stored
* rounded to two decimals — 10µm, far below any imagesetter — so a JSON round
* trip cannot accumulate drift.
*
* 2. **The paint order is the array order.** `elements[0]` is at the back. No
* `zIndex` field that could disagree with itself, "bring to front" is a
* splice, and because the DOM paints in the same order the editor and the
* renderer agree for free.
*
* A text element may be *bound* to a piece of the journey (an entry's title, its
* story, a photo caption). A bound element re-reads its source when the book is
* opened, so fixing a typo in the journal fixes it in the book — until someone
* edits the text in Studio, which sets `overridden` and stops the sync.
*/
/**
* Two decimals of a millimetre. Anything finer is below print resolution.
*
* Bounded at ten metres in either direction, which is four orders of magnitude
* past the largest book anyone binds and still leaves room for an element
* parked well off the spread. Without a bound a stored `1e9` is a legal
* millimetre, and the editor and the PDF renderer both try to draw a sheet a
* thousand kilometres wide.
*/
const mm = z.number().finite().min(-10000).max(10000).transform(v => Math.round(v * 100) / 100);
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
/**
* Every shape the book can draw — and, because a picture frame *is* a shape with
* a photograph inside it, every mask a photo can be cut to.
*
* One list rather than two. Canva keeps "shapes" and "frames" in separate
* panels, but underneath they are the same geometry, and duplicating it would
* mean a heart that exists as a frame but not as a shape, or the two drifting
* apart the first time one of them is adjusted.
*
* The first four entries are the shapes that existed before this list did. They
* stay at the front and keep their names, so a document saved by the earlier
* editor still parses.
*/
export const BOOK_SHAPES = [
'rect', 'ellipse', 'line', 'triangle',
'triangle-down', 'diamond', 'parallelogram', 'trapezoid',
'pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon',
'arch', 'half-circle', 'quarter-circle', 'capsule', 'squircle',
'star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle',
'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
'chevron-right', 'chevron-left', 'arrow-bent',
'bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think',
'heart', 'cloud', 'cloud-puffy', 'drop', 'moon', 'sun',
'flower-5', 'flower-6', 'leaf', 'cross', 'plus', 'shield', 'gear',
'ticket', 'wave', 'mountain', 'compass', 'pin',
'blob-1', 'blob-2', 'blob-3', 'blob-4',
'banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag',
] as const;
export type BookShapeId = (typeof BOOK_SHAPES)[number];
/**
* The families a book may be set in.
*
* `sans`, `serif` and `display` are the three the format started with and are
* kept by name, so every document written so far still parses — they now point
* at bundled faces rather than at whatever the rendering machine happened to
* have installed. See client/src/components/Studio/bookFonts.ts.
*/
export const BOOK_FONTS_IDS = [
'sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas',
] as const;
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
export const bookFrameSchema = z.object({
/** Millimetres from the left edge of the spread. Negative means bleed. */
x: mm,
/** Millimetres from the top edge of the spread. */
y: mm,
w: mm.refine(v => v > 0, 'width must be positive'),
h: mm.refine(v => v > 0, 'height must be positive'),
});
export type BookFrame = z.infer<typeof bookFrameSchema>;
const elementBase = {
id: z.string().min(1),
frame: bookFrameSchema,
/** Degrees, clockwise. */
rotation: z.number().finite().default(0),
opacity: z.number().min(0).max(1).default(1),
locked: z.boolean().default(false),
};
export const bookPhotoElementSchema = z.object({
...elementBase,
kind: z.literal('photo'),
/**
* `trek_photos.id` — never a resolved URL, or the document would not survive a
* provider change and Studio could not warn about print resolution.
*
* Null is an empty frame: a template lays out where the pictures go before it
* knows which pictures those are, and a placeholder you can drop a photo onto
* is the whole point of a template.
*/
photoId: z.number().int().positive().nullable().default(null),
fit: z.enum(['cover', 'contain']).default('cover'),
/** Where the interesting part of the picture is, 0..1 of each edge. */
focalX: z.number().min(0).max(1).default(0.5),
focalY: z.number().min(0).max(1).default(0.5),
radius: mm.default(0),
filter: z.enum(['none', 'bw', 'warm', 'cool', 'fade', 'contrast']).default('none'),
/**
* Cut the picture to a shape. Null is the plain rectangle every photo starts
* as — and stays, unless someone asks for a heart.
*
* The mask is stretched to the frame rather than kept square, which is what
* makes a frame usable: you place a wide star across a spread and it is wide.
* A shape you cannot resize freely is an ornament, not a frame.
*/
mask: z.enum(BOOK_SHAPES).nullable().default(null),
/**
* Decoration *around* the picture — a Polaroid's thick chin, a print's white
* border, the shadow of a photo lying on a page.
*
* Not a mask and not a shape: it adds to the frame instead of cutting into it,
* and it is the one thing you cannot express with either.
*/
frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'),
});
export const bookTextElementSchema = z.object({
...elementBase,
kind: z.literal('text'),
text: z.string().max(8000).default(''),
font: z.enum(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
leading: z.number().min(0.7).max(3).default(1.45),
/** Ems, may be negative. */
tracking: z.number().min(-0.2).max(1).default(0),
color: hex.default('#1a1a1a'),
/** Where this text came from, when it came from the journey. */
binding: z
.object({
source: z.enum(['journey.title', 'journey.subtitle', 'entry.title', 'entry.story', 'entry.location', 'entry.date', 'photo.caption']),
entryId: z.number().int().optional(),
photoId: z.number().int().optional(),
/**
* How to set the source, where it can be set more than one way.
*
* Only `entry.location` uses it, and only because a stop is both a name
* and a point: absent means the place as it is written in the journal,
* `dms` and `decimal` mean its coordinates. It is a property of the
* binding rather than a second `source`, because both read the one field
* the journal actually records — and because a document written with an
* unknown *source* would fail the union and normalise to an empty book,
* while an unknown key inside the binding is dropped and the page
* survives. A print format is not worth a lost book.
*/
format: z.enum(['dms', 'decimal']).optional(),
/**
* The raw value this element was last set from — an ISO date, a
* `lat,lng` pair — for the sources whose words are *formatted* rather
* than copied.
*
* Without it, re-reading a date cannot tell a journal that changed from a
* page that was set in another language, or in small caps, or as part of
* a longer line: it would rewrite all four alike, in the reader's locale,
* on open. With it the question is exactly the right one — has the entry
* moved? — and an element from before this existed carries no value and
* is therefore left alone, which is the only safe thing to do to a book
* somebody has already printed.
*/
value: z.string().max(200).optional(),
})
.nullable()
.default(null),
/** True once a human edited the text; stops it re-reading its source. */
overridden: z.boolean().default(false),
});
export const bookShapeElementSchema = z.object({
...elementBase,
kind: z.literal('shape'),
shape: z.enum(BOOK_SHAPES).default('rect'),
fill: hex.nullable().default('#111827'),
/**
* Fade the fill out towards one edge. A cover almost always needs this: a
* flat panel behind the title cuts the photograph in half, while a fade lets
* the picture keep going and still leaves the words readable.
*/
gradient: z.enum(['none', 'up', 'down']).default('none'),
stroke: hex.nullable().default(null),
strokeWidth: mm.default(0),
/**
* A dashed rule is a different thing from a solid one — it reads as a fold, a
* route or a cut mark rather than as a line under a heading. The dash length
* follows the stroke width, so it stays proportional at any size.
*/
strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'),
radius: mm.default(0),
});
/**
* ── The travel elements ────────────────────────────────────────────────────
*
* A photo book of a journey is not a photo album: the trip carries facts —
* which countries, how far, how long, which way round — and those facts make
* pages that photographs cannot. This is what Polarsteps' printed books do
* better than a generic book maker, and it is the one thing TREK is in a
* position to do well, because the data is already in the trips hanging off the
* journey.
*
* **Every one of them carries its own values.** The numbers are resolved when
* the element is placed and stored in the document, and the renderer reads them
* from there — it never fetches. Three reasons, and the third is the one that
* decides it:
*
* 1. The print renderer is headless Chromium loading a document. A page that
* needs a logged-in API call to know what it says is a page that prints
* blank on the day the token expires.
* 2. A book is a record of a trip as it was. Adding a stop next year should not
* silently rewrite the distance printed in last year's book.
* 3. It makes the elements ordinary. They move, resize, lock, undo and park like
* everything else, because they *are* like everything else.
*
* `stale` is how the editor offers the other behaviour without giving up any of
* that: it marks a snapshot that no longer matches the journey, and the
* inspector shows a refresh button. Updating is a choice, not a surprise.
*/
/** What the travel elements share with text: they are typeset, not drawn. */
const typeset = {
font: z.enum(BOOK_FONTS_IDS).default('sans'),
color: hex.default('#1a1a1a'),
/**
* The one colour that carries emphasis — the figure in a stat, the route on
* a map, the fill of a chip.
*
* Black by default, not the app's orange. A book is printed and a printed
* page is ink on paper; a colour that arrives without being chosen turns
* every travel element into the same accent whether or not the book wants
* one. Picking a colour is one click and is what the swatches are for — and
* the map's dark style overrides it anyway, because black lines on a dark
* map are lines nobody can see.
*/
accent: hex.default('#111111'),
/** Scales every piece of type in the element at once. 1 is the drawn default. */
textScale: z.number().min(0.4).max(3).default(1),
/**
* How heavy the type is set.
*
* The travel elements draw at several sizes at once — a figure over its
* label, a country name over its outline — and this moves all of them
* together, the way `textScale` moves their size. Without it the only way to
* lighten a stats block was to not use one.
*/
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
/** Set when the journey has moved on since these values were taken. */
stale: z.boolean().default(false),
};
/** Kilometres or miles. Stored metric; the element converts when it draws. */
export const bookUnitsSchema = z.enum(['metric', 'imperial']);
export const bookMapElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('map'),
/**
* Where the map underneath comes from.
*
* `vector` draws country outlines from the bundled boundaries: it carries no
* licence, fetches nothing at render time, and prints sharp at any size,
* which is why it is the default and why a book can be exported offline.
*
* `tiles` and `static` use the map provider the instance is already
* configured with, for a book that wants real geography — streets, terrain,
* coastline detail an outline cannot give. Both fetch at render time and both
* carry an attribution the element prints, because the licence requires it
* and a book is a published work. A tiled map is also raster: it is cut for a
* zoom level, so printing it much larger than it was fetched goes soft.
*/
source: z.enum(['vector', 'tiles', 'static']).default('vector'),
/**
* The tile template, frozen when the element was placed.
*
* Kept in the document rather than read from settings at render time for the
* same reason the figures are: a page that resolves its own contents from an
* account's current settings is a page that changes when someone else edits
* a preference.
*/
tileUrl: z.string().max(500).default(''),
/** What the licence requires the page to say. Printed small, in a corner. */
attribution: z.string().max(200).default(''),
/**
* Zoom for the tiled sources. Null lets the element choose one that fits the
* route, which is right almost always and wrong when someone wants the
* street detail of a single city.
*/
zoom: z.number().int().min(0).max(19).nullable().default(null),
style: z.enum(['minimal', 'outline', 'dark', 'paper']).default('minimal'),
/** Country silhouettes under the route, drawn from the bundled boundaries. */
showLand: z.boolean().default(true),
showRoute: z.boolean().default(true),
showPins: z.boolean().default(true),
showLabels: z.boolean().default(false),
/**
* ── How the line is drawn ────────────────────────────────────────────
*
* `plain` is one stroke in the element's ink: what every map in every book
* made so far already draws, and the default for exactly that reason.
*
* `drawn` is the printed-atlas treatment — a light line over a dark casing,
* or the reverse on pale paper — which is what keeps a single route legible
* across near-black bathymetry and bright desert in the same picture. It
* cannot be the default: a stored book would change the day it was opened.
*/
routeStyle: z.enum(['plain', 'drawn']).default('plain'),
/**
* Whether a long leg bows.
*
* A straight line between two stops a continent apart is a claim about the
* journey that is obviously false, and drawing it as a shallow curve is how
* printed maps have always said "this part was a flight". Only ever applied
* to the inferred stop-to-stop line: bowing a recorded GPS track would draw
* a journey that did not happen.
*/
routeArc: z.enum(['straight', 'bow']).default('straight'),
/**
* Whether the bowed legs are dashed.
*
* The dash then MEANS something — this leg is inferred, not recorded — which
* is why it applies to the bowed legs alone rather than to the whole line.
*/
routeDash: z.enum(['solid', 'arcs']).default('solid'),
/**
* What a stop looks like.
*
* `photo` draws the stop as a small round picture with a white ring, from the
* photograph the point carries, and falls back to a dot where there is none.
* That fallback is the designed state rather than a failure: a printed page
* with a few plain dots between the pictures reads as a route, and one with
* empty rings reads as a bug.
*/
pinStyle: z.enum(['dot', 'photo']).default('dot'),
/** ISO-3166-1 alpha-2, the countries whose outlines to draw. */
countries: z.array(z.string().length(2)).max(80).default([]),
/**
* The route, in order. Capped where a printed line stops gaining from more
* points — a book page cannot resolve four hundred stops anyway.
*/
points: z
.array(z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
label: z.string().max(120).default(''),
/**
* One photograph from this stop, for a marker that shows it.
*
* `trek_photos.id`, never a resolved URL, for the same reason a photo
* element stores an id: a book has to survive a provider change. Null is
* ordinary and means the marker is a dot.
*/
photoId: z.number().int().positive().nullable().default(null),
}))
.max(400)
.default([]),
/**
* The way the trip was actually travelled, where the trip knows it.
*
* `points` are the stops, and a line drawn straight between them is a claim
* about the journey that is almost always false: it crosses mountains,
* water and borders that the trip went around. TREK already stores routed
* geometry per place from the planner and from imported GPX, so where that
* exists the line follows the road instead of the ruler.
*
* Segments rather than one list, because two tracks that do not join must
* not be drawn as though they did: the gap between the end of one day's
* driving and the start of the next is a gap, not a leg.
*
* `[lat, lng]`, matching the order the tracks endpoint answers in, and thinned
* when it is frozen into the document: a printed line gains nothing from a
* point every fifteen metres, and a book carrying every GPS fix of a
* three-week trip is a document nobody can save.
*/
path: z
.array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(1200))
.max(40)
.default([]),
/**
* The roads each leg actually took, when somebody asked for them.
*
* ── Why this is not `path` ───────────────────────────────────────────
*
* `path` is a recorded track: a GPS trace of where somebody went, which has
* no idea which stop it started at and is drawn instead of the stop chain.
* This is per leg — entry `i` is the way from stop `i` to stop `i + 1`, and
* `null` is a leg with no road answer, which is most journeys' flights.
*
* Per leg rather than one line, because a partial road route drawn as one
* would be a lie by omission: the renderer replaces the whole stop chain the
* moment it has a track, so a journey with roads for three legs out of twelve
* would print three legs and drop the other nine.
*
* Frozen into the document like every other travel figure. A page that asks a
* routing service for its line at print time is a page that changes when a
* road is rerouted, and prints empty when the export runs offline.
*/
roads: z
.array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(200).nullable())
.max(400)
.default([]),
/**
* How much room to leave around what is drawn, as a share of its own extent.
*
* The view is fitted to the stops, not to the countries: a trip that stayed
* in Berlin is a map of Berlin, not a map of Germany with two dots on it.
* Country outlines are still drawn, they simply run off the edge like the
* geography they are.
*/
/*
* Half again the extent of the route, rather than a fifth.
*
* A map fitted tightly to its own stops is a diagram; the air around it is
* what makes it a place. The reference books all leave room for the coast to
* arrive and the neighbouring country to show, and a route that touches its
* own frame on two sides reads as cropped rather than as composed.
*/
fitPadding: z.number().min(0).max(4).default(0.5),
/**
* Fit to the countries rather than to the stops.
*
* The default, because it is the picture a travel book usually wants: the
* country, with the route inside it. Fitting to the stops is the other half
* of the choice and matters as soon as the trip is smaller than the country
* it happened in — a week in Berlin drawn as the whole of Germany is two
* dots in a large empty shape.
*/
fitToCountries: z.boolean().default(true),
/**
* The shape the map is cut to.
*
* `rect` is a map in a box, which is what a map usually is and what a full
* page wants. `country` cuts it to the outline of the countries themselves,
* so the picture *is* the country: no frame, no box, the page showing through
* around the coastline. That is the version worth putting next to a
* photograph, and it is the one thing a rectangle can never be.
*
* Cutting only makes sense against the whole outline, so it fits to the
* countries whatever `fitToCountries` says: a city-sized view cut to the
* shape of a country is an evenly coloured rectangle with a route on it.
*/
clip: z.enum(['rect', 'country']).default('rect'),
/**
* Which trip this map is of, when it is of one.
*
* Null is the whole journey, which is what a map has always been. A journey
* is a collection of trips, though, and printing two of them as one route
* draws a line from the last stop of the first to the first stop of the
* second: a leg nobody travelled, usually the longest one on the page.
*
* Stored rather than derived because the points are frozen into the element
* when it is placed. Without it, bringing a map up to date would quietly turn
* a map of Iceland back into a map of everywhere.
*/
tripId: z.number().int().positive().nullable().default(null),
});
export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const;
export type BookMetric = (typeof BOOK_METRICS)[number];
export const bookStatsElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('stats'),
/** Which figures, in the order they are drawn. */
metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']),
layout: z.enum(['grid', 'row', 'column']).default('grid'),
showIcons: z.boolean().default(true),
units: bookUnitsSchema.default('metric'),
/**
* Metric to value. Distance is metres, everything else a plain count.
*
* Anything that is not a metric is dropped rather than rejected: both readers
* only ever index by a `BOOK_METRICS` member, so a stray key is payload that
* gets persisted and re-served to every viewer without ever being drawn.
* Filtered instead of typed as `z.record(z.enum(BOOK_METRICS), …)`, because on
* zod 4 an enum key makes the record exhaustive — every metric would become
* required and every stats element written so far would fail, then be thrown
* away as unreadable.
*/
values: z.record(z.string(), z.number().finite()).default({})
.transform(v => Object.fromEntries(
Object.entries(v).filter(([k]) => (BOOK_METRICS as readonly string[]).includes(k)),
)),
});
export const bookCountriesElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('countries'),
/** ISO-3166-1 alpha-2, in visit order. */
codes: z.array(z.string().length(2)).max(80).default([]),
/** Names as resolved when placed, so the page does not depend on a lookup. */
names: z.array(z.string().max(80)).max(80).default([]),
layout: z.enum(['list', 'grid', 'column']).default('list'),
/** The silhouette behind each name — the thing that makes the page read as a map. */
showOutline: z.boolean().default(true),
showFlag: z.boolean().default(false),
showName: z.boolean().default(true),
align: z.enum(['left', 'center', 'right']).default('center'),
});
export const BOOK_BADGES = [
'flag', 'date', 'day', 'coords', 'country', 'distance', 'weather', 'altitude',
'mood',
] as const;
export type BookBadgeVariant = (typeof BOOK_BADGES)[number];
/**
* The small marks: a flag, a date set as a numeral, a "DAY 5" chip, a line of
* coordinates. Individually trivial, and collectively most of what makes a
* printed travel page look composed rather than typed.
*/
export const bookBadgeElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('badge'),
variant: z.enum(BOOK_BADGES).default('date'),
/** The resolved value — "13", "48°51'N 2°21'E", "ICELAND". */
text: z.string().max(200).default(''),
/** The line under it — a month, a place, a unit. */
sub: z.string().max(200).default(''),
/**
* What the mark is *of*, when the drawing needs more than the words.
*
* ISO-3166-1 alpha-2 for a flag or a country silhouette, and the journal's
* own key for a mood or a weather mark — `amazing`, `partly` — which is what
* lets the icon and the palette come from the journey page rather than being
* listed a second time here.
*
* It was two characters once, back when only flags used it, and a mood mark
* placed against that contract took the whole book down with it: the save was
* refused and the document normalised to nothing. A country code is still two
* characters; the field is simply no longer only that.
*/
code: z.string().max(24).nullable().default(null),
style: z.enum(['plain', 'chip', 'outline', 'stacked']).default('plain'),
/**
* Let the mark pick its own text colour.
*
* The same bargain the page numbers make: automatic until somebody chooses,
* and choosing is what turns it off. A chip is a filled capsule, so its words
* have to answer to the fill rather than to the page: white on ink, ink on a
* pale accent. Doing that by hand means changing two colours every time you
* change one, and getting it wrong means a black day counter on a black chip,
* which is what happened before this existed.
*/
autoColor: z.boolean().default(true),
/**
* Whether the mark draws its icon, and whether it draws its words.
*
* Two flags rather than one three-valued mode, because that is what the
* renderer actually asks — and because a document written before they
* existed reads `undefined` as "draw it", which is what it always did. The
* panel offers the three combinations that mean something (both, icon only,
* words only); an empty mark is not one of them.
*/
showIcon: z.boolean().default(true),
showLabel: z.boolean().default(true),
/**
* The icon's own colour, and whether it picks it.
*
* Automatic is a mood's palette from the journal — the pink of "amazing",
* the amber of "good" — and the element's accent for everything else, which
* is what these marks have always drawn. It has to stay a flag rather than a
* nullable colour: every badge already in a book carries `iconColor`'s
* default once it is parsed, and reading that as a choice would repaint every
* mood mark in the field to a flat near-black.
*/
autoIconColor: z.boolean().default(true),
iconColor: hex.default('#111111'),
});
/**
* A drawing from the icon set the app is already built out of.
*
* Not a member of BOOK_SHAPES, and deliberately: a shape is a filled outline
* the editor scales by moving its points, while an icon is a stroked drawing of
* several parts that has to keep its proportions to stay legible — a compass
* stretched to twice its width stops reading as a compass. They also come from
* opposite places. A shape is drawn here and lives in this file; an icon is one
* of about fourteen hundred names in lucide, and listing them in the contract
* would mean a document that stops parsing the day the library renames one.
*
* So the name travels as a bounded string and the renderer resolves it, falling
* back the way the rest of TREK already does for a plugin's icon. A book that
* outlives an icon loses that drawing, not the page it was on.
*/
export const bookIconElementSchema = z.object({
...elementBase,
kind: z.literal('icon'),
/** A lucide export name, PascalCase — "Compass", "Plane", "MountainSnow". */
name: z.string().regex(/^[A-Z][A-Za-z0-9]*$/, 'expected a lucide icon name').max(60),
color: hex.default('#111827'),
/**
* How heavy the drawing is, against lucide's own 24-unit grid — not
* millimetres, which is why it is not called `strokeWidth`: three separate
* places in the client rescale a field of that name by the page width when a
* spread moves between books, and an icon's weight is not a length.
*/
lineWidth: z.number().min(0.25).max(4).default(2),
});
/**
* A journal entry's pros and cons, set as a page.
*
* TREK's entries already carry them as two lists, and two lists is exactly what
* a text element cannot express: run together as a paragraph they lose the
* pairing, and run as one column they lose which side each line is on. Two
* marked columns is how a travel diary has always printed this.
*/
export const bookListElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('list'),
items: z
.array(z.object({
text: z.string().max(400),
/** Which column the line belongs to, and which mark it gets. */
tone: z.enum(['pro', 'con', 'plain']).default('plain'),
}))
.max(60)
.default([]),
/** Pros beside cons, or one column with the marks inline. */
layout: z.enum(['columns', 'stacked']).default('columns'),
showMarks: z.boolean().default(true),
/** A heading over each column — empty for none. */
proLabel: z.string().max(80).default(''),
conLabel: z.string().max(80).default(''),
});
/**
* How much may stand on one spread.
*
* A limit rather than a taste: the document is saved whole on every autosave,
* and a page nobody could design is a page somebody's browser has to serialise.
*/
export const MAX_SPREAD_ELEMENTS = 60;
export const bookElementSchema = z.discriminatedUnion('kind', [
bookPhotoElementSchema,
bookTextElementSchema,
bookShapeElementSchema,
bookMapElementSchema,
bookStatsElementSchema,
bookCountriesElementSchema,
bookBadgeElementSchema,
bookListElementSchema,
bookIconElementSchema,
]);
export type BookElement = z.infer<typeof bookElementSchema>;
export type BookIconElement = z.infer<typeof bookIconElementSchema>;
export type BookPhotoElement = z.infer<typeof bookPhotoElementSchema>;
export type BookTextElement = z.infer<typeof bookTextElementSchema>;
export type BookShapeElement = z.infer<typeof bookShapeElementSchema>;
export type BookMapElement = z.infer<typeof bookMapElementSchema>;
export type BookStatsElement = z.infer<typeof bookStatsElementSchema>;
export type BookCountriesElement = z.infer<typeof bookCountriesElementSchema>;
export type BookBadgeElement = z.infer<typeof bookBadgeElementSchema>;
export type BookListElement = z.infer<typeof bookListElementSchema>;
export type BookUnits = z.infer<typeof bookUnitsSchema>;
export const bookSpreadSchema = z.object({
id: z.string().min(1),
/** 'cover' and 'back' are single pages; everything else is a double spread. */
role: z.enum(['cover', 'back', 'inner']).default('inner'),
background: hex.nullable().default(null),
/** Back to front. */
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/**
* Content that belongs to this spread but is not currently placed.
*
* Changing to a layout with fewer frames must not destroy the pictures the old
* one held — the user is trying an arrangement, not deleting their photographs,
* and a single wrong click on a text-only layout would otherwise cost them
* everything on the page. Parked elements come back the moment a layout with
* room for them is applied.
*/
parked: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/** The journey entry this spread was generated from, if any. */
entryId: z.number().int().nullable().default(null),
});
export type BookSpread = z.infer<typeof bookSpreadSchema>;
/**
* Page numbers.
*
* Set here, on the document, and filled by the renderer — which is the only
* place that knows what number a given page is. Letting pdf-lib stamp them
* afterwards would put a different number in the PDF than the one the editor
* showed, and a book you cannot proofread on screen is not much of an editor.
*
* `startAt` exists because the first inner spread is rarely page 1 in the
* finished object: the cover is a separate sheet, and some binders count it and
* some do not.
*/
export const bookPageNumbersSchema = z.object({
show: z.boolean().default(false),
/** Which number the first inner spread's left page carries. */
startAt: z.number().int().min(0).max(9999).default(2),
position: z.enum(['outer', 'inner', 'centre']).default('outer'),
/** Millimetres from the trim edge. */
margin: mm.default(12),
size: z.number().min(4).max(48).default(8),
/**
* Pick the folio colour from what it lands on, rather than using `color`.
*
* A book has dark pages and light ones, and one fixed colour is wrong on half
* of them — a grey folio disappears into a full-bleed night photograph and
* again into a black page. The renderer decides per page; `color` is what it
* uses when this is off, and choosing a colour turns it off.
*/
autoColor: z.boolean().default(true),
color: hex.default('#8a8578'),
font: z.enum(BOOK_FONTS_IDS).default('sans'),
});
export type BookPageNumbers = z.infer<typeof bookPageNumbersSchema>;
export const bookPageSetupSchema = z.object({
preset: z.enum(['square-210', 'square-300', 'a4-landscape', 'a4-portrait', 'a5-landscape', 'custom']).default('square-210'),
/**
* A single page. A double spread is drawn twice this wide.
*
* These four fall back rather than fail, like `version` below. The salvage
* pass only strips unreadable *elements*, so a page block the contract
* cannot read takes the whole book down to an empty one — and that empty
* book is what the server writes back. A nonsense dimension degrading to the
* preset is the smaller loss by a long way.
*/
pageWidth: mm.refine(v => v > 0, 'page width must be positive').catch(210).default(210),
pageHeight: mm.refine(v => v > 0, 'page height must be positive').catch(210).default(210),
bleed: mm.refine(v => v >= 0, 'bleed cannot be negative').catch(3).default(3),
safe: mm.refine(v => v >= 0, 'safe margin cannot be negative').catch(5).default(5),
pageNumbers: bookPageNumbersSchema.default(() => bookPageNumbersSchema.parse({})),
});
export type BookPageSetup = z.infer<typeof bookPageSetupSchema>;
export const bookDocumentSchema = z.object({
/** In the document, not in a column: a format bump must not need a migration. */
version: z.literal(1).catch(1).default(1),
title: z.string().max(200).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
spreads: z.array(bookSpreadSchema).max(150).default([]),
});
export type BookDocument = z.infer<typeof bookDocumentSchema>;
/**
* Read a stored document without ever throwing.
*
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
* open — an editor that refuses to load someone's work because one field drifted
* is worse than one that drops the field.
*
* ── Why there are two attempts ───────────────────────────────────────────
*
* Dropping *the field* was the intent; dropping the whole book was what the
* code did. `elements` is an array of a discriminated union, so one element the
* contract cannot read fails its spread, fails the array, fails the document —
* and this function then handed back an empty book. It is not hypothetical: a
* mood mark carried the journal's key in a field capped at two characters, so
* every book with one on a page refused to save, and would have opened blank.
* Both the client's load seam and the server's read *and* write path run
* through here, which means the empty book was also what got written down.
*
* So a document that fails whole is tried again with the unreadable elements
* removed, and only a document that fails even then is given up on. Losing one
* decoration to a version skew is a shrug; losing the book is not.
*/
export function normalizeBookDocument(raw: unknown): BookDocument {
const parsed = bookDocumentSchema.safeParse(raw);
if (parsed.success) return parsed.data;
const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
if (salvaged.success) return salvaged.data;
return bookDocumentSchema.parse({});
}
function withoutUnreadableElements(raw: unknown): unknown {
if (!raw || typeof raw !== 'object') return raw;
const doc = raw as { spreads?: unknown };
if (!Array.isArray(doc.spreads)) return raw;
const readable = (list: unknown) =>
(Array.isArray(list)
// Trimmed to the cap as well as filtered: a spread that grew past 60
// fails the array the same way an unreadable element fails the union, and
// losing the sixty-first decoration beats losing the book.
? list.filter(el => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
: list);
return {
...doc,
spreads: doc.spreads.map(sp => {
if (!sp || typeof sp !== 'object') return sp;
const spread = sp as { elements?: unknown; parked?: unknown };
return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
}),
};
}
+118
View File
@@ -0,0 +1,118 @@
import { z } from 'zod';
/**
* What a journey adds up to.
*
* The figures a travel book puts on a page — how far, how long, how many
* countries, which way round — and they are not stored anywhere. They are
* derived from the trips the user added to the journey: the places on those
* trips carry coordinates, the trips carry dates, and the journey carries the
* entries and the photographs.
*
* Computed on the server rather than in Studio for one reason that settles it:
* a place's country is a point-in-polygon test against the bundled admin-0
* boundaries, and those are 4MB gzipped. The server already holds them and
* already caches the answer per place in `place_regions` for Atlas. Sending the
* boundaries to the browser so it can repeat work the server has done would be
* the wrong trade twice over.
*
* Distance is the sum of great-circle hops along the route, in metres. Not
* driving distance: there is no routing engine here, the places are not
* necessarily connected by road, and a book that claims a road distance it did
* not measure is worse than one that says "as the crow flies". Metres because
* miles and kilometres are a display choice, and rounding at the source would
* bake one of them in.
*/
export const journeyStatsPointSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
/** The place or entry name, for a map that labels its stops. */
label: z.string(),
/** ISO date, when the stop has one. Orders the route. */
date: z.string().nullable(),
/** ISO-3166-1 alpha-2, resolved from the coordinates. */
country: z.string().length(2).nullable(),
/**
* Which trip this stop belongs to, when the journey knows.
*
* A journey is a collection of trips, and a book of one is not always a book
* of all of them: three countries over two trips printed as a single route
* draws a line between the last stop of one and the first of the next, which
* is a journey nobody made. With the trip on every point, Studio can offer a
* map per trip as well as a map of the whole thing, and the line stops where
* the trip stopped.
*
* Null for a stop that came from an entry nobody linked to a trip.
*/
tripId: z.number().int().positive().nullable().default(null),
/**
* One representative photograph from this stop, when it has one.
*
* For a map that marks its stops with pictures rather than with dots. Null is
* the ordinary case rather than a failure — a printed route with a few plain
* dots between the photographs reads correctly, and it is what a stop nobody
* photographed honestly is.
*/
photoId: z.number().int().positive().nullable().default(null),
});
export type JourneyStatsPoint = z.infer<typeof journeyStatsPointSchema>;
/**
* The trips behind a journey, named.
*
* Only what a panel needs to offer a choice: which trips there are, what they
* are called, and when they ran. Ordered the way the journey reads, by start
* date, because `journey_trips` has carried no order of its own since the
* column was dropped in migration 87.
*/
export const journeyStatsTripSchema = z.object({
id: z.number().int().positive(),
title: z.string(),
start: z.string().nullable(),
end: z.string().nullable(),
/** How many of the route's stops fall on this trip. */
points: z.number().int().nonnegative(),
});
export type JourneyStatsTrip = z.infer<typeof journeyStatsTripSchema>;
export const journeyStatsCountrySchema = z.object({
code: z.string().length(2),
/** English name. The client translates where it has a translation. */
name: z.string(),
/** How many stops fell inside it — enough to order countries by weight. */
places: z.number().int().nonnegative(),
/** ISO date of the first stop there, for ordering the list as travelled. */
firstVisit: z.string().nullable(),
});
export type JourneyStatsCountry = z.infer<typeof journeyStatsCountrySchema>;
export const journeyStatsSchema = z.object({
journeyId: z.number().int().positive(),
/** Great-circle metres along the route, in order. */
distance: z.number().nonnegative(),
/**
* Calendar days the journey spans, first stop to last, inclusive — a one-day
* trip is 1 day, not 0. Falls back to the trips' own dates when the entries
* carry none.
*/
days: z.number().int().nonnegative(),
/** Journey entries — what Polarsteps calls a step. */
steps: z.number().int().nonnegative(),
/** Photographs in the journey, entries and gallery together. */
photos: z.number().int().nonnegative(),
/** Places across every trip added to the journey. */
places: z.number().int().nonnegative(),
/** Metres from the first stop to the one furthest from it. */
furthest: z.number().nonnegative(),
/** In visit order. */
countries: z.array(journeyStatsCountrySchema).max(200),
/** The route, in order, capped at what a printed line can resolve. */
points: z.array(journeyStatsPointSchema).max(400),
/** The trips this journey is made of, for a map of one of them. */
trips: z.array(journeyStatsTripSchema).max(200).default([]),
/** ISO dates, null when nothing in the journey is dated. */
start: z.string().nullable(),
end: z.string().nullable(),
});
export type JourneyStats = z.infer<typeof journeyStatsSchema>;
+20
View File
@@ -3,6 +3,8 @@ import {
budgetUpdateMembersRequestSchema,
budgetToggleMemberPaidRequestSchema,
budgetReorderItemsRequestSchema,
COST_CATEGORIES,
typeToCostCategory,
} from './budget.schema';
import { describe, it, expect } from 'vitest';
@@ -41,3 +43,21 @@ describe('budgetReorderItemsRequestSchema', () => {
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
});
});
describe('COST_CATEGORIES', () => {
it('includes fuel and parking alongside the existing fixed categories', () => {
expect(COST_CATEGORIES).toContain('fuel');
expect(COST_CATEGORIES).toContain('parking');
});
});
describe('typeToCostCategory', () => {
it('files a parking booking under parking, not transport', () => {
expect(typeToCostCategory('parking')).toBe('parking');
});
it('leaves the other vehicle types on transport', () => {
expect(typeToCostCategory('car-rental')).toBe('transport');
expect(typeToCostCategory('taxi')).toBe('transport');
});
});
+13
View File
@@ -46,6 +46,8 @@ export const COST_CATEGORIES = [
'fees',
'health',
'tips',
'fuel',
'parking',
'other',
] as const;
export type CostCategory = (typeof COST_CATEGORIES)[number];
@@ -70,6 +72,7 @@ const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
parking: 'parking',
restaurant: 'food',
activity: 'activities',
};
@@ -112,7 +115,12 @@ export const budgetItemSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
/** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */
ticket_json: z.string().nullable().optional(),
reservation_id: z.number().nullable().optional(),
/** Set when the expense was created from a place (#1298) — the same link
* reservation_id is for a booking, on the other side of the planner. */
place_id: z.number().nullable().optional(),
paid_by_user_id: z.number().nullable().optional(),
expense_date: z.string().nullable().optional(),
sort_order: z.number().optional(),
@@ -147,10 +155,14 @@ export const budgetCreateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
// Link this expense to a reservation (e.g. created from a booking's
// "add expense" flow). The server stores it on budget_items.reservation_id.
reservation_id: z.number().optional(),
// The same for a place: the place form's "add expense" flow saves the place
// first, then creates the expense against it (#1298).
place_id: z.number().optional(),
});
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
@@ -167,6 +179,7 @@ export const budgetUpdateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
+16 -2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { hexColorSchema } from '../place/place.schema';
/**
* Category API contract — single source of truth for the /api/categories endpoints.
@@ -12,6 +13,13 @@ import { z } from 'zod';
* messages are reproduced in the controller so the bodies stay byte-identical.
*/
/**
* Note the asymmetry below: the response keeps `color: z.string()` while the
* requests demand a hex value. Tightening the response would make an instance
* fail on its own stored rows — the write path was unvalidated for a long time,
* so anything could be in there. The client allow-lists on read for the same
* reason (client/src/utils/safeColor.ts).
*/
export const categorySchema = z.object({
id: z.number(),
name: z.string(),
@@ -24,7 +32,10 @@ export type Category = z.infer<typeof categorySchema>;
export const createCategoryRequestSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
// Hex only: the value is interpolated into a style="…" attribute of the
// hand-built marker HTML on both map renderers, and on the share page that
// answers without a guard.
color: hexColorSchema.optional(),
icon: z.string().optional(),
});
export type CreateCategoryRequest = z.infer<typeof createCategoryRequestSchema>;
@@ -32,7 +43,10 @@ export type CreateCategoryRequest = z.infer<typeof createCategoryRequestSchema>;
/** All fields optional — the service COALESCEs each against the stored value. */
export const updateCategoryRequestSchema = z.object({
name: z.string().optional(),
color: z.string().optional(),
// Hex only: the value is interpolated into a style="…" attribute of the
// hand-built marker HTML on both map renderers, and on the share page that
// answers without a guard.
color: hexColorSchema.optional(),
icon: z.string().optional(),
});
export type UpdateCategoryRequest = z.infer<typeof updateCategoryRequestSchema>;
+30
View File
@@ -1,5 +1,6 @@
import {
collabNoteCreateRequestSchema,
collabNoteUpdateRequestSchema,
collabPollCreateRequestSchema,
collabPollVoteRequestSchema,
collabMessageCreateRequestSchema,
@@ -14,6 +15,35 @@ describe('collabNoteCreateRequestSchema', () => {
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
});
it('accepts explicit null for the optional fields (desktop null-clearing)', () => {
expect(
collabNoteCreateRequestSchema.safeParse({
title: 'Idea',
content: 'Body',
category: null,
color: '#6366f1',
website: null,
}).success,
).toBe(true);
});
});
describe('collabNoteUpdateRequestSchema', () => {
it('accepts explicit null for the optional fields (desktop null-clearing)', () => {
expect(
collabNoteUpdateRequestSchema.safeParse({
title: 'Idea',
content: null,
category: null,
color: null,
website: null,
pinned: 1,
}).success,
).toBe(true);
expect(collabNoteUpdateRequestSchema.safeParse({ pinned: true }).success).toBe(true);
expect(collabNoteUpdateRequestSchema.safeParse({ title: null }).success).toBe(false);
});
});
describe('collabPollCreateRequestSchema', () => {
+12 -8
View File
@@ -13,20 +13,24 @@ import { z } from 'zod';
export const collabNoteCreateRequestSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
category: z.string().optional(),
color: z.string().optional(),
website: z.string().optional(),
// The desktop notes form clears optional fields by sending explicit null
// (the service coerces falsy to its defaults), so they are all nullable.
content: z.string().nullable().optional(),
category: z.string().nullable().optional(),
color: z.string().nullable().optional(),
website: z.string().nullable().optional(),
});
export type CollabNoteCreateRequest = z.infer<typeof collabNoteCreateRequestSchema>;
export const collabNoteUpdateRequestSchema = z.object({
title: z.string().optional(),
content: z.string().optional(),
category: z.string().optional(),
color: z.string().optional(),
// Same null-clearing protocol as create (the desktop form resends the whole
// note object, with cleared fields as explicit null).
content: z.string().nullable().optional(),
category: z.string().nullable().optional(),
color: z.string().nullable().optional(),
pinned: z.union([z.boolean(), z.number()]).optional(),
website: z.string().optional(),
website: z.string().nullable().optional(),
});
export type CollabNoteUpdateRequest = z.infer<typeof collabNoteUpdateRequestSchema>;
@@ -1,4 +1,9 @@
import { collectionPlaceUpdateRequestSchema } from './collection.schema';
import {
collectionDeleteManyRequestSchema,
collectionPlaceUpdateRequestSchema,
collectionReorderRequestSchema,
collectionSavePlaceRequestSchema,
} from './collection.schema';
import { describe, expect, it } from 'vitest';
@@ -19,4 +24,42 @@ describe('collectionPlaceUpdateRequestSchema', () => {
it('catches an invalid status back to idea rather than throwing', () => {
expect(collectionPlaceUpdateRequestSchema.parse({ status: 'bogus' as never }).status).toBe('idea');
});
// #1870: the update contract knew no `address`, so the validation pipe stripped
// it and the address of a saved place could never be corrected.
it('passes an address through and accepts null to clear it', () => {
expect(collectionPlaceUpdateRequestSchema.parse({ address: 'Via Nuova 1' }).address).toBe('Via Nuova 1');
expect(collectionPlaceUpdateRequestSchema.parse({ address: null }).address).toBeNull();
});
it('leaves an absent address absent, so an unrelated edit keeps the stored one', () => {
const parsed = collectionPlaceUpdateRequestSchema.parse({ name: 'Trevi Fountain' });
expect('address' in parsed).toBe(false);
expect(parsed.address).toBeUndefined();
});
// Save and update must cap the address the same way (neither does): a limit on
// one side only would reject on edit what saving accepted.
it('caps the address on neither the save nor the update side', () => {
const long = 'x'.repeat(5000);
expect(collectionPlaceUpdateRequestSchema.parse({ address: long }).address).toBe(long);
expect(collectionSavePlaceRequestSchema.parse({ collection_id: 1, name: 'X', address: long }).address).toBe(long);
});
});
// The two ratchet schemas replace hand-rolled "array of numbers" checks — they
// must stay exactly that permissive (empty arrays included, no int/positive).
describe('collectionReorderRequestSchema / collectionDeleteManyRequestSchema', () => {
it('accepts any array of numbers, empty included', () => {
expect(collectionReorderRequestSchema.parse({ orderedIds: [] }).orderedIds).toEqual([]);
expect(collectionReorderRequestSchema.parse({ orderedIds: [3, 1, 2] }).orderedIds).toEqual([3, 1, 2]);
expect(collectionDeleteManyRequestSchema.parse({ ids: [] }).ids).toEqual([]);
expect(collectionDeleteManyRequestSchema.parse({ ids: [7] }).ids).toEqual([7]);
});
it('rejects non-arrays and non-numeric members', () => {
expect(() => collectionReorderRequestSchema.parse({ orderedIds: 'nope' })).toThrow();
expect(() => collectionReorderRequestSchema.parse({})).toThrow();
expect(() => collectionDeleteManyRequestSchema.parse({ ids: ['a'] })).toThrow();
});
});
+93 -4
View File
@@ -1,4 +1,4 @@
import { placeCategorySchema } from '../place/place.schema';
import { placeCategorySchema, placeImageUrlSchema, placeRatingVoteSchema, placeWebsiteSchema } from '../place/place.schema';
import { tagSchema } from '../tag/tag.schema';
import { z } from 'zod';
@@ -53,6 +53,9 @@ export const collectionPlaceSchema = z.object({
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Deliberately open on the way out, pinned on the way in (see the save/update
// request schemas below): rows written before the request-side check existed
// still have to round-trip.
image_url: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
@@ -70,6 +73,10 @@ export const collectionPlaceSchema = z.object({
tags: z.array(tagSchema.partial()).optional(),
/** Ids of the per-collection labels assigned to this place. */
label_ids: z.array(z.number()).optional(),
// Collaborative ratings (#1435): every member's vote + the displayed aggregate.
ratings: z.array(placeRatingVoteSchema).optional(),
rating_avg: z.number().nullable().optional(),
rating_count: z.number().optional(),
});
export type CollectionPlace = z.infer<typeof collectionPlaceSchema>;
@@ -124,6 +131,14 @@ export const collectionUpdateRequestSchema = collectionCreateRequestSchema.parti
});
export type CollectionUpdateRequest = z.infer<typeof collectionUpdateRequestSchema>;
/** Reorder the caller's lists — every visible collection id in the desired order.
* Plain z.number() (no .min/.int) mirrors the legacy hand-rolled check the DTO
* ratchet replaced: any array of numbers, empty included. */
export const collectionReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type CollectionReorderRequest = z.infer<typeof collectionReorderRequestSchema>;
/** Save a place into a list from a raw maps/manual payload (or carrying provenance). */
export const collectionSavePlaceRequestSchema = z.object({
collection_id: z.number(),
@@ -138,11 +153,11 @@ export const collectionSavePlaceRequestSchema = z.object({
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
image_url: z.string().nullable().optional(),
image_url: placeImageUrlSchema.nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
website: z.string().nullable().optional(),
website: placeWebsiteSchema.nullable().optional(),
phone: z.string().nullable().optional(),
status: collectionStatusSchema.optional(),
links: collectionLinksSchema.optional(),
@@ -173,6 +188,13 @@ export const collectionPlaceUpdateRequestSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Editable coordinates so a place added by GPS can be corrected later (#1435).
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
// Free-text address, correctable after saving (#1870): the add dialog always
// offered it, the edit form did not. Deliberately uncapped, exactly like
// collectionSavePlaceRequestSchema.address, so save and update stay in step.
address: z.string().nullable().optional(),
// .removeDefault() strips the inner .default('idea') so an ABSENT status parses to
// undefined (left unchanged) instead of being injected as 'idea' (see #1437). The
// .catch('idea') guard against invalid values is preserved.
@@ -183,12 +205,46 @@ export const collectionPlaceUpdateRequestSchema = z.object({
tag_ids: z.array(z.number()).optional(),
// Replace the place's per-collection label assignments (omit to leave unchanged).
label_ids: z.array(z.number()).optional(),
// Custom thumbnail (#1136): null clears it (falls back to the auto-fetched photo).
image_url: placeImageUrlSchema.nullable().optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
export const collectionSetStatusRequestSchema = z.object({ status: collectionStatusSchema });
export type CollectionSetStatusRequest = z.infer<typeof collectionSetStatusRequestSchema>;
/** Set one status on several saved places at once — the place dialog's "mark
* visited everywhere it is saved" (#1469). Ids are collection_places ids. */
export const collectionSetStatusManyRequestSchema = z.object({
ids: z.array(z.number()).min(1).max(1000),
status: collectionStatusSchema,
});
export type CollectionSetStatusManyRequest = z.infer<typeof collectionSetStatusManyRequestSchema>;
/** Same, but naming the places by their TRIP ids: the server resolves each one to
* the lists it is saved in. Drives the planner's bulk "mark as visited" (#1469). */
export const collectionSetStatusFromTripRequestSchema = z.object({
trip_id: z.number(),
place_ids: z.array(z.number()).min(1).max(1000),
status: collectionStatusSchema,
});
export type CollectionSetStatusFromTripRequest = z.infer<typeof collectionSetStatusFromTripRequestSchema>;
export const collectionSetStatusManyResponseSchema = z.object({
/** Saved places whose status actually changed. */
updated: z.number(),
/** Trip places that were found in at least one list (from-trip form only). */
places: z.number().optional(),
});
export type CollectionSetStatusManyResponse = z.infer<typeof collectionSetStatusManyResponseSchema>;
/** Bulk-delete saved places. Plain z.number() (no .min/.int) mirrors the legacy
* hand-rolled check the DTO ratchet replaced (same shape as placeBulkDeleteRequestSchema). */
export const collectionDeleteManyRequestSchema = z.object({
ids: z.array(z.number()),
});
export type CollectionDeleteManyRequest = z.infer<typeof collectionDeleteManyRequestSchema>;
/** Copy one or many saved places INTO a trip (dedup precheck on server). */
export const collectionCopyToTripRequestSchema = z.object({
trip_id: z.number(),
@@ -287,6 +343,39 @@ export type CollectionSaveResult = z.infer<typeof collectionSaveResultSchema>;
/** Library-wide "is this place already saved anywhere I can see?" lookup (inspector indicator). */
export const collectionMembershipSchema = z.object({
saved: z.boolean(),
lists: z.array(z.object({ collection_id: z.number(), name: z.string(), place_id: z.number() })),
lists: z.array(z.object({
collection_id: z.number(),
name: z.string(),
place_id: z.number(),
/** Per-list status, so the picker can show and change it without a second round trip (#1469). */
status: collectionStatusSchema,
/** False for a list the viewer may read but not edit — its status pill stays read-only. */
can_edit: z.boolean().default(true),
})),
});
export type CollectionMembership = z.infer<typeof collectionMembershipSchema>;
/** One trip place as offered by the import preview. `already_in_list` is the SAME dedup
* verdict the bulk import applies (name, or coordinates within tolerance), resolved on the
* server so the dialog can never disagree with what the write path then does. `scheduled`
* is false for a place no day holds — the ones a trip left behind, which is exactly what
* the import is for, so the dialog pre-selects them. */
export const collectionImportablePlaceSchema = z.object({
place_id: z.number(),
name: z.string(),
address: z.string().nullable(),
lat: z.number().nullable(),
lng: z.number().nullable(),
category_id: z.number().nullable(),
image_url: z.string().nullable(),
already_in_list: z.boolean(),
scheduled: z.boolean(),
day_number: z.number().nullable(),
date: z.string().nullable(),
});
export type CollectionImportablePlace = z.infer<typeof collectionImportablePlaceSchema>;
export const collectionImportablesResponseSchema = z.object({
places: z.array(collectionImportablePlaceSchema),
});
export type CollectionImportablesResponse = z.infer<typeof collectionImportablesResponseSchema>;
+30 -2
View File
@@ -10,17 +10,40 @@ describe('dayCreateRequestSchema', () => {
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 250', () => {
it('requires non-empty text capped at 500, body capped at 2000', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
// Raised from 250 with #1629: the field carries formatted Markdown now.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(251),
time: 'y'.repeat(2001),
}).success,
).toBe(false);
});
it('takes a note colour, and only as a short string', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: '#dc2626' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: null }).success).toBe(true);
// The palette itself is enforced in the service — the contract only keeps
// the column from taking arbitrary length.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: 'x'.repeat(10) }).success).toBe(false);
});
it('accepts null time/icon (moveDayNote re-sends the nullable entity fields)', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
});
it('takes an icon as a label and not as a payload', () => {
// Both shapes the field is written in: a lucide export name from NOTE_ICONS
// and a plain emoji, plus the longest ZWJ sequence anyone types.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'ParkingSquare' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: '👩‍👩‍👧‍👦' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'x'.repeat(64) }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'x'.repeat(65) }).success).toBe(false);
});
});
describe('dayNoteUpdateRequestSchema', () => {
@@ -28,5 +51,10 @@ describe('dayNoteUpdateRequestSchema', () => {
expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(true);
expect(dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
expect(dayNoteUpdateRequestSchema.safeParse({ icon: 'x'.repeat(65) }).success).toBe(false);
});
it('accepts an explicit null time (clears the label)', () => {
expect(dayNoteUpdateRequestSchema.safeParse({ time: null }).success).toBe(true);
});
});
+33 -9
View File
@@ -6,11 +6,13 @@ import { z } from 'zod';
* Day + day-note API contract — single source of truth for the
* /api/trips/:tripId/days and /api/trips/:tripId/days/:dayId/notes endpoints.
*
* Trip-scoped, both gated by the 'day_edit' permission. The legacy routes
* (server/src/routes/days.ts + routes/dayNotes.ts) wrap dayService /
* dayNoteService. Day rows (with their assignments) are wide and DB-derived, so
* list responses stay open. Day notes cap text at 500 and time at 150 chars
* (the legacy validateStringLengths middleware) — reproduced in the controller.
* Trip-scoped, both gated by the 'day_edit' permission. Served by the Nest
* DaysController / DayNotesController (server/src/nest/days/). Day rows (with
* their assignments) are wide and DB-derived, so list responses stay open. Day
* note writes are capped field by field: text 500, time 2000, icon 64, color 9.
* Time is wider than the legacy validateStringLengths middleware's 150 because
* the raw-body route it replaced already accepted more, and narrowing it now
* would reject payloads today's client is allowed to send.
*/
/**
@@ -24,6 +26,8 @@ export const dayNoteSchema = z.object({
text: z.string(),
time: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
});
@@ -41,6 +45,8 @@ export const daySchema = z.object({
date: z.string().nullable().optional(),
title: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Whole-day default route mode (#1281); per-segment leg modes override it.
default_transport_mode: z.string().nullable().optional(),
assignments: z.array(assignmentSchema).optional(),
notes_items: z.array(dayNoteSchema).optional(),
});
@@ -66,18 +72,36 @@ export const dayUpdateRequestSchema = z.object({
});
export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
/**
* Whole-day default route mode (#1281). The client always sends
* `{ transport_mode: string | null }`; null clears the default. Kept loose (no
* enum, no max) — the legacy raw-body route accepted any string.
*/
export const dayTransportRequestSchema = z.object({
transport_mode: z.string().nullable().optional(),
});
export type DayTransportRequest = z.infer<typeof dayTransportRequestSchema>;
// `time`/`icon` accept null on the wire: the client's moveDayNote re-sends the
// entity fields (both nullable on dayNoteSchema) through create, and an explicit
// null time on update means "clear" (the legacy raw-body route accepted both).
export const dayNoteCreateRequestSchema = z.object({
text: z.string().min(1).max(500),
time: z.string().max(250).optional(),
icon: z.string().optional(),
time: z.string().max(2000).nullable().optional(),
// A lucide name from NOTE_ICONS or a single emoji; 64 leaves room for a ZWJ
// sequence with skin-tone modifiers and still keeps the column a label.
icon: z.string().max(64).nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
export const dayNoteUpdateRequestSchema = z.object({
text: z.string().max(500).optional(),
time: z.string().max(250).optional(),
icon: z.string().optional(),
time: z.string().max(2000).nullable().optional(),
icon: z.string().max(64).nullable().optional(),
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteUpdateRequest = z.infer<typeof dayNoteUpdateRequestSchema>;
+32
View File
@@ -0,0 +1,32 @@
/**
* Note colour palette (#1629) — shared so the picker, the note card and the PDF
* hand out the exact same set.
*
* These are drawn on UI surfaces rather than on map tiles, which is why they are
* not TRACK_COLORS: a note is rendered as a tint of its colour behind body text
* in both themes, so each entry has to stay legible at ~12% opacity on a white
* card and at ~18% on a dark one. The 500/600 tier does that; the 700s used for
* tracks go muddy once tinted.
*
* The set is small on purpose. A note colour is a label ("watch out", "must
* see", "booked"), and a palette of twenty makes people pick by hue instead of
* by meaning. Seven distinct hues, none of them adjacent, plus "no colour" as
* the default.
*/
export const NOTE_COLORS = [
'#dc2626', // red-600 — warnings
'#ea580c', // orange-600
'#d97706', // amber-600
'#16a34a', // green-600 — done, confirmed
'#0891b2', // cyan-600
'#2563eb', // blue-600 — information
'#9333ea', // purple-600
] as const;
export type NoteColor = (typeof NOTE_COLORS)[number];
/** True for a value the palette actually offers, or for "no colour at all". */
export function isNoteColor(value: unknown): boolean {
if (value === null || value === undefined || value === '') return true;
return typeof value === 'string' && (NOTE_COLORS as readonly string[]).includes(value);
}
+11
View File
@@ -14,6 +14,17 @@ import { z } from 'zod';
const nullableIdField = z.union([z.string(), z.number()]).nullable().optional();
/**
* Multipart text fields riding along with the upload — always strings on the
* wire (multipart/form-data has no other type), so no numeric coercion here.
*/
export const fileUploadRequestSchema = z.object({
place_id: z.string().optional(),
description: z.string().optional(),
reservation_id: z.string().optional(),
});
export type FileUploadRequest = z.infer<typeof fileUploadRequestSchema>;
export const fileUpdateRequestSchema = z.object({
description: z.string().optional(),
place_id: nullableIdField,
+36
View File
@@ -142,6 +142,9 @@ const admin: TranslationStrings = {
'admin.placesDetails.title': 'تفاصيل الأماكن',
'admin.placesDetails.subtitle':
'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
'admin.placesEnrich.title': 'إثراء الأماكن',
'admin.placesEnrich.subtitle':
'يعرض صورًا ووصفًا أثناء إضافة مكان. تُستخدم ويكيبيديا وOpenStreetMap دائمًا، وتُضاف Google عند تفعيل صور الأماكن أو تفاصيل الأماكن.',
'admin.bagTracking.title': 'تتبع الأمتعة',
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
'admin.collab.chat.title': 'الدردشة',
@@ -177,9 +180,12 @@ const admin: TranslationStrings = {
'admin.packingTemplates.loadError': 'فشل تحميل القوالب',
'admin.packingTemplates.createError': 'فشل إنشاء القالب',
'admin.packingTemplates.deleteError': 'فشل حذف القالب',
'admin.packingTemplates.deleteCategoryError': 'فشل حذف الفئة',
'admin.packingTemplates.deleteItemError': 'فشل حذف العنصر',
'admin.packingTemplates.saveError': 'فشل الحفظ',
'admin.tabs.addons': 'الإضافات',
'admin.tabs.plugins': 'الإضافات الخارجية',
'admin.tabs.storage': 'التخزين',
'admin.plugins.rescan': 'إعادة الفحص',
'admin.plugins.rescanned': 'تمت إعادة فحص مجلد الإضافات',
'admin.plugins.upload': 'رفع إضافة',
@@ -232,6 +238,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'تفعيل الإضافة',
'plugins.notFound': 'الإضافة غير موجودة',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'الإضافات الخارجية',
'admin.plugins.subtitle': 'ثبّت وأدر إضافات الجهات الخارجية على خادمك.',
'admin.plugins.disabledTitle': 'الإضافات الخارجية معطّلة',
@@ -325,6 +332,14 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:trip-warning-provider': 'إظهار تحذيرات التحقق على الرحلة (تظهر في المخطط)',
'admin.plugins.perm.hook:table-contributor': 'إضافة أعمدة وإجراءات إلى عروض الرحلة (الحجوزات، الأماكن، الأيام)',
'admin.plugins.perm.hook:map-marker-provider': 'إضافة علامات إلى خريطة الرحلة (مثل عرض الحجوزات أو نقاط الاهتمام)',
'admin.plugins.perm.hook:map-layer-provider': 'رسم مسارات وممرات ومناطق على خريطة الرحلة',
'admin.plugins.perm.hook:route-provider':
'توفير ملفات تعريف توجيه يحسب بها المخطط مسارات الأيام (مثل توجيه السيارات الكهربائية مع توقفات للشحن)',
'admin.plugins.perm.hook:day-schedule-provider': 'إلحاق مدخلات زمنية بخطة اليوم (توقفات للشحن، هوامش احتياطية)',
'admin.plugins.perm.hook:day-tint-provider':
'تلوين الأيام في خطة اليوم (مثلاً تحديد المرحلة التي ينتمي إليها كل يوم)',
'admin.plugins.perm.geolocation:read':
'الاستعلام عن موقعك المباشر ما دامت إحدى واجهاته مفتوحة (يقرأه TREK بإذن الموقع الممنوح لهذا الموقع الإلكتروني، لا بإذن خاص بالإضافة)',
'admin.plugins.perm.hook:pdf-section-provider': 'إضافة أقسام نصية إلى تصدير PDF للرحلة',
'admin.plugins.perm.hook:atlas-layer-provider':
'إبراز دول على خريطة أطلس العالمية (مثل قوائم الأمنيات أو تحذيرات السفر)',
@@ -413,6 +428,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'التحديثات أولًا',
'admin.plugins.sortDownloads': 'الأكثر تنزيلًا',
'admin.plugins.updatesAvailable': '{count} تحديثات متاحة لإضافاتك.',
'admin.plugins.newerNeedsTrek': 'الإصدار v{version} متوفر — يتطلب TREK {range}',
'admin.plugins.versionsTitle': 'الإصدارات',
'admin.plugins.versionPickerTitle': 'تغيير الإصدار — {name}',
'admin.plugins.versionSwitch': 'التبديل إلى {version}',
'admin.plugins.versionNeedsTrek': 'يتطلب TREK {range}',
'admin.plugins.changeVersion': 'تغيير الإصدار…',
'admin.plugins.noVersions': 'لم يتم العثور على إصدارات منشورة في السجل.',
'admin.plugins.downgradeTitle': 'التراجع عن هذه الإضافة؟',
'admin.plugins.downgradeBody': 'التبديل من v{from} إلى v{to}: تبقى البيانات المكتوبة بواسطة الإصدار الأحدث في مكانها، وقد لا يفهمها الإصدار الأقدم.',
'admin.plugins.downgradeConfirm': 'تراجع',
'admin.plugins.updatesHeld': 'التحديثات متوقفة عند v{version}',
'admin.plugins.resumeUpdates': 'استئناف التحديثات',
'admin.plugins.updatesResumed': 'تم استئناف التحديثات',
'admin.plugins.updateAll': 'تحديث الكل',
'admin.plugins.noMatchInstalled': 'لا توجد إضافات مثبّتة تطابق بحثك.',
'admin.plugins.noMatchRegistry': 'لا توجد إضافات في السجل تطابق بحثك.',
@@ -441,6 +469,11 @@ const admin: TranslationStrings = {
'admin.plugins.cap.calendar': 'يوفّر أحداث التقويم',
'admin.plugins.cap.placeDetails': 'يُثري الأماكن',
'admin.plugins.cap.warnings': 'يرصد المشكلات',
'admin.plugins.cap.mapLayers': 'يرسم على الخريطة',
'admin.plugins.cap.routing': 'يوفّر التوجيه',
'admin.plugins.cap.daySchedule': 'يضيف أوقات الخطة',
'admin.plugins.cap.dayTint': 'يلوّن الأيام',
'admin.plugins.cap.geolocation': 'يقرأ موقعك',
'admin.plugins.cap.events': 'يتفاعل مع النشاط',
'admin.plugins.cap.requiresAddon': 'يتطلب {addon}',
'admin.plugins.cap.dependsOn': 'يحتاج إلى {id} {version}',
@@ -636,6 +669,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
'admin.defaultSettings.mapboxQuality': 'وضع الجودة العالية',
'admin.defaultSettings.cartoKey': 'مفتاح CARTO المشترك',
'admin.defaultSettings.cartoKeyHint':
'يُستخدم لكل مستخدم لم يُدخل مفتاحه الخاص، حتى يحصل الخادم بأكمله على بلاطات CARTO بدون علامة مائية. يُخزَّن مشفّرًا.',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+8 -2
View File
@@ -15,11 +15,15 @@ const atlas: TranslationStrings = {
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.searchCountry': 'ابحث عن دولة...',
'atlas.searchPlaces': 'الأماكن',
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.removeFromBucket': 'إزالة من قائمة الأمنيات',
'atlas.removeFromBucketHint': 'إزالة هذا البلد من قائمة أمنياتك',
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'atlas.bucketDuplicate': 'موجود بالفعل في قائمة الأمنيات',
'atlas.statsTab': 'الإحصائيات',
'atlas.bucketTab': 'قائمة الأمنيات',
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
@@ -31,7 +35,6 @@ const atlas: TranslationStrings = {
'atlas.cities': 'المدن',
'atlas.noData': 'لا توجد بيانات سفر بعد',
'atlas.noDataHint': 'أنشئ رحلة وأضف أماكن لرؤية خريطتك العالمية',
'atlas.lastTrip': 'آخر رحلة',
'atlas.nextTrip': 'الرحلة القادمة',
'atlas.daysLeft': 'يوم متبقٍ',
'atlas.streak': 'سلسلة',
@@ -49,10 +52,13 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'أوقيانوسيا',
'atlas.other': 'أخرى',
'atlas.firstVisit': 'أول رحلة',
'atlas.lastVisitLabel': 'آخر رحلة',
'atlas.tripSingular': 'رحلة',
'atlas.tripPlural': 'رحلات',
'atlas.placeVisited': 'مكان تمت زيارته',
'atlas.placesVisited': 'أماكن تمت زيارتها',
'atlas.planned': 'مُخطط',
'atlas.showPlanned': 'إظهار الدول المخطط زيارتها',
'atlas.plannedFor': 'مخطط لـ',
'atlas.antarctica': 'أنتاركتيكا',
};
export default atlas;
+17
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const budget: TranslationStrings = {
'costs.confirm.deleteTitle': 'حذف المصروف؟',
'costs.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
'budget.title': 'الميزانية',
'budget.exportCsv': 'تصدير CSV',
'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد',
@@ -87,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'تراجع',
'costs.whatFor': 'لأجل ماذا كان؟',
'costs.namePlaceholder': 'مثل: عشاء، هدايا تذكارية، وقود…',
'costs.note': 'ملاحظة',
'costs.notePlaceholder': 'ما الذي شملته، ومن يدين بماذا…',
'costs.addNote': 'إضافة ملاحظة',
'costs.showNote': 'عرض الملاحظة',
'costs.hideNote': 'إخفاء الملاحظة',
'costs.totalAmount': 'المبلغ الإجمالي',
'costs.currency': 'العملة',
'costs.day': 'اليوم',
@@ -110,6 +117,8 @@ const budget: TranslationStrings = {
'costs.cat.fees': 'الرسوم والتذاكر',
'costs.cat.health': 'الصحة',
'costs.cat.tips': 'البقشيش',
'costs.cat.fuel': 'وقود',
'costs.cat.parking': 'موقف سيارات',
'costs.cat.other': 'أخرى',
'costs.daysCount': '{count} أيام',
'costs.travelers': '{count} مسافرين',
@@ -129,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'كل الفئات',
'costs.filter.allDays': 'كل الأيام',
'costs.expensesCount': '{count} مصروف',
'costs.ticketItemName': 'اسم العنصر',
'costs.ticketSplitting': 'التقسيم:',
'costs.ticketAddItem': 'إضافة عنصر',
'costs.ticketShares': 'حصة كل شخص',
'costs.excluded': 'مستبعد',
'costs.splitBalanced': 'التقسيم يطابق الإجمالي',
'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})',
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
};
export default budget;
+10
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types';
const collab: TranslationStrings = {
'collab.chat.readOnly': 'وصول للقراءة فقط لهذه المحادثة',
'collab.chat.messageOptions': 'خيارات الرسالة',
'collab.polls.confirmDeleteTitle': 'حذف الاستطلاع؟',
'collab.polls.confirmDeleteBody': 'سيتم حذف هذا الاستطلاع نهائيًا.',
'collab.polls.countdownDaysHours': 'متبقٍ {d} يوم {h} س',
'collab.polls.countdownHoursMinutes': 'متبقٍ {h} س {m} د',
'collab.polls.countdownMinutes': 'متبقٍ {m} د',
'collab.polls.clearDeadline': 'إزالة الموعد النهائي',
'collab.tabs.chat': 'الدردشة',
'collab.tabs.notes': 'الملاحظات',
'collab.tabs.polls': 'الاستطلاعات',
@@ -29,6 +37,8 @@ const collab: TranslationStrings = {
'collab.notes.emptyHint': 'ابدأ بتسجيل الأفكار والخطط',
'collab.notes.all': 'الكل',
'collab.notes.titlePlaceholder': 'عنوان الملاحظة',
'collab.notes.content': 'المحتوى',
'collab.notes.removeFile': 'إزالة {name}',
'collab.notes.contentPlaceholder': 'اكتب شيئًا...',
'collab.notes.categoryPlaceholder': 'الفئة',
'collab.notes.newCategory': 'فئة جديدة...',
+32
View File
@@ -36,6 +36,29 @@ const collection: TranslationStrings = {
'collections.addPlace': 'إضافة مكان',
'collections.addPlaceSearch': 'ابحث عن مكان…',
'collections.addPlaceHint': 'ابحث لإضافة مكان إلى هذه القائمة',
'collections.importFromTrip': 'استيراد من رحلة',
'collections.importTitle': 'استيراد من رحلة',
'collections.importDoneTitle': 'اكتمل الاستيراد',
'collections.importPickTripHint': 'اختر رحلة وانقل أماكنها إلى {name}.',
'collections.importNoTrips': 'لا توجد رحلات بعد',
'collections.importPlacesCount': '{count} أماكن',
'collections.importSearchPlaces': 'البحث عن أماكن…',
'collections.importOnlyNew': 'الجديد فقط',
'collections.importSelectAll': 'تحديد الكل',
'collections.importClearSelection': 'إلغاء التحديد',
'collections.importAlreadySaved': 'محفوظ بالفعل',
'collections.importUnscheduled': 'بدون يوم',
'collections.importOnDay': 'اليوم {count}',
'collections.importSelectedCount': 'تم تحديد {count}',
'collections.importAction': 'استيراد {count}',
'collections.importEmptyTrip': 'لا تحتوي هذه الرحلة على أماكن بعد',
'collections.importNothingNew': 'كل شيء من هذه الرحلة موجود بالفعل في القائمة',
'collections.importDone': 'تمت إضافة {count} أماكن',
'collections.importDoneSkipped': '{count} كانت موجودة بالفعل وبقيت كما هي',
'collections.importDoneClean': 'جميعها الآن في {name}',
'collections.sort.default': 'افتراضي',
'collections.sort.nameAsc': 'الاسم (أ–ي)',
'collections.coordinates': 'الإحداثيات',
'collections.showList': 'إظهار القائمة',
'collections.expandMap': 'توسيع الخريطة',
'collections.hero.mine': 'مجموعتي',
@@ -74,10 +97,18 @@ const collection: TranslationStrings = {
'collections.status.filterAll': 'الكل',
'collections.status.cycleHint': 'انقر للتغيير',
'collections.markVisited': 'وضع علامة كمَزار',
'collections.markVisitedAll': 'مَزار في كل القوائم',
'collections.markVisitedSelection': 'وضع علامة كمَزار في قوائمك',
'collections.markVisitedNone': 'لا يوجد أي من هذه الأماكن في أي قائمة',
'collections.markedVisited': 'تم وضع علامة كمَزار',
'collections.markedVisitedTrip': 'تم وضع علامة على {count} مكان كمَزار',
'collections.copyToTrip': 'نسخ إلى رحلة',
'collections.copyToTripTitle': 'نسخ إلى رحلة',
'collections.copyN': 'نسخ {count} إلى رحلة',
'collections.copyToTripSearch': 'البحث عن الرحلات',
'collections.searchLists': 'بحث في القوائم',
'collections.copyToTripConfirm': 'نسخ',
'collections.noTrips': 'لا توجد رحلات بعد',
'collections.alreadyInTrip': 'موجود بالفعل في هذه الرحلة',
@@ -107,6 +138,7 @@ const collection: TranslationStrings = {
'collections.noListsYet': 'أنشئ قائمة أولًا لحفظ الأماكن.',
'collections.removedFromAll': 'تمت الإزالة من المجموعة',
'collections.picker.title': 'الأماكن المحفوظة',
'collections.picker.showMore': 'عرض {count} إضافية',
'collections.picker.search': 'ابحث في أماكنك المحفوظة',
'collections.picker.empty': 'لا توجد أماكن محفوظة لإضافتها',
'collections.picker.hint': 'اختر مكانًا من مجموعاتك',
+10
View File
@@ -16,6 +16,7 @@ const common: TranslationStrings = {
'common.selectAll': 'تحديد الكل',
'common.deselectAll': 'إلغاء تحديد الكل',
'common.error': 'خطأ',
'common.success': 'تم بنجاح',
'common.unknownError': 'خطأ غير معروف',
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
'common.back': 'رجوع',
@@ -63,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
'common.errorTitle': 'حدث خطأ ما',
'common.errorBody': 'توقف هذا الجزء من التطبيق عن العمل. بياناتك آمنة.',
'common.errorPanelTitle': 'تعذّر عرض هذا القسم',
'common.errorPanelBody': 'بقية الصفحة لا تزال تعمل.',
'common.errorRetry': 'إعادة المحاولة',
'common.errorReload': 'إعادة تحميل الصفحة',
'common.errorUpdateTitle': 'يتوفر إصدار جديد',
'common.errorUpdateBody': 'تم تحديث TREK أثناء فتح علامة التبويب هذه. أعد التحميل للحصول على الإصدار الجديد.',
'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة',
};
export default common;
+9 -1
View File
@@ -10,6 +10,9 @@ const dashboard: TranslationStrings = {
'dashboard.subtitle.archivedSuffix': ' · {count} مؤرشفة',
'dashboard.newTrip': 'رحلة جديدة',
'dashboard.newTripSub': 'خطّط لرحلة جديدة من الصفر',
'dashboard.subscribeAllTrips': 'الاشتراك في جميع الرحلات',
'dashboard.subscribeAllTripsDesc':
'خلاصة تقويم واحدة لكل رحلاتك النشطة، تبقى متزامنة تلقائيًا. لا تشمل الرحلات المؤرشفة ولا الرحلات التي انتهت قبل أكثر من 90 يومًا.',
'dashboard.gridView': 'عرض شبكي',
'dashboard.listView': 'عرض قائمة',
'dashboard.currency': 'العملة',
@@ -87,7 +90,8 @@ const dashboard: TranslationStrings = {
'dashboard.dateShiftTitle': 'تاريخ بداية جديد',
'dashboard.dateShiftIntro': 'لقد غيّرت موعد بداية هذه الرحلة. كيف تريد أن تتبع خططك التواريخ الجديدة؟',
'dashboard.dateShiftKeepBookings': 'إبقاء الحجوزات في تواريخها',
'dashboard.dateShiftKeepBookingsDesc': 'تنتقل خطط الأيام مع التواريخ الجديدة، بينما تبقى الحجوزات وأماكن الإقامة في تواريخها الأصلية ما دامت تلك التواريخ لا تزال ضمن الرحلة.',
'dashboard.dateShiftKeepBookingsDesc':
'تنتقل خطط الأيام مع التواريخ الجديدة، بينما تبقى الحجوزات وأماكن الإقامة في تواريخها الأصلية ما دامت تلك التواريخ لا تزال ضمن الرحلة.',
'dashboard.dateShiftAll': 'نقل كل شيء',
'dashboard.dateShiftAllDesc': 'ينتقل خط سير الرحلة بالكامل مع التواريخ الجديدة، بما في ذلك الحجوزات وأماكن الإقامة.',
'dashboard.dateShiftHint': 'نصيحة: لنقل جزء من خط سير الرحلة فقط، استخدم "إضافة يوم" في مخطط الرحلة بدلاً من ذلك.',
@@ -152,6 +156,7 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.places': 'الأماكن',
'dashboard.mobile.buddies': 'الرفاق',
'dashboard.mobile.newTrip': 'رحلة جديدة',
'dashboard.mobile.addCoverImage': 'إضافة صورة غلاف',
'dashboard.mobile.currency': 'العملة',
'dashboard.mobile.timezone': 'المنطقة الزمنية',
'dashboard.mobile.upcomingTrips': 'الرحلات القادمة',
@@ -166,6 +171,9 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.tomorrow': 'غدًا',
'dashboard.mobile.inDays': 'خلال {count} يوم',
'dashboard.mobile.inMonths': 'خلال {count} شهر',
'dashboard.mobile.spotlightDayOf': 'اليوم {day} من {total}',
'dashboard.mobile.spotlightDayOne': '{count} يوم',
'dashboard.mobile.spotlightDaysMany': '{count} أيام',
'dashboard.mobile.completed': 'مكتملة',
'dashboard.mobile.currencyConverter': 'محوّل العملات',
'dashboard.aria.toggleView': 'تبديل العرض',
+37
View File
@@ -15,14 +15,40 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'تعديل الملاحظة',
'dayplan.noteAdd': 'إضافة ملاحظة',
'dayplan.noteEdit': 'تعديل الملاحظة',
'dayplan.noteDelete': 'حذف الملاحظة',
'dayplan.noteTitle': 'ملاحظة',
'dayplan.noteSubtitle': 'ملاحظة يومية',
'dayplan.noteIcon': 'أيقونة',
'notes.appearance': 'المظهر',
'notes.preview': 'معاينة',
'notes.bodyPlaceholder': 'تفاصيل وروابط وتذكيرات…',
'notes.markdownHint': 'يدعم Markdown',
'notes.format.toolbar': 'التنسيق',
'notes.format.bold': 'غامق',
'notes.format.italic': 'مائل',
'notes.format.strike': 'يتوسطه خط',
'notes.format.code': 'كود',
'notes.format.link': 'رابط',
'notes.format.list': 'قائمة نقطية',
'notes.format.orderedList': 'قائمة مرقمة',
'notes.format.quote': 'اقتباس',
'notes.color.label': 'اللون',
'notes.color.none': 'بلا لون',
'notes.color.red': 'أحمر',
'notes.color.orange': 'برتقالي',
'notes.color.amber': 'كهرماني',
'notes.color.green': 'أخضر',
'notes.color.cyan': 'سماوي',
'notes.color.blue': 'أزرق',
'notes.color.purple': 'بنفسجي',
'dayplan.totalCost': 'إجمالي التكلفة',
'dayplan.days': 'الأيام',
'dayplan.dayN': 'اليوم {n}',
'dayplan.calculating': 'جارٍ الحساب...',
'dayplan.route': 'المسار',
'dayplan.optimize': 'تحسين',
'dayplan.transportMode.change': 'تغيير وسيلة النقل',
'dayplan.transportMode.useDefault': 'استخدام إعداد اليوم الافتراضي',
'dayplan.optimized': 'تم تحسين المسار',
'dayplan.routeError': 'فشل حساب المسار',
'dayplan.toast.needTwoPlaces': 'يلزم مكانان على الأقل لتحسين المسار',
@@ -32,9 +58,20 @@ const dayplan: TranslationStrings = {
'dayplan.confirmed': 'مؤكد',
'dayplan.pendingRes': 'قيد الانتظار',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.gpxTooltip': 'تصدير كملف GPX للخرائط دون اتصال وأجهزة GPS',
'dayplan.gpxAll': 'الرحلة كاملة',
'dayplan.gpxPlaces': 'الأماكن فقط',
'dayplan.gpxDays': 'الأيام كمسارات',
'dayplan.gpxEmpty': 'لا يوجد شيء للتصدير بعد',
'dayplan.gpxFailed': 'فشل تصدير GPX',
'dayplan.pdfError': 'فشل تصدير PDF',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
'dayplan.export': 'تصدير',
'dayplan.exportIntro': 'خذ خطتك معك: كمستند أو في تقويمك أو على جهاز GPS.',
'dayplan.exportDocument': 'مستند',
'dayplan.exportCalendar': 'التقويم',
'dayplan.exportMaps': 'الخرائط وGPS',
'dayplan.pdf': 'PDF', // en-fallback
'dayplan.mobile.addPlace': 'Add Place', // en-fallback
'dayplan.mobile.searchPlaces': 'Search places...', // en-fallback
@@ -28,6 +28,10 @@ const ar: NotificationLocale = {
title: 'دعوة دمج الإجازة',
body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.`,
}),
vacay_share: (p) => ({
title: 'تمت مشاركة تقويم Vacay',
body: `${p.actor} شارك تقويم إجازاته معك. افتح TREK لعرضه.`,
}),
collection_invite: (p) => ({
title: 'دعوة إلى مجموعة',
body: `${p.actor} يدعوك لمشاركة مجموعة. افتح TREK للقبول أو الرفض.`,
@@ -48,6 +52,12 @@ const ar: NotificationLocale = {
title: 'إصدار TREK جديد متاح',
body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.`,
}),
replica_failure: (p) => ({
title: 'فشل النسخة المتماثلة للتخزين',
body:
`فشلت الكتابة على النسخة المتماثلة '${p.backend}': ${p.op} لـ ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` تم تجاهل ${p.suppressed} فشل إضافي منذ آخر إشعار.` : ''),
}),
synology_session_cleared: () => ({
title: 'تمت إعادة تعيين جلسة Synology',
body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.',
+6
View File
@@ -1,6 +1,12 @@
import type { TranslationStrings } from '../types';
const files: TranslationStrings = {
'files.filterStarred': 'المميزة',
'files.link': 'ربط',
'files.linkTitle': 'ربط الملف',
'files.linkEmpty': 'لا توجد أماكن أو حجوزات للربط بعد',
'files.menu': 'المزيد من الخيارات',
'files.uploadErrorSize': 'الملف كبير جدًا (الحد الأقصى 50 ميغابايت)',
'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}',
+18
View File
@@ -18,6 +18,14 @@ import login from './login';
import map from './map';
import members from './members';
import memories from './memories';
import mobileAdmin from './mobileAdmin';
import mobileAtlas from './mobileAtlas';
import mobileCollections from './mobileCollections';
import mobileJourney from './mobileJourney';
import mobileNav from './mobileNav';
import mobileSettings from './mobileSettings';
import mobileTrip from './mobileTrip';
import mobileVacay from './mobileVacay';
import nav from './nav';
import notif from './notif';
import notifications from './notifications';
@@ -34,6 +42,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import stats from './stats';
import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import transport from './transport';
@@ -86,5 +95,14 @@ const locale = {
...system_notice,
...transport,
...help,
...mobileTrip,
...mobileJourney,
...mobileVacay,
...mobileAtlas,
...mobileNav,
...mobileAdmin,
...mobileSettings,
...mobileCollections,
...storage,
};
export default locale;
+5
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار',
'inspector.google': 'Google Maps',
'inspector.navigation': 'التنقل',
'inspector.openWith': 'الفتح باستخدام',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.website': 'فتح الموقع الإلكتروني',
'inspector.saveToCollection': 'حفظ في مجموعة',
@@ -21,5 +23,8 @@ const inspector: TranslationStrings = {
'inspector.editRes': 'تعديل الحجز',
'inspector.participants': 'المشاركون',
'inspector.trackStats': 'بيانات المسار',
'inspector.trackColor': 'لون المسار',
'inspector.trackColorAuto': 'لون تلقائي',
'inspector.trackColorCustom': 'اختيار لون مخصص',
};
export default inspector;
+365
View File
@@ -20,6 +20,11 @@ const journey: TranslationStrings = {
'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول',
'journey.editor.searching': 'جارٍ البحث...',
'journey.editor.useCurrentLocation': 'استخدام موقعي الحالي',
'journey.editor.locationPermissionDenied': 'تم رفض الوصول إلى الموقع. اسمح به في إعدادات المتصفح وحاول مرة أخرى.',
'journey.editor.locationTimeout': 'انتهت مهلة تحديد موقعك. حاول مرة أخرى.',
'journey.editor.locationUnavailable': 'تعذر تحديد موقعك.',
'journey.editor.locationInsecureContext': 'يتطلب تحديد الموقع اتصالاً آمناً (HTTPS).',
'journey.share.copy': 'نسخ',
'journey.share.copied': 'تم النسخ!',
'journey.invite.role': 'الدور',
@@ -99,6 +104,7 @@ const journey: TranslationStrings = {
'journey.frontpage.subtitle': "Turn your trips into stories you'll never forget", // en-fallback
'journey.frontpage.createJourney': 'Create Journey', // en-fallback
'journey.frontpage.activeJourney': 'Active Journey', // en-fallback
'journey.frontpage.latestJourney': 'أحدث رحلة',
'journey.frontpage.allJourneys': 'All Journeys', // en-fallback
'journey.frontpage.journeys': 'journeys', // en-fallback
'journey.frontpage.createNew': 'Create a new Journey', // en-fallback
@@ -119,6 +125,8 @@ const journey: TranslationStrings = {
'journey.frontpage.places': 'places', // en-fallback
'journey.detail.syncedWithTrips': 'Synced with Trips', // en-fallback
'journey.detail.addEntry': 'Add Entry', // en-fallback
'journey.detail.jumpToTop': 'العودة إلى الأعلى',
'journey.detail.jumpToLast': 'الانتقال إلى آخر مدخل',
'journey.detail.newEntry': 'New Entry', // en-fallback
'journey.detail.editEntry': 'Edit Entry', // en-fallback
'journey.detail.noEntries': 'No entries yet', // en-fallback
@@ -227,5 +235,362 @@ const journey: TranslationStrings = {
'journey.pdf.theEnd': 'The End', // en-fallback
'journey.pdf.saveAsPdf': 'Save as PDF', // en-fallback
'journey.pdf.pages': 'pages', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
'journey.studio.title': 'TREK Studio', // en-fallback
'journey.studio.open': 'Studio', // en-fallback
'journey.studio.openAria': 'Open the photo book studio', // en-fallback
'journey.studio.backToJourney': 'Back to the journey', // en-fallback
'journey.studio.format': 'Page format', // en-fallback
'journey.studio.formatA4Landscape': 'A4 landscape', // en-fallback
'journey.studio.formatA4Portrait': 'A4 portrait', // en-fallback
'journey.studio.formatSquare21': 'Square 21 × 21 cm', // en-fallback
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
'journey.studio.pages': 'Pages', // en-fallback
'journey.studio.cover': 'Cover', // en-fallback
'journey.studio.inspector': 'Properties', // en-fallback
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
'journey.studio.autoLayout': 'Auto layout', // en-fallback
'journey.studio.export': 'Export', // en-fallback
'journey.studio.day': 'اليوم',
'journey.studio.stations': 'المحطات',
'journey.studio.peersHere': 'هنا',
'journey.studio.folioAuto': 'تلقائي',
'journey.studio.exportLayout': 'التخطيط',
'journey.studio.exportPages': 'صفحات مفردة',
'journey.studio.exportPagesHint': 'ورقة واحدة لكل صفحة، بترتيب القراءة. هذا ما تطلبه المطبعة.',
'journey.studio.exportSpreads': 'صفحات مزدوجة',
'journey.studio.exportSpreadsHint': 'صفحتان معًا، كما يُفتح الكتاب. للقراءة.',
'journey.studio.exportFinishing': 'التشطيب',
'journey.studio.exportMarks': 'علامات القص',
'journey.studio.exportMarksHint': 'يضيف {bleed} مم من الفيض عند كل حافة ويحدد موضع القص',
'journey.studio.exportNote': '{sheets} ورقة بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
'journey.studio.exportOpen': 'معاينة الطباعة',
'journey.studio.exportSave': 'حفظ بصيغة PDF',
'journey.studio.exportPreparing': 'جارٍ التحضير',
'journey.studio.exportSheetCount': '{count} ورقة',
'journey.studio.undo': 'Undo', // en-fallback
'journey.studio.redo': 'Redo', // en-fallback
'journey.studio.zoomIn': 'Zoom in', // en-fallback
'journey.studio.zoomOut': 'Zoom out', // en-fallback
'journey.studio.zoomFit': 'Fit to view', // en-fallback
'journey.studio.downloadSpread': 'تنزيل هذه الصفحة المزدوجة',
'journey.studio.downloadSpreadHint': 'يحفظ تصميم هذه الصفحة كملف، بدون الصور، لمشاركته أو إعادة استخدامه',
'journey.studio.importSpread': 'استيراد',
'journey.studio.importSpreadHint': 'إضافة صفحة مزدوجة من ملف تصميم منزّل',
'journey.studio.importSpreadFailed': 'هذا الملف ليس صفحة من TREK Studio',
'journey.studio.desktopOnly': 'Studio needs a bigger screen', // en-fallback
'journey.studio.desktopOnlyHint': 'تصميم كتاب يحتاج مساحة للعمل، لذلك يعمل الاستوديو على سطح المكتب فقط، وكذلك إنشاء ملف PDF. كل شيء آخر في رحلتك يعمل هنا كالمعتاد.', // en-fallback
'journey.studio.formatA5Landscape': 'A5 landscape', // en-fallback
'journey.studio.bookView': 'Book view', // en-fallback
'journey.studio.multiple': 'Several', // en-fallback
'journey.studio.kind.photo': 'Photo', // en-fallback
'journey.studio.kind.text': 'Text', // en-fallback
'journey.studio.kind.shape': 'Shape', // en-fallback
'journey.studio.position': 'Position', // en-fallback
'journey.studio.width': 'W', // en-fallback
'journey.studio.height': 'H', // en-fallback
'journey.studio.text': 'Text', // en-fallback
'journey.studio.typography': 'Type', // en-fallback
'journey.studio.leading': 'Line', // en-fallback
'journey.studio.colour': 'Colour', // en-fallback
'journey.studio.autoColour': 'تلقائي',
'journey.studio.countryNames': 'الأسماء',
'journey.studio.crop': 'Crop', // en-fallback
'journey.studio.look': 'Look', // en-fallback
'journey.studio.radius': 'Corner', // en-fallback
'journey.studio.shape': 'Shape', // en-fallback
'journey.studio.arrange': 'Arrange', // en-fallback
'journey.studio.toFront': 'Bring to front', // en-fallback
'journey.studio.forward': 'Bring forward', // en-fallback
'journey.studio.backward': 'Send backward', // en-fallback
'journey.studio.toBack': 'Send to back', // en-fallback
'journey.studio.lock': 'Lock', // en-fallback
'journey.studio.unlock': 'Unlock', // en-fallback
'journey.studio.delete': 'Delete', // en-fallback
'journey.studio.pageHint': 'Page', // en-fallback
'journey.studio.boundHint': 'Follows the journal entry. Editing it here breaks that link.', // en-fallback
'journey.studio.fit.cover': 'Fill', // en-fallback
'journey.studio.fit.contain': 'Fit', // en-fallback
'journey.studio.filter.none': 'Original', // en-fallback
'journey.studio.filter.bw': 'Black & white', // en-fallback
'journey.studio.filter.warm': 'Warm', // en-fallback
'journey.studio.shapeKind.rect': 'Rectangle', // en-fallback
'journey.studio.shapeKind.ellipse': 'Ellipse', // en-fallback
'journey.studio.focalHint': 'Drag the point to choose what stays in frame.', // en-fallback
'journey.studio.backCover': 'Back cover', // en-fallback
'journey.studio.sections': 'Sections', // en-fallback
'journey.studio.content': 'Content', // en-fallback
'journey.studio.elements': 'Elements', // en-fallback
'journey.studio.templates': 'Layouts', // en-fallback
'journey.studio.photos': 'Photos', // en-fallback
'journey.studio.entries': 'Entries', // en-fallback
'journey.studio.addToPage': 'Add to this page', // en-fallback
'journey.studio.noPhotos': 'This journey has no photos yet.', // en-fallback
'journey.studio.untitled': 'Untitled', // en-fallback
'journey.studio.addTitle': 'Title', // en-fallback
'journey.studio.addStory': 'Story', // en-fallback
'journey.studio.addPlace': 'Place', // en-fallback
'journey.studio.shapes': 'Shapes', // en-fallback
'journey.studio.frames': 'إطارات', // en-fallback
'journey.studio.emptyFrame': 'Empty frame', // en-fallback
'journey.studio.frameHint': 'An empty frame marks where a picture goes. Drop one on it from Content.', // en-fallback
'journey.studio.shapeKind.line': 'Line', // en-fallback
'journey.studio.styleTitle': 'Heading', // en-fallback
'journey.studio.styleSubtitle': 'Subheading', // en-fallback
'journey.studio.styleBody': 'Body text', // en-fallback
'journey.studio.styleCaption': 'Caption', // en-fallback
'journey.studio.sampleHeading': 'A heading', // en-fallback
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
'journey.studio.sampleCaption': 'Caption', // en-fallback
'journey.studio.templatesCoverHint': 'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
'journey.studio.tpl.grid4': 'Four up', // en-fallback
'journey.studio.tpl.grid6': 'Six up', // en-fallback
'journey.studio.tpl.strip': 'Strip and text', // en-fallback
'journey.studio.tpl.quietText': 'Text only', // en-fallback
'journey.studio.tpl.portraitPair': 'A pair', // en-fallback
'journey.studio.dropPhotoHere': 'اسحب صورتك\nوأفلتها هنا',
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
'journey.studio.decorations': 'Decoration', // en-fallback
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
'journey.studio.travel': 'الرحلة',
'journey.studio.travelEmpty': 'أرقام هذه الرحلة ليست جاهزة بعد.',
'journey.studio.grids': 'الشبكات',
'journey.studio.gridHint': 'تضع الشبكة مجموعة من الإطارات الفارغة. اسحب الصور إليها من المحتوى.',
'journey.studio.lines': 'الخطوط',
'journey.studio.frameStyles': 'أنماط الإطار',
'journey.studio.frameShapes': 'أشكال الإطار',
'journey.studio.plainFrame': 'بسيط',
'journey.studio.polaroidFrame': 'بولارويد',
'journey.studio.whiteFrame': 'حافة بيضاء',
'journey.studio.shadowFrame': 'ظل',
'journey.studio.filmFrame': 'فيلم',
'journey.studio.tapeFrame': 'بشريط لاصق',
'journey.studio.shapeGroup.basic': 'أساسية',
'journey.studio.shapeGroup.polygons': 'مضلعات',
'journey.studio.shapeGroup.stars': 'نجوم',
'journey.studio.shapeGroup.arrows': 'أسهم',
'journey.studio.shapeGroup.speech': 'فقاعات كلام',
'journey.studio.shapeGroup.travel': 'سفر',
'journey.studio.shapeGroup.decor': 'زخرفة',
'journey.studio.shapeGroup.banners': 'لافتات',
'journey.studio.summary': 'الملخص',
'journey.studio.tripSummary': 'ملخص الرحلة',
'journey.studio.statsRow': 'صف واحد',
'journey.studio.statsFull': 'كل شيء',
'journey.studio.routeMap': 'خريطة المسار',
'journey.studio.mapStyle.minimal': 'بسيط',
'journey.studio.mapStyle.outline': 'مخطط',
'journey.studio.mapStyle.paper': 'ورقي',
'journey.studio.mapStyle.dark': 'داكن',
'journey.studio.countries': 'الدول',
'journey.studio.countryList': 'قائمة الدول',
'journey.studio.countryGrid': 'شبكة الدول',
'journey.studio.noCountries': 'لم تُحدَّد دول لهذه الرحلة بعد.',
'journey.studio.noRoute': 'لا توجد محطات بإحداثيات بعد.',
'journey.studio.marks': 'العلامات',
'journey.studio.dateMark': 'التاريخ',
'journey.studio.dayMark': 'عدّاد الأيام',
'journey.studio.dayWord': 'اليوم',
'journey.studio.coordsMark': 'الإحداثيات',
'journey.studio.coordsDms': 'درجات',
'journey.studio.coordsDecimal': 'عشرية',
'journey.studio.flagMark': 'العلم',
'journey.studio.distanceMark': 'المسافة',
'journey.studio.metric.distance': 'المسافة',
'journey.studio.metric.days': 'الأيام',
'journey.studio.metric.steps': 'المحطات',
'journey.studio.metric.photos': 'الصور',
'journey.studio.metric.countries': 'الدول',
'journey.studio.metric.places': 'الأماكن',
'journey.studio.metric.furthest': 'الأبعد',
'journey.studio.kind.map': 'خريطة',
'journey.studio.kind.stats': 'أرقام',
'journey.studio.kind.countries': 'دول',
'journey.studio.kind.badge': 'علامة',
'journey.studio.kind.list': 'قائمة',
'journey.studio.kind.icon': 'أيقونة',
'journey.studio.duplicate': 'تكرار',
'journey.studio.style': 'النمط',
'journey.studio.shows': 'المعروض',
'journey.studio.size': 'الحجم',
'journey.studio.weight': 'الوزن',
'journey.studio.italic': 'مائل',
'journey.studio.tracking': 'تباعد الأحرف',
'journey.studio.rotation': 'التدوير',
'journey.studio.opacity': 'العتامة',
'journey.studio.fill': 'التعبئة',
'journey.studio.fillOn': 'معبّأ',
'journey.studio.stroke': 'الإطار',
'journey.studio.strokeWidth': 'السماكة',
'journey.studio.gradient': 'التدرّج',
'journey.studio.gradientDown': 'لأسفل',
'journey.studio.gradientUp': 'لأعلى',
'journey.studio.showIcons': 'الأيقونات',
'journey.studio.mapFit': 'الملاءمة',
'journey.studio.mapPadding': 'الهامش',
'journey.studio.mapShape': 'الشكل',
'journey.studio.align.left': 'يسار',
'journey.studio.align.center': 'وسط',
'journey.studio.align.right': 'يمين',
'journey.studio.markStyle.plain': 'بسيط',
'journey.studio.markStyle.chip': 'شريحة',
'journey.studio.markStyle.outline': 'مفرّغ',
'journey.studio.markStyle.stacked': 'عمودي',
'journey.studio.icon': 'الأيقونة',
'journey.studio.iconAndLabel': 'أيقونة ونص',
'journey.studio.iconOnly': 'أيقونة فقط',
'journey.studio.labelOnly': 'نص فقط',
'journey.studio.icons': 'أيقونات',
'journey.studio.iconsForTravel': 'للسفر',
'journey.studio.iconsAll': 'كل الأيقونات',
'journey.studio.searchIcons': 'بحث عن أيقونات',
'journey.studio.lineWidth': 'السماكة',
'journey.studio.mask': 'قص حسب الشكل',
'journey.studio.maskNone': 'بدون',
'journey.studio.frameStyle': 'إطار',
'journey.studio.mapLayers': 'الطبقات',
'journey.studio.showLand': 'الدول',
'journey.studio.showRoute': 'المسار',
'journey.studio.showPins': 'المحطات',
'journey.studio.showLabels': 'التسميات',
'journey.studio.units': 'الوحدات',
'journey.studio.metrics': 'الأرقام',
'journey.studio.layout': 'التخطيط',
'journey.studio.layoutGrid': 'شبكة',
'journey.studio.layoutRow': 'صف',
'journey.studio.layoutColumn': 'عمود',
'journey.studio.layoutList': 'قائمة',
'journey.studio.showOutline': 'المخططات',
'journey.studio.showFlag': 'الأعلام',
'journey.studio.showName': 'الأسماء',
'journey.studio.textScale': 'حجم النص',
'journey.studio.accent': 'لون التمييز',
'journey.studio.refresh': 'تحديث من الرحلة',
'journey.studio.staleHint': 'تغيّرت الرحلة منذ أن أُخذت هذه الأرقام.',
'journey.studio.align': 'المحاذاة',
'journey.studio.filter.cool': 'بارد',
'journey.studio.filter.fade': 'باهت',
'journey.studio.filter.contrast': 'قوي',
'journey.studio.strokeStyle': 'الحد',
'journey.studio.strokeSolid': 'متصل',
'journey.studio.strokeDashed': 'متقطع',
'journey.studio.strokeDotted': 'منقّط',
'journey.studio.singleFigures': 'أرقام مفردة',
'journey.studio.addPage': 'إضافة صفحة',
'journey.studio.addPageAfter': 'إدراج صفحة بعد هذه',
'journey.studio.duplicatePage': 'تكرار الصفحة',
'journey.studio.deletePage': 'حذف الصفحة',
'journey.studio.movePageUp': 'نقل للأمام',
'journey.studio.movePageDown': 'نقل للخلف',
'journey.studio.beta': 'Beta',
'journey.studio.addProsCons': 'Pros & Cons',
'journey.studio.showMarks': 'علامات',
'journey.studio.formatCustom': 'مقاس مخصص',
'journey.studio.document': 'المستند',
'journey.studio.pageNumbers': 'أرقام الصفحات',
'journey.studio.pageNumbersOn': 'تشغيل',
'journey.studio.pageNumbersOff': 'إيقاف',
'journey.studio.folio.outer': 'خارجي',
'journey.studio.folio.inner': 'داخلي',
'journey.studio.folio.centre': 'وسط',
'journey.studio.folioStart': 'يبدأ من',
'journey.studio.folioMargin': 'الهامش',
'journey.studio.relayoutSpread': 'هذه الصفحة',
'journey.studio.relayoutSpreadHint': 'إعادة بنائها من مدخلها',
'journey.studio.relayoutSpreadNone': 'هذه الصفحة ليست من مدخل',
'journey.studio.relayoutBook': 'الكتاب كامل',
'journey.studio.relayoutBookHint': 'يستبدل كل الصفحات — يمكن التراجع',
'journey.studio.tpl.coverFull': 'بلا حواف',
'journey.studio.tpl.coverBand': 'صورة وشريط',
'journey.studio.tpl.coverWindow': 'بإطار',
'journey.studio.tpl.coverQuiet': 'نص فقط',
'journey.studio.tpl.coverHalf': 'نصفان',
'journey.studio.tpl.fullText': 'صورة وقصة',
'journey.studio.tpl.grid9': 'تسعة',
'journey.studio.tpl.mosaic': 'فسيفساء',
'journey.studio.tpl.bandQuote': 'كلمات في الوسط',
'journey.studio.tpl.staggerFour': 'أربع متدرجة',
'journey.studio.weightMissing': 'هذا الخط لا يوفر هذا الوزن',
'journey.studio.mapSource': 'مصدر الخريطة',
'journey.studio.mapSourceVector': 'المخططات',
'journey.studio.mapSourceRelief': 'تضاريس',
'journey.studio.mapSourceSatellite': 'قمر صناعي',
'journey.studio.mapSourceSatelliteHint': 'صور Sentinel-2 خالية من السحب، يمكن طباعتها بحرية مع ذكر المصدر. حادة حتى مستوى شارع في المدينة.',
'journey.studio.routeLook': 'الخط',
'journey.studio.routeStyle': 'الشكل',
'journey.studio.routePlain': 'عادي',
'journey.studio.routeDrawn': 'مرسوم',
'journey.studio.routeArc': 'المراحل الطويلة',
'journey.studio.routeStraight': 'مستقيمة',
'journey.studio.routeBow': 'مقوّسة',
'journey.studio.routeDashArcs': 'المقوّسة بخط متقطع',
'journey.studio.mapStops': 'المحطات',
'journey.studio.pinDot': 'نقاط',
'journey.studio.pinPhoto': 'صور',
'journey.studio.pinPhotoNone': 'لا توجد صور بعد على هذه المحطات، لذا تُرسم كنقاط.',
'journey.studio.roads': 'الطرق',
'journey.studio.roadsFetch': 'اتبع الطرق',
'journey.studio.roadsFollow': 'عبر الطرق',
'journey.studio.roadsDirect': 'خط مباشر',
'journey.studio.recommended': 'موصى به',
'journey.studio.bleed': 'الفيض',
'journey.studio.safeArea': 'الآمنة',
'journey.studio.roadsAgain': 'جلب من جديد',
'journey.studio.roadsClear': 'مسح',
'journey.studio.roadsBusy': 'جارٍ الجلب',
'journey.studio.roadsHint': 'اطلب من خدمة توجيه الطريق الذي قُطعت به كل مرحلة. المراحل الطويلة تبقى كما هي.',
'journey.studio.roadsHave': 'تُحفظ الطرق في هذا الكتاب، فيُطبع الخط نفسه دون اتصال.',
'journey.studio.mapSourceReliefHint': 'تضاريس ناسا المظللة، يمكن طباعتها بحرية. تناسب بلدًا أو قارة، وهي خشنة جدًا لمدينة واحدة.',
'journey.studio.mapPrintDpi': 'تُطبع بنحو',
'journey.studio.mapPrintDpiLow': 'غير حادة بهذا الحجم، جرّب عرضًا أوسع أو مصدرًا آخر',
'journey.studio.mapPerTrip': 'رحلة واحدة في كل مرة',
'journey.studio.mapWholeJourney': 'الرحلة كاملة',
'journey.studio.mapScope': 'المعروض',
'journey.studio.mapSourceTiles': 'بلاطات الخريطة',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'تُجلب عند العرض وتُطبع مع إسنادها',
'journey.studio.mapZoom': 'التقريب',
'journey.studio.mapFraming': 'العرض',
'journey.studio.mapFitStops': 'المحطات',
'journey.studio.mapFitCountry': 'البلد كامل',
'journey.studio.mapPadTight': 'ضيق',
'journey.studio.mapPadNormal': 'عادي',
'journey.studio.mapPadWide': 'واسع',
'journey.studio.mapPadFar': 'واسع جدا',
'journey.studio.mapClipRect': 'داخل إطار',
'journey.studio.mapClipCountry': 'مقصوص',
'journey.studio.mapClipNeedsCountry': 'يحتاج بلدا ليقص وفقه',
'journey.studio.mapCutVector': 'مقصوص',
'journey.studio.mapCutTiles': 'خريطة مقصوصة',
'journey.studio.mapZoomAuto': 'ملائم',
'journey.studio.saving': 'يجري الحفظ',
'journey.studio.saved': 'تم الحفظ',
'journey.studio.saveFailed': 'لم يُحفظ',
'journey.studio.saveRetry': 'إعادة المحاولة',
'journey.studio.saveConflict': 'حفظ شخص آخر هذا الكتاب',
'journey.studio.saveTakeTheirs': 'نسختهم',
'journey.studio.saveKeepMine': 'نسختي',
'journey.studio.rotate': 'تدوير',
'journey.studio.rotateLeft': 'تدوير لليسار',
'journey.studio.rotateRight': 'تدوير لليمين',
'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ',
};
export default journey;
-17
View File
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
'login.error': 'فشل تسجيل الدخول. يرجى التحقق من بياناتك.',
'login.tagline': 'رحلاتك.\nخطتك.',
'login.description': 'خطط لرحلاتك بشكل تعاوني مع خرائط تفاعلية وميزانيات ومزامنة لحظية.',
'login.features.maps': 'خرائط تفاعلية',
'login.features.mapsDesc': 'Google Places ومسارات وتجميع',
'login.features.realtime': 'مزامنة فورية',
'login.features.realtimeDesc': 'خططوا معًا عبر WebSocket',
'login.features.budget': 'تتبع التكاليف',
'login.features.budgetDesc': 'فئات ورسوم وتقسيم لكل شخص',
'login.features.collab': 'تعاون',
'login.features.collabDesc': 'عدة مستخدمين مع رحلات مشتركة',
'login.features.packing': 'قوائم تجهيز',
'login.features.packingDesc': 'فئات وتقدم واقتراحات',
'login.features.bookings': 'الحجوزات',
'login.features.bookingsDesc': 'رحلات وفنادق ومطاعم وغير ذلك',
'login.features.files': 'المستندات',
'login.features.filesDesc': 'رفع الملفات وإدارتها',
'login.features.routes': 'مسارات ذكية',
'login.features.routesDesc': 'تحسين تلقائي وتصدير إلى Google Maps',
'login.selfHosted': 'استضافة ذاتية · مفتوح المصدر · بياناتك تبقى ملكك',
'login.title': 'تسجيل الدخول',
'login.subtitle': 'مرحبًا بعودتك',
'login.signingIn': 'جارٍ تسجيل الدخول…',
+4
View File
@@ -15,5 +15,9 @@ const map: TranslationStrings = {
'poi.cat.activities': 'أنشطة',
'map.showAllConnections': 'إظهار جميع مسارات الحجوزات',
'map.hideAllConnections': 'إخفاء جميع مسارات الحجوزات',
'map.baseLayer.default': 'خريطة',
'map.baseLayer.satellite': 'قمر صناعي',
'map.baseLayer.switchToSatellite': 'التبديل إلى عرض القمر الصناعي',
'map.baseLayer.switchToDefault': 'التبديل إلى عرض الخريطة',
};
export default map;
+9
View File
@@ -0,0 +1,9 @@
import type { TranslationStrings } from '../types';
const mobileAdmin: TranslationStrings = {
'mobileAdmin.create': 'مستخدم جديد',
'mobileAdmin.updateAction': 'تحديث',
'mobileAdmin.updateLine': 'الإصدار {version} متاح · لديك {current}',
};
export default mobileAdmin;
+8
View File
@@ -0,0 +1,8 @@
import type { TranslationStrings } from '../types';
const mobileAtlas: TranslationStrings = {
'mobileAtlas.visited': 'تمت الزيارة',
'mobileAtlas.planned': 'مُخطط',
};
export default mobileAtlas;
+7
View File
@@ -0,0 +1,7 @@
import type { TranslationStrings } from '../types';
const mobileCollections: TranslationStrings = {
'mobileCollections.status': 'الحالة',
};
export default mobileCollections;
+17
View File
@@ -0,0 +1,17 @@
import type { TranslationStrings } from '../types';
const mobileJourney: TranslationStrings = {
'mobileJourney.latestJourney': 'أحدث رحلة',
'mobileJourney.otherJourneys': 'رحلات أخرى',
'mobileJourney.entriesCount': '{count} مدخلات',
'mobileJourney.photosCount': '{count} صور',
'mobileJourney.placesCount': '{count} أماكن',
'mobileJourney.daysCount': '{count} أيام',
'mobileJourney.time': 'الوقت',
'mobileJourney.tags': 'الوسوم',
'mobileJourney.addTag': 'إضافة وسم',
'mobileJourney.uploadFromDevice': 'رفع من الجهاز',
'mobileJourney.browseProvider': 'تصفح {name}',
};
export default mobileJourney;
+9
View File
@@ -0,0 +1,9 @@
import type { TranslationStrings } from '../types';
const mobileNav: TranslationStrings = {
'mobileNav.journeySub': 'مذكرات السفر',
'mobileNav.collectionsSub': 'الأماكن المحفوظة',
'mobileNav.more': 'المزيد',
};
export default mobileNav;
+7
View File
@@ -0,0 +1,7 @@
import type { TranslationStrings } from '../types';
const mobileSettings: TranslationStrings = {
'mobileSettings.blurBookingCodesHint': 'إخفاء رموز الحجز حتى تنقر لإظهارها',
};
export default mobileSettings;
+49
View File
@@ -0,0 +1,49 @@
import type { TranslationStrings } from '../types';
const mobileTrip: TranslationStrings = {
'mobileTrip.addBookingShort': 'حجز',
'mobileTrip.addNoteShort': 'ملاحظة',
'mobileTrip.addPlaceShort': 'المكان',
'mobileTrip.addToDayQuestion': 'إضافة إلى يوم؟',
'mobileTrip.addTransportShort': 'النقل',
'mobileTrip.assignedDays': 'الأيام المخصصة',
'mobileTrip.bookingsEmpty': 'لا توجد حجوزات بعد',
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
'mobileTrip.compactView': 'عرض مضغوط',
'mobileTrip.dayAnnounce': 'اليوم {current} من {total}',
'mobileTrip.dayTitlePlaceholder': 'عنوان اليوم',
'mobileTrip.export': 'تصدير',
'mobileTrip.filesEmpty': 'لا توجد ملفات بعد',
'mobileTrip.filesNext': 'الملف التالي',
'mobileTrip.filesPrev': 'الملف السابق',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'تنزيل .ics',
'mobileTrip.icsSubscribe': 'الاشتراك في التقويم',
'mobileTrip.icsSubscribeSub': 'تحديث تلقائي في تطبيق التقويم لديك',
'mobileTrip.importListSub': 'الصق رابط قائمة مشتركة',
'mobileTrip.importPlaces': 'استيراد الأماكن',
'mobileTrip.inCountdown': 'بعد {time}',
'mobileTrip.listView': 'عرض القائمة',
'mobileTrip.mapView': 'عرض الخريطة',
'mobileTrip.more': 'المزيد',
'mobileTrip.newCategory': 'فئة جديدة',
'mobileTrip.newReservation': 'حجز جديد',
'mobileTrip.notes': 'الملاحظات',
'mobileTrip.onMap': 'على الخريطة',
'mobileTrip.profileDriving': 'بالسيارة',
'mobileTrip.profileWalking': 'سيرًا على الأقدام',
'mobileTrip.renameDay': 'إعادة تسمية اليوم',
'mobileTrip.resBadge': 'حجز',
'mobileTrip.showOnMap': 'إظهار على الخريطة',
'mobileTrip.statDocuments': '{count} ملفات',
'mobileTrip.statPeople': '{count} أشخاص',
'mobileTrip.stay': 'إقامة',
'mobileTrip.tapAgainToDelete': 'اضغط مرة أخرى للحذف',
'mobileTrip.todoOpenCount': '{count} مفتوحة',
'mobileTrip.travel': 'السفر',
'mobileTrip.upNext': 'التالي',
'mobileTrip.viewDetails': 'عرض التفاصيل',
'mobileTrip.transportsEmpty': 'لا يوجد نقل بعد',
};
export default mobileTrip;
+19
View File
@@ -0,0 +1,19 @@
import type { TranslationStrings } from '../types';
const mobileVacay: TranslationStrings = {
'mobileVacay.prevYear': 'السنة السابقة',
'mobileVacay.nextYear': 'السنة التالية',
'mobileVacay.prevMonth': 'الشهر السابق',
'mobileVacay.nextMonth': 'الشهر التالي',
'mobileVacay.usedCount': '{count} مستخدَمة',
'mobileVacay.leftCount': '{count} متبقية',
'mobileVacay.left': 'متبقية',
'mobileVacay.modeCompany': 'الشركة',
'mobileVacay.decreaseAllowance': 'تقليل الرصيد',
'mobileVacay.increaseAllowance': 'زيادة الرصيد',
'mobileVacay.companyLegend': 'أيام الشركة',
'mobileVacay.editCalendar': 'تحرير التقويم',
'mobileVacay.viewYear': 'عرض السنة',
};
export default mobileVacay;
+5
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.vacay_share.title': 'تمت مشاركة تقويم Vacay',
'notif.vacay_share.text': '{actor} شارك تقويم إجازاته معك',
'notif.collection_invite.title': 'دعوة إلى مجموعة',
'notif.collection_invite.text': '{actor} يدعوك لمشاركة مجموعة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
@@ -25,6 +27,9 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
'notif.version_available.title': 'إصدار جديد متاح',
'notif.version_available.text': 'TREK {version} متاح الآن',
'notif.replica_failure.title': 'فشل النسخة المتماثلة للتخزين',
'notif.replica_failure.text': "فشلت الكتابة على النسخة المتماثلة '{backend}': {op} لـ {key} — {error}",
'notif.replica_failure.textSuppressed': "فشلت الكتابة على النسخة المتماثلة '{backend}': {op} لـ {key} — {error}. تم تجاهل {suppressed} فشل إضافي منذ آخر إشعار.",
'notif.action.view_trip': 'عرض الرحلة',
'notif.action.view_collab': 'عرض الرسائل',
'notif.action.view_packing': 'عرض التعبئة',
+6
View File
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
const oauth: TranslationStrings = {
'oauth.scope.group.trips': 'الرحلات',
'oauth.scope.group.places': 'الأماكن',
'oauth.scope.group.collections': 'المجموعات',
'oauth.scope.group.packing': 'الأمتعة',
'oauth.scope.group.todos': 'المهام',
'oauth.scope.group.budget': 'الميزانية',
@@ -24,6 +25,11 @@ const oauth: TranslationStrings = {
'oauth.scope.places:read.description': 'قراءة الأماكن وتعيينات الأيام والعلامات والفئات',
'oauth.scope.places:write.label': 'إدارة الأماكن',
'oauth.scope.places:write.description': 'إنشاء وتحديث وحذف الأماكن والتعيينات والعلامات',
'oauth.scope.collections:read.label': 'عرض المجموعات',
'oauth.scope.collections:read.description': 'قراءة مجموعات الأماكن المحفوظة وأماكنها وتقييماتها وتسمياتها وأعضائها',
'oauth.scope.collections:write.label': 'إدارة المجموعات',
'oauth.scope.collections:write.description':
'إنشاء وتعديل المجموعات، وحفظ الأماكن وتقييمها وتسميتها ونسخها، ومشاركة القوائم',
'oauth.scope.atlas:read.label': 'عرض Atlas',
'oauth.scope.atlas:read.description': 'قراءة الدول والمناطق المزارة وقائمة الأمنيات',
'oauth.scope.atlas:write.label': 'إدارة Atlas',
+11
View File
@@ -1,6 +1,15 @@
import type { TranslationStrings } from '../types';
const packing: TranslationStrings = {
'packing.actions': 'الإجراءات',
'packing.editDone': 'تم',
'packing.personalEmptyHint': 'قائمتك الشخصية فارغة — انسخ عناصر مشتركة أو أضف عناصرك الخاصة.',
'packing.assignMembers': 'تعيين الأعضاء',
'packing.categoryOptions': 'خيارات القائمة',
'packing.editItem': 'تعديل العنصر',
'packing.itemName': 'الاسم',
'packing.itemQuantity': 'الكمية',
'packing.itemWeight': 'الوزن (غ)',
'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة',
'packing.import': 'استيراد',
@@ -197,5 +206,7 @@ const packing: TranslationStrings = {
'packing.alsoBring': 'يمكنني إحضاره أيضًا',
'packing.alsoBringingStop': 'لن أحضره',
'packing.cloneToMine': 'نسخ إلى قائمتي',
'packing.bagLimit': 'حد الوزن',
'packing.setBagLimit': 'تعيين حد',
};
export default packing;
+1
View File
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'التكلفة',
'pdf.preview': 'معاينة PDF',
'pdf.saveAsPdf': 'حفظ كـ PDF',
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
};
export default pdf;
+40 -2
View File
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
const places: TranslationStrings = {
'places.addPlace': 'إضافة مكان/نشاط',
'places.importFile': 'استيراد ملف',
'places.trackIndicator': 'مسار / طريق',
'places.sidebarDrop': 'أفلت للاستيراد',
'places.importFileHint':
'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.',
@@ -42,7 +43,11 @@ const places: TranslationStrings = {
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
'places.planned': 'مخطط لها',
'places.filterTracks': 'المسارات',
'places.filterByRating': 'تصفية حسب التقييم',
'places.yourRating': 'تقييمك',
'places.notRated': 'لم يُقيَّم بعد',
'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات',
'places.categoriesSelected': 'فئات',
@@ -62,6 +67,7 @@ const places: TranslationStrings = {
'places.formLng': 'خط الطول (مثال: 2.3522)',
'places.formCategory': 'الفئة',
'places.noCategory': 'بلا فئة',
'places.newCategory': 'فئة جديدة',
'places.categoryNamePlaceholder': 'اسم الفئة',
'places.formTime': 'الوقت',
'places.startTime': 'البداية',
@@ -78,15 +84,47 @@ const places: TranslationStrings = {
'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…',
'places.osmHint':
'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
'places.osmActive':
'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.',
'places.osmActive': 'يتم استخدام OpenStreetMap. مفتاح Google API يضيف التقييمات وساعات العمل.',
'places.details.title': 'تفاصيل المكان',
'places.details.empty': 'اختر نتيجة بحث لعرض الصور والوصف.',
'places.details.loading': 'جارٍ تحميل التفاصيل...',
'places.details.error': 'تعذّر تحميل التفاصيل.',
'places.details.disabled': 'الإثراء معطّل من قِبل المسؤول.',
'places.details.noPhotos': 'لم يتم العثور على صور لهذا المكان.',
'places.details.pickImage': 'اختر صورة',
'places.details.description': 'الوصف',
'places.details.adopt': 'استخدام هذا النص',
'places.details.adoptBlocked': 'أفرغ حقل الوصف أولًا',
'places.details.facts': 'معلومات مفيدة',
'places.details.nothing': 'لم يتم العثور على شيء لهذا المكان.',
'places.details.noKeyTitle': 'تتوفر تفاصيل أكثر',
'places.details.noKeyHint': 'بدون مفتاح Google API يستخدم هذا التثبيت المصادر الحرة فقط. اطلب من مسؤول TREK إضافة مفتاح للحصول على التقييمات وساعات العمل والصور.',
'places.details.aboutBrand': 'عن السلسلة',
'places.details.aboutBrandNote': 'يصف السلسلة، لا هذا الفرع.',
'places.details.fact.rating': 'التقييم',
'places.details.fact.cuisine': 'المطبخ',
'places.details.fact.openingHours': 'ساعات العمل',
'places.details.fact.menu': 'قائمة الطعام',
'places.details.fact.outdoorSeating': 'جلسات خارجية',
'places.details.fact.takeaway': 'طلبات خارجية',
'places.details.fact.delivery': 'توصيل',
'places.details.fact.wheelchair': 'مدخل بدون درج',
'places.details.fact.vegetarian': 'نباتي',
'places.details.fact.vegan': 'نباتي صرف',
'places.details.fact.internetAccess': 'واي فاي',
'places.categoryCreateError': 'فشل إنشاء الفئة',
'places.nameRequired': 'يرجى إدخال اسم',
'places.saveError': 'فشل الحفظ',
'places.createExpenseHint': 'يحفظ المكان ثم يفتح محرر التكاليف.',
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
'places.addAnyway': 'الإضافة على أي حال',
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
'places.enrichOnImportHint':
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
'places.uploadImage': 'رفع صورة',
'places.changeImage': 'تغيير الصورة',
'places.removeImage': 'إزالة الصورة',
'places.imageUploadError': 'تعذّر رفع الصورة',
'places.imageRemoveError': 'تعذّر إزالة الصورة',
};
export default places;
+3 -2
View File
@@ -29,8 +29,8 @@ const planner: TranslationStrings = {
'planner.noEntries': 'لا توجد عناصر لهذا اليوم',
'planner.addPlace': 'إضافة مكان/نشاط',
'planner.addPlaceShort': '+ إضافة مكان/نشاط',
'planner.resPending': 'حجز قيد الانتظار · ',
'planner.resConfirmed': 'حجز مؤكد · ',
'planner.resPending': 'حجز قيد الانتظار',
'planner.resConfirmed': 'حجز مؤكد',
'planner.notePlaceholder': 'ملاحظة…',
'planner.noteTimePlaceholder': 'الوقت (اختياري)',
'planner.noteExamplePlaceholder': 'مثال: S3 الساعة 14:30 من المحطة المركزية، عبّارة من الرصيف 7، استراحة غداء…',
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'المسار',
'planner.optimize': 'تحسين',
'planner.openGoogleMaps': 'فتح في Google Maps',
'planner.openCoMaps': 'فتح في CoMaps',
'planner.selectDayHint': 'اختر يومًا من القائمة اليسرى لعرض خطة اليوم',
'planner.noPlacesForDay': 'لا توجد أماكن لهذا اليوم بعد',
'planner.addPlacesLink': 'إضافة أماكن ←',
+7
View File
@@ -58,6 +58,7 @@ const reservations: TranslationStrings = {
'reservations.type.cruise': 'رحلة بحرية',
'reservations.type.event': 'فعالية',
'reservations.type.tour': 'جولة',
'reservations.type.parking': 'موقف سيارات',
'reservations.type.other': 'أخرى',
'reservations.type.bus': 'حافلة',
'reservations.type.ferry': 'عبّارة',
@@ -115,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'الوصول',
'reservations.span.inTransit': 'في الطريق',
'reservations.span.pickup': 'الاستلام',
'reservations.span.dropOff': 'التسليم',
'reservations.span.return': 'الإرجاع',
'reservations.span.active': 'نشط',
'reservations.span.start': 'البداية',
@@ -149,6 +151,8 @@ const reservations: TranslationStrings = {
'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
'reservations.airtrail.notSynced': 'غير متزامن',
'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
'reservations.airtrail.layoverHint':
'تم الاستيراد من AirTrail. الرحلة متعددة المراحل مع توقف لا تقابلها رحلة واحدة في AirTrail للمزامنة معها، لذا تبقى استيرادًا لمرة واحدة.',
'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.',
'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات',
'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
@@ -168,5 +172,8 @@ const reservations: TranslationStrings = {
'reservations.removeExpense': 'Remove expense',
'reservations.urlLabel': 'رابط',
'reservations.urlPlaceholder': 'https://...',
'reservations.travelers.label': 'المسافرون',
'reservations.travelers.assign': 'تعيين المسافرين',
'reservations.travelers.none': 'لا يوجد مسافرون في الرحلة بعد.',
};
export default reservations;
+42 -5
View File
@@ -32,6 +32,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
'settings.mapCartoKey': 'مفتاح CARTO API',
'settings.mapCartoKeyHint': 'تعرض خرائط CARTO الأساسية علامة مائية بدون مفتاح. مجاني وبدون حساب، من',
'settings.mapCartoKeyLink': 'مفتاح API لخرائط carto.com الأساسية',
'settings.mapCartoKeyMissing':
'هذا القالب خريطة أساسية من CARTO. بدون مفتاح تطبع CARTO عبارة "API KEY REQUIRED" على كل بلاطة.',
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
@@ -77,6 +82,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyVacayShare': 'مشاركات تقويم Vacay',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
@@ -194,6 +200,10 @@ const settings: TranslationStrings = {
'settings.about.featureRequest': 'اقتراح ميزة',
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
'settings.about.wikiHint': 'التوثيق والأدلة',
'settings.about.descriptionManaged':
'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.',
'settings.about.sourceTitle': 'Source code',
'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0',
'settings.about.supporters.badge': 'الداعمون الشهريون',
'settings.about.supporters.title': 'رفاق رحلة TREK',
'settings.about.supporters.subtitle':
@@ -238,6 +248,7 @@ const settings: TranslationStrings = {
'settings.avatarUploaded': 'تم تحديث صورة الملف الشخصي',
'settings.avatarRemoved': 'تمت إزالة صورة الملف الشخصي',
'settings.avatarError': 'فشل الرفع',
'settings.avatarRemoveError': 'فشل الحذف',
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
'settings.mfa.description':
'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
@@ -264,7 +275,6 @@ const settings: TranslationStrings = {
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
'settings.tabs.offline': 'Offline', // en-fallback
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', // en-fallback
'settings.notificationPreferences.email': 'Email', // en-fallback
'settings.notificationPreferences.webhook': 'Webhook', // en-fallback
'settings.notificationPreferences.inapp': 'In-App', // en-fallback
@@ -286,8 +296,9 @@ const settings: TranslationStrings = {
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer', // en-fallback
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currency': 'عملة العرض',
'settings.currencyHint':
'تُعرض المبالغ في التكاليف محوّلة إلى هذه العملة للعرض فقط — تبقى المبالغ الأصلية دون تغيير.',
'settings.currencyTrip': 'عملة الرحلة',
'settings.passkey.title': 'مفاتيح المرور',
'settings.passkey.description':
@@ -335,11 +346,13 @@ const settings: TranslationStrings = {
'settings.airtrail.test.failed': 'فشل الاتصال',
'settings.aiParsing.title': 'التحليل بالذكاء الاصطناعي',
'settings.aiParsing.hint':
'استخدم نموذج الذكاء الاصطناعي الخاص بك لاستخراج الحجوزات من الملفات المرفوعة. لا يسري هذا إلا عندما لا يكون المسؤول قد أعدّ نموذجًا للنظام بأكمله.',
'اختر نموذج الذكاء الاصطناعي المستخدم لاستخراج الحجوزات من الملفات المرفوعة. لا يسري هذا إلا عندما لا يكون المسؤول قد أعدّ نموذجًا للنظام بأكمله.',
'settings.aiParsing.provider': 'المزوّد',
'settings.aiParsing.providerLocal': 'محلي (Ollama)',
'settings.aiParsing.providerOpenai': 'OpenAI',
'settings.aiParsing.providerAnthropic': 'Anthropic',
'settings.aiParsing.localAdminOnly':
'يتم إعداد نقطة نهاية محلية (Ollama) مرة واحدة للمثيل بأكمله من إعدادات المسؤول. ولا يزال بإمكانك استخدام مفتاح OpenAI أو Anthropic الخاص بك هنا.',
'settings.aiParsing.model': 'النموذج',
'settings.aiParsing.baseUrl': 'عنوان URL الأساسي',
'settings.aiParsing.baseUrlHint': 'المكان الذي يعمل فيه النموذج — خادم Ollama محلي أو نقطة نهاية متوافقة مع OpenAI.',
@@ -413,8 +426,31 @@ const settings: TranslationStrings = {
'settings.appearance.example.normal': 'Place names, descriptions',
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'شريط التنقّل السفلي',
'settings.appearance.mobileNav.hint':
'اختَر العناصر التي تظهر في الشريط وتلك التي توضَع ضمن «المزيد». تبقى لوحة التحكّم دائماً في المقدّمة.',
'settings.appearance.mobileNav.inBar': 'في الشريط',
'settings.appearance.mobileNav.underMore': 'ضمن «المزيد»',
'settings.appearance.mobileNav.moreEmpty': 'لا شيء هنا بعد — كل العناصر تتّسع في الشريط.',
'settings.appearance.mobileNav.pinned': 'مثبَّت',
'settings.appearance.mobileNav.toMore': 'نقل إلى «المزيد»',
'settings.appearance.mobileNav.toBar': 'نقل إلى الشريط',
'settings.appearance.dashOrder': 'ترتيب لوحة التحكم',
'settings.appearance.dashOrder.hint':
'أعد ترتيب تسلسل قائمة الرحلات والأدوات على لوحة تحكم هاتفك. تبقى الرحلة المميّزة دائمًا في الأعلى.',
'settings.appearance.dashOrder.trips': 'الرحلات',
'settings.appearance.dashOrder.hidden': 'مخفي',
'settings.general.languageRegion': 'Language & region',
'settings.general.travelMap': 'Travel & map',
'settings.general.startup': 'البدء',
'settings.startPage': 'صفحة البدء',
'settings.startPageDashboard': 'لوحة التحكم',
'settings.startPageActiveTrip': 'الرحلة النشطة',
'settings.startPageHint':
'يفتح TREK مباشرةً على الرحلة الجارية، وإن لم توجد فعلى الرحلة القادمة التالية. وهي نفس الرحلة التي تبرزها لوحة التحكم.',
'settings.startTripTab': 'علامة تبويب البدء',
'settings.startTripTabHint':
'علامة التبويب التي تفتح بها الرحلة. إذا كانت تابعة لإضافة أوقفت تشغيلها، تُفتح الخطة بدلاً منها.',
// ── Offline (#1135)
'settings.offline.cache.title': 'ذاكرة التخزين المؤقت دون اتصال',
@@ -478,7 +514,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'الوقت',
'settings.pluginActivity.columns.status': 'النتيجة',
'settings.alwaysShowRoutes': 'إظهار مسارات الحجوزات دائمًا',
'settings.alwaysShowRoutesHint': 'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
'settings.alwaysShowRoutesHint':
'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
};
export default settings;
+137
View File
@@ -0,0 +1,137 @@
import type { TranslationStrings } from '../types';
const storage: TranslationStrings = {
'storage.field.root': 'الدليل الجذري',
'storage.help.root': 'المسار المطلق على الخادم حيث يخزّن هذا الواجهة الخلفية كائناته.',
'storage.field.endpoint': 'عنوان URL لنقطة النهاية',
'storage.help.endpoint':
'عنوان URL الأساسي للخدمة المتوافقة مع S3، مثل https://s3.example.com أو http://127.0.0.1:9000.',
'storage.field.bucket': 'Bucket',
'storage.field.accessKeyId': 'معرّف مفتاح الوصول',
'storage.field.secretAccessKey': 'مفتاح الوصول السري',
'storage.field.region': 'المنطقة',
'storage.help.region': 'احتفظ بالقيمة الافتراضية ما لم يتطلب مزوّد الخدمة لديك منطقة معينة.',
'storage.field.keyPrefix': 'بادئة المفتاح',
'storage.help.keyPrefix': 'بادئة اختيارية تُضاف إلى كل مفتاح كائن، مثل trek/prod.',
'storage.field.retries': 'عدد المحاولات',
'storage.field.timeoutMs': 'مهلة الانتظار (بالمللي ثانية)',
'storage.field.primary': 'الواجهة الخلفية الأساسية',
'storage.field.replicas': 'النسخ المتماثلة',
'storage.title': 'التخزين',
'storage.description': 'المكان الذي يحفظ فيه TREK الملفات والصور والنسخ الاحتياطية المرفوعة. لا يتغيّر شيء حتى تحفظ.',
'storage.loading': 'جارٍ التحميل…',
'storage.saved': 'تم حفظ إعدادات التخزين',
'storage.save': 'حفظ التغييرات',
'storage.unsaved': 'تغييرات غير محفوظة',
'storage.saveConflict':
'تغيّرت إعدادات التخزين منذ تحميلها، لذلك لم يتم حفظ تغييراتك. تجاهلها وأعد تحميل الإعدادات المحفوظة للبدء من جديد.',
'storage.discardAndReload': 'تجاهل تغييراتي وأعد التحميل',
'storage.configError.banner': 'فشل تحميل إعدادات التخزين المخزنة — سيؤدي الحفظ إلى استبدالها: {error}',
'storage.backends.title': 'الواجهات الخلفية',
'storage.backends.add': 'إضافة واجهة خلفية',
'storage.backends.usedBy': 'مستخدَمة بواسطة: {categories}',
'storage.backends.unused': 'غير مخصّصة لأي فئة',
'storage.backends.envReadOnly': 'محدَّدة بواسطة متغيّر بيئة — للقراءة فقط',
'storage.source.built-in': 'مدمجة',
'storage.source.env': 'البيئة',
'storage.source.settings': 'الإعدادات',
'storage.type.local': 'محلي',
'storage.type.s3': 'S3',
'storage.type.mirror': 'مرآة',
'storage.actions.test': 'اختبار',
'storage.actions.edit': 'تعديل',
'storage.actions.remove': 'إزالة',
'storage.test.running': 'جارٍ الاختبار…',
'storage.test.ok': 'الاتصال ناجح',
'storage.test.failed': 'فشل الاختبار',
'storage.remove.title': 'إزالة الواجهة الخلفية',
'storage.remove.body': 'هل تريد إزالة {name} من الإعدادات؟ يرفض الخادم الحفظ إذا كان لا يزال هناك ما يعتمد عليها.',
'storage.remove.stillAssigned': 'لا تزال مخصّصة لـ: {categories}',
'storage.form.addTitle': 'إضافة واجهة خلفية',
'storage.form.editTitle': 'تعديل الواجهة الخلفية',
'storage.form.name': 'الاسم',
'storage.form.type': 'النوع',
'storage.form.apply': 'تطبيق',
'storage.form.cancel': 'إلغاء',
'storage.form.duplicateName': 'توجد بالفعل واجهة خلفية باسم {name}',
'storage.categories.title': 'الفئات',
'storage.categories.default': 'الافتراضية',
'storage.categories.reassignWarning':
'لا يتم نقل الكائنات الحالية: تذهب الكائنات الجديدة إلى الواجهة الخلفية المخصّصة حديثًا، وتبقى القديمة في مكانها.',
'storage.category.files': 'مستندات الرحلة',
'storage.category.journey': 'صور Journey',
'storage.category.covers': 'صور الغلاف',
'storage.category.avatars': 'الصور الشخصية',
'storage.category.places': 'صور الأماكن',
'storage.category.photos-google': 'ذاكرة تخزين مؤقت لصور Google',
'storage.category.photos-trek': 'ذاكرة تخزين مؤقت لصور TREK',
'storage.category.backups': 'النسخ الاحتياطية',
// What each category stores — rendered under the label in the category map.
'storage.categoryDesc.files':
'مرفقات الملفات التي تُرفع إلى الرحلات — التذاكر وملفات PDF وتأكيدات الحجز والملفات المُشارَكة في محادثة الرحلة.',
'storage.categoryDesc.journey': 'الصور والصور المصغّرة المرفقة بمدخلات Journey.',
'storage.categoryDesc.covers': 'صور غلاف الرحلات والمجموعات، بما في ذلك الأغلفة المجلوبة من Unsplash.',
'storage.categoryDesc.avatars': 'الصور الشخصية لحسابات المستخدمين.',
'storage.categoryDesc.places': 'الصور المرفقة بالأماكن وأماكن المجموعات — سواء تم رفعها أو استيرادها.',
'storage.categoryDesc.photos-google':
'نسخ مخزّنة مؤقتًا من صور Google Places — يمكن إعادة جلبها، ويُمكن فقدانها بأمان.',
'storage.categoryDesc.photos-trek':
'صور مخزّنة مؤقتًا من خدمة صور TREK التي تستخدمها ميزة الصور (Memories) — يمكن إعادة جلبها، ويُمكن فقدانها بأمان.',
'storage.categoryDesc.backups': 'أرشيفات النسخ الاحتياطي للخادم التي تُنشئها لوحة النسخ الاحتياطي أو الجدولة.',
'storage.health.title': 'الحالة',
'storage.health.allClear': 'لم يتم تسجيل أي أعطال في النسخ المتماثلة.',
'storage.health.seedFile':
'يوجد ملف بذرة storage-config.json لكن يتم تجاهله — صفوف الإعدادات موجودة بالفعل. أدر التخزين من هنا.',
'storage.health.failureLine': 'فشلت عملية {op} لـ {key} على {backend}: {error}',
// Replicas-on-primary mirror UX (2026-08-20 spec)
'storage.mirror.targets': 'أهداف المرآة',
'storage.mirror.targetsHelp': 'كل عملية كتابة على هذه الواجهة الخلفية تُنسخ أيضًا إلى كل هدف محدد.',
'storage.mirror.latencyNote':
'تُكتب النسخ المتماثلة واحدة تلو الأخرى أثناء كل عملية رفع — أي هدف بطيء أو يتعذّر الوصول إليه يُبطئ كل عملية رفع لكل فئة على هذه الواجهة الخلفية.',
'storage.mirror.mirroredTo': 'معكوسة إلى: {targets}',
'storage.mirror.replicaOf': 'نسخة من: {primaries}',
'storage.mirror.cacheWarning':
'غير مستحسن: تحتوي هذه الفئة على محتوى قابل لإعادة الجلب — نسخه عادةً ما يكون مضيعة للموارد.',
'storage.mirror.degenerate.duplicate-mirror':
'مرآة ثانية تُغلّف {primary} — تُدير اللوحة الأولى فقط؛ أزل هذه لإدارة المرآة من {primary}.',
'storage.mirror.degenerate.env-primary': 'تُغلّف واجهة خلفية معرّفة بواسطة متغيّر بيئة — غير قابلة للتعديل هنا.',
'storage.mirror.degenerate.missing-primary': 'تشير إلى واجهة خلفية لم تعد موجودة.',
'storage.remove.usedAsReplicaBy': 'مستخدَمة كنسخة متماثلة بواسطة: {primaries}',
// Backfill + usage (backfill/stats/notifications spec)
'storage.sync.now': 'مزامنة الآن',
'storage.sync.running': 'جارٍ المزامنة… {done}/{total}',
'storage.sync.counts': '{copied} منسوخة · {skipped} متخطاة · {failed} فاشلة',
'storage.sync.cancel': 'إلغاء المزامنة',
'storage.sync.done': 'انتهت المزامنة: تم نسخ {copied}، وحُذف {deleted}، وفشل {failed}',
'storage.sync.cancelled': 'تم إلغاء المزامنة',
'storage.sync.error': 'فشلت المزامنة: {error}',
'storage.sync.prompt': 'الكائنات الحالية لم تُنسخ بعد — هل تريد المزامنة الآن؟',
'storage.sync.dismiss': 'تجاهل',
'storage.usage.line': '{objects} كائن · {size}',
'storage.usage.computed': 'تم حساب الاستخدام {age}',
'storage.usage.never': 'لم يُحسب الاستخدام بعد',
'storage.usage.refresh': 'تحديث',
'storage.usage.compute': 'احسب الآن',
'storage.usage.legacyNote': 'يشمل مكتبة الصور القديمة',
// Category migration (copy → flip → delta sweep)
'storage.migrate.promptTitle': 'هل تريد نقل الكائنات الحالية إلى الواجهة الخلفية الجديدة؟',
'storage.migrate.promptLine': '{category}: {objects} كائن ({size}) من {from} إلى {to}',
'storage.migrate.promptLineUnknown': '{category}: حجم غير معروف (لم يُجرَ فحص استخدام بعد) من {from} إلى {to}',
'storage.migrate.move': 'نقل الكائنات الحالية',
'storage.migrate.routeOnly': 'توجيه الكتابات الجديدة فقط',
'storage.migrate.running': 'جارٍ نقل {category}… {done}/{total}',
'storage.migrate.done': 'اكتمل النقل: تم نسخ {copied}، وتخطي {skipped}',
'storage.migrate.doneFailures': '{failed} فشل — لم يتم نسخ تلك الكائنات إلى الواجهة الخلفية الجديدة',
'storage.migrate.failed': 'فشل النقل: {error} — لم يتم تبديل الفئة',
'storage.migrate.cancelled': 'تم إلغاء النقل — لم يتم تبديل أي شيء',
'storage.migrate.reclaimable': '{objects} كائن ({size}) لا تزال على {from} — استرجعها يدويًا',
'storage.migrate.cancel': 'إلغاء النقل',
'storage.migrate.promptCancel': 'إلغاء',
'storage.migrate.queued': 'في قائمة الانتظار: {categories}',
'storage.migrate.queueDropped': 'تعذّر بدء عملية النقل التالية — تم مسح قائمة الانتظار المتبقية: {categories}',
};
export default storage;
+35
View File
@@ -53,5 +53,40 @@ const system_notice: TranslationStrings = {
'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee',
'system_notice.thank_you_support.cta_kofi': 'ادعمني على Ko-fi',
'system_notice.pager.counter': '{current} / {total}', // en-fallback
'system_notice.release_400.eyebrow': 'تم تثبيت التحديث',
'system_notice.release_400.tag': 'إصدار',
'system_notice.release_400.headline': 'أكبر إصدار حظي به TREK على الإطلاق.',
'system_notice.release_400.intro':
'يحصل TREK على هاتف، وعلى كتاب. تسعة عشر شخصًا كتبوا هذا الإصدار — ومعه نحو مئة وخمسين بلاغ خطأ تم إصلاحها.',
'system_notice.release_400.feature_mobile_title': 'TREK على الهاتف',
'system_notice.release_400.feature_mobile_body':
'كل ما دون 768px صار واجهة مستقلة — شريط زجاجي، ولوحات منزلقة خاصة به، ومخطط رحلة خاص به. افتح TREK على هاتفك.',
'system_notice.release_400.feature_studio_title': 'TREK Studio',
'system_notice.release_400.feature_studio_badge': 'Beta',
'system_notice.release_400.feature_studio_body':
'تحوّل PDF الخاص بـ Journey إلى مصمّم ألبومات صور. يرتّب الألبوم حين تطلب منه، ثم يبتعد عن طريقك.',
'system_notice.release_400.feature_vacay_title': 'Vacay يتعلّم الباقي',
'system_notice.release_400.feature_vacay_body':
'أنصاف الأيام، وأيام التعويض والمرونة، والعطل المدرسية على الشبكة — وسنة إجازات لا يلزم أن تبدأ في يناير.',
'system_notice.release_400.feature_places_title': 'الأماكن تعرّف عن نفسها، والملفات تنتقل',
'system_notice.release_400.feature_places_body':
'تُملأ الصور والوصف تلقائيًا قبل أن تحفظ المكان. ولم تعد ملفاتك المرفوعة مضطرة للبقاء على القرص الذي يعمل عليه TREK.',
'system_notice.release_400.footnote':
'وهذه أربعة منها فقط. يحمل 4.0.0 مئات التغييرات الأخرى، من Collections و Atlas إلى الخادم بأكمله تحتها.',
'system_notice.release_400.note_eyebrow': 'كلمة من صاحب المشروع',
'system_notice.release_400.note_title': 'شكرًا لاستخدامك TREK.',
'system_notice.release_400.note_body':
'بدأ TREK كأداة صغيرة لرحلاتي الخاصة، كتبتها في وقت فراغي. وما زال كذلك: أمسيات، وعطل نهاية الأسبوع، والساعات المتبقية بجانب عمل بدوام كامل.\n\nلفترة كنت وحدي. لم يعد الأمر كذلك — تسعة عشر شخصًا أطلقوا هذا الإصدار، وآلاف منكم جاؤوا بنجوم ومشكلات وترجمات وطلبات دمج. أنا ممتن لكل جزء من ذلك.',
'system_notice.release_400.promise_label': 'الوعد',
'system_notice.release_400.promise_text':
'الجانب مفتوح المصدر من TREK يبقى مجانيًا، إلى الأبد. لا باقات مدفوعة، لا اشتراكات، لا شروط خفية. أعدكم.',
'system_notice.release_400.note_body_after':
'استغرق 4.0.0 أسابيع من الليالي المتأخرة — تطبيق هاتف، ومصمّم ألبومات، وترحيل الخادم، معظمه كُتب بين منتصف الليل والثانية. ليست شكوى: أحب بناء هذا. إنها فقط الإجابة الصادقة عن كيف يخرج إصدار بهذا الحجم من مشروع في وقت الفراغ.',
'system_notice.release_400.note_closing': 'شكرًا لوجودك هنا.',
'system_notice.release_400.note_signature': '— Maurice',
'system_notice.release_400.support_text':
'الدعم هو ما يبقي هذا مستمرًا — الخوادم والنطاقات والليالي المتأخرة التي تتحول إلى إصدارات كهذا. إذا كان TREK يساوي شيئًا لك، ففنجان قهوة هو أقصر طريق لإبقائه مستمرًا.',
'system_notice.release_400.cta_bmc': 'Buy me a coffee',
'system_notice.release_400.cta_kofi': 'ادعمني على Ko-fi',
};
export default system_notice;
+1
View File
@@ -1,6 +1,7 @@
import type { TranslationStrings } from '../types';
const todo: TranslationStrings = {
'todo.emptyFiltered': 'لا توجد مهام مطابقة لهذا الفلتر',
'todo.subtab.packing': 'قائمة الأمتعة',
'todo.subtab.todo': 'المهام',
'todo.completed': 'مكتمل',
+6 -2
View File
@@ -13,9 +13,13 @@ const trip: TranslationStrings = {
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
'trip.loadingSteps.pack': 'نحزم حقائبك...',
'trip.loadingSteps.road': 'ننطلق في الرحلة...',
'trip.loadingSteps.arrive': 'أوشكنا على الوصول...',
'trip.mobilePlan': 'الخطة',
'trip.mobilePlaces': 'الأماكن',
'trip.toast.placeUpdated': 'تم تحديث المكان',
'trip.toast.tripUpdated': 'تم تحديث الرحلة',
'trip.toast.placeAdded': 'تمت إضافة المكان',
'trip.toast.placeDeleted': 'تم حذف المكان',
'trip.toast.selectDay': 'يرجى اختيار يوم أولًا',
@@ -70,8 +74,8 @@ const trip: TranslationStrings = {
'transit.addToDay': 'إضافة إلى اليوم',
'transit.itinerary': 'خط سير الرحلة',
'transit.attribution': 'بيانات المسارات عبر',
'transport.modeManual': 'نقل يدوي',
'transport.modeAutomated': 'نقل تلقائي',
'transport.modeManual': 'يدوي',
'transport.modeAutomated': 'تلقائي',
'transit.sectionTitle': 'النقل العام التلقائي',
'transit.changeRoute': 'تغيير المسار',
'transit.journey': 'رحلة بالنقل العام',
+1
View File
@@ -14,6 +14,7 @@ const undo: TranslationStrings = {
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
'undo.importKeyholeMarkup': 'استيراد KMZ/KML',
'undo.importFiles': 'استيراد ملفات',
'undo.importGoogleList': 'استيراد خرائط Google',
'undo.importNaverList': 'استيراد خرائط Naver',
'undo.addPlace': 'تمت إضافة المكان',
+38 -3
View File
@@ -27,6 +27,12 @@ const vacay: TranslationStrings = {
'vacay.weekend': 'نهاية الأسبوع',
'vacay.modeVacation': 'إجازة',
'vacay.modeCompany': 'عطلة شركة',
'vacay.modeHalf': 'نصف يوم',
'vacay.modeHalfHint': 'تسجيل أنصاف أيام الإجازة',
'vacay.fullDay': 'يوم كامل',
'vacay.modeComp': 'تعويض / مرن',
'vacay.modeCompHint': 'تسجيل أيام التعويض / الوقت المرن (مجانًا — دون استخدام إجازة)',
'vacay.compHalf': 'نصف يوم تعويضي',
'vacay.entitlement': 'الاستحقاق',
'vacay.entitlementDays': 'الأيام',
'vacay.used': 'المستخدم',
@@ -43,13 +49,14 @@ const vacay: TranslationStrings = {
'vacay.sat': 'السبت',
'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.schoolHolidays': 'School Holidays',
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
'vacay.selectCountry': 'اختر الدولة',
'vacay.selectRegion': 'اختر المنطقة (اختياري)',
'vacay.selectRegion': 'اختر المنطقة (مطلوب)',
'vacay.addCalendar': 'إضافة تقويم',
'vacay.calendarLabel': 'التسمية',
'vacay.calendarColor': 'اللون',
'vacay.noCalendars': 'لا توجد تقويمات',
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
'vacay.companyHolidays': 'عطل الشركة',
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
@@ -57,6 +64,18 @@ const vacay: TranslationStrings = {
'vacay.weekStartHint': 'اختر ما إذا كان الأسبوع يبدأ يوم الاثنين أو الأحد',
'vacay.carryOver': 'الترحيل',
'vacay.carryOverHint': 'ترحيل أيام الإجازة المتبقية تلقائيًا إلى السنة التالية',
'vacay.carriedOverPrevPeriod': 'من الفترة السابقة',
'vacay.compUsedCount': '{count} تعويض',
'vacay.yearType': 'سنة الإجازة',
'vacay.yearTypeHint': 'متى تبدأ سنة إجازتك — يتبعها الاستحقاق والاستخدام والترحيل',
'vacay.yearTypeCalendar': 'تقويمية',
'vacay.yearTypeFiscal': 'مالية',
'vacay.yearTypeAnniversary': 'تاريخ التعيين',
'vacay.yearStartMonth': 'شهر البدء',
'vacay.yearStartDay': 'اليوم',
'vacay.hireDate': 'تاريخ التعيين',
'vacay.hireDateHint': 'اختر تاريخ تعيينك — تبدأ سنة الإجازة بعدها في هذا اليوم من كل عام.',
'vacay.yearWindow': '{year}: {window}',
'vacay.sharing': 'المشاركة',
'vacay.sharingHint': 'شارك خطة إجازاتك مع مستخدمي TREK الآخرين',
'vacay.owner': 'المالك',
@@ -89,5 +108,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'يمكن لكلا الطرفين حذف الإدخالات وتغيير مستحقات الإجازة.',
'vacay.fuseInfo4': 'تتم مشاركة الإعدادات مثل العطل الرسمية وعطل الشركة.',
'vacay.fuseInfo5': 'يمكن فك الدمج في أي وقت من قبل أي طرف. ستبقى إدخالاتك محفوظة.',
'vacay.sharedCalendars': 'التقويمات المشتركة',
'vacay.shareCalendar': 'مشاركة التقويم',
'vacay.shareCalendarHint': 'اسمح لمستخدم TREK آخر برؤية تقويم إجازاتك. عرض فقط — بدون دمج وبدون تعديل.',
'vacay.sharedWithYou': 'تمت مشاركته معك',
'vacay.youShareWith': 'تشاركه مع',
'vacay.viewOnly': 'عرض فقط',
'vacay.share': 'مشاركة',
'vacay.shareSent': 'تمت مشاركة التقويم',
'vacay.shareFailed': 'تعذرت مشاركة التقويم',
'vacay.stopSharing': 'إيقاف المشاركة',
'vacay.showInCalendar': 'إظهار في التقويم',
'vacay.hideFromCalendar': 'إخفاء من التقويم',
'vacay.sharedEmpty': 'اعرضا تقويميكما لبعضكما البعض دون دمجهما.',
'vacay.sharedLegend': 'مشترك (عرض فقط)',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.noCalendars': 'No holiday calendars added yet',
};
export default vacay;
+37
View File
@@ -121,6 +121,9 @@ const admin: TranslationStrings = {
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle':
'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'admin.placesEnrich.title': 'Enriquecimento de lugares',
'admin.placesEnrich.subtitle':
'Mostra imagens e uma descrição ao adicionar um lugar. Wikipédia e OpenStreetMap são sempre usados; o Google entra quando Fotos de lugares ou Detalhes de lugares estão ativos.',
'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
'admin.collab.chat.title': 'Chat',
@@ -156,9 +159,12 @@ const admin: TranslationStrings = {
'admin.packingTemplates.loadError': 'Falha ao carregar modelos',
'admin.packingTemplates.createError': 'Falha ao criar modelo',
'admin.packingTemplates.deleteError': 'Falha ao excluir modelo',
'admin.packingTemplates.deleteCategoryError': 'Falha ao excluir a categoria',
'admin.packingTemplates.deleteItemError': 'Falha ao excluir o item',
'admin.packingTemplates.saveError': 'Falha ao salvar',
'admin.tabs.addons': 'Complementos',
'admin.tabs.plugins': 'Plugins',
'admin.tabs.storage': 'Armazenamento',
'admin.plugins.rescan': 'Reescanear',
'admin.plugins.rescanned': 'Pasta de plugins reescaneada',
'admin.plugins.upload': 'Enviar plugin',
@@ -212,6 +218,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Atualizar → v{version}',
'admin.plugins.enabledToggle': 'Ativar plugin',
'plugins.notFound': 'Plugin não encontrado',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Instale e gerencie plugins de terceiros na sua instância.',
'admin.plugins.disabledTitle': 'Os plugins estão desativados',
@@ -310,6 +317,15 @@ const admin: TranslationStrings = {
'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)',
'admin.plugins.perm.hook:map-marker-provider':
'Adicionar marcadores ao mapa da viagem (ex. mostrar reservas ou POIs)',
'admin.plugins.perm.hook:map-layer-provider': 'Desenhar rotas, corredores e zonas no mapa da viagem',
'admin.plugins.perm.hook:route-provider':
'Oferecer perfis de roteamento com os quais o planejador calcula os dias (ex. rotas para carro elétrico com paradas de recarga)',
'admin.plugins.perm.hook:day-schedule-provider':
'Anexar entradas de horário ao plano do dia (paradas de recarga, margens de segurança)',
'admin.plugins.perm.hook:day-tint-provider':
'Colorir os dias no plano do dia (por exemplo, a qual trecho da viagem um dia pertence)',
'admin.plugins.perm.geolocation:read':
'Consultar sua localização em tempo real enquanto uma das visualizações dele estiver aberta (o TREK a lê com a permissão de localização deste site, não com uma permissão própria do plugin)',
'admin.plugins.perm.hook:pdf-section-provider': 'Anexar seções de texto à exportação em PDF da viagem',
'admin.plugins.perm.hook:atlas-layer-provider':
'Destacar países no mapa-múndi do Atlas (ex. listas de desejos ou avisos de viagem)',
@@ -401,6 +417,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Atualizações primeiro',
'admin.plugins.sortDownloads': 'Mais baixados',
'admin.plugins.updatesAvailable': '{count} atualizações disponíveis para seus plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} disponível — requer TREK {range}',
'admin.plugins.versionsTitle': 'Versões',
'admin.plugins.versionPickerTitle': 'Alterar versão — {name}',
'admin.plugins.versionSwitch': 'Mudar para {version}',
'admin.plugins.versionNeedsTrek': 'requer TREK {range}',
'admin.plugins.changeVersion': 'Alterar versão…',
'admin.plugins.noVersions': 'Nenhuma versão publicada encontrada no registro.',
'admin.plugins.downgradeTitle': 'Reverter este plugin?',
'admin.plugins.downgradeBody': 'Mudando de v{from} para v{to}: os dados gravados pela versão mais recente permanecem no lugar, e a versão mais antiga pode não entendê-los.',
'admin.plugins.downgradeConfirm': 'Reverter',
'admin.plugins.updatesHeld': 'Atualizações pausadas em v{version}',
'admin.plugins.resumeUpdates': 'Retomar atualizações',
'admin.plugins.updatesResumed': 'Atualizações retomadas',
'admin.plugins.updateAll': 'Atualizar todos',
'admin.plugins.noMatchInstalled': 'Nenhum plugin instalado corresponde à sua busca.',
'admin.plugins.noMatchRegistry': 'Nenhum plugin no registro corresponde à sua busca.',
@@ -429,6 +458,11 @@ const admin: TranslationStrings = {
'admin.plugins.cap.calendar': 'Fornece eventos de calendário',
'admin.plugins.cap.placeDetails': 'Enriquece locais',
'admin.plugins.cap.warnings': 'Sinaliza problemas',
'admin.plugins.cap.mapLayers': 'Desenha no mapa',
'admin.plugins.cap.routing': 'Oferece roteamento',
'admin.plugins.cap.daySchedule': 'Adiciona horários ao plano',
'admin.plugins.cap.dayTint': 'Colore os dias',
'admin.plugins.cap.geolocation': 'Lê sua localização',
'admin.plugins.cap.events': 'Reage a atividades',
'admin.plugins.cap.requiresAddon': 'Requer {addon}',
'admin.plugins.cap.dependsOn': 'Requer {id} {version}',
@@ -652,6 +686,9 @@ const admin: TranslationStrings = {
'admin.defaultSettings.mapboxStylePlaceholder': 'Escolha um estilo…',
'admin.defaultSettings.mapbox3d': 'Edifícios & relevo em 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta qualidade',
'admin.defaultSettings.cartoKey': 'Chave CARTO compartilhada',
'admin.defaultSettings.cartoKeyHint':
"Usada para todos os usuários que não inseriram a própria chave, assim toda a instância recebe blocos do CARTO sem marca d'água. Armazenada de forma criptografada.",
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+8 -2
View File
@@ -15,11 +15,15 @@ const atlas: TranslationStrings = {
'atlas.addToBucket': 'Adicionar à lista de desejos',
'atlas.addPoi': 'Adicionar lugar',
'atlas.searchCountry': 'Buscar um país...',
'atlas.searchPlaces': 'Lugares',
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
'atlas.month': 'Mês',
'atlas.year': 'Ano',
'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar',
'atlas.removeFromBucket': 'Remover da lista de desejos',
'atlas.removeFromBucketHint': 'Tirar este país da sua lista de desejos',
'atlas.bucketWhen': 'Quando pretende visitar?',
'atlas.bucketDuplicate': 'Já está na sua lista de desejos',
'atlas.statsTab': 'Estatísticas',
'atlas.bucketTab': 'Lista de desejos',
'atlas.addBucket': 'Adicionar à lista de desejos',
@@ -31,7 +35,6 @@ const atlas: TranslationStrings = {
'atlas.cities': 'Cidades',
'atlas.noData': 'Ainda sem dados de viagem',
'atlas.noDataHint': 'Crie uma viagem e adicione lugares para ver o mapa mundial',
'atlas.lastTrip': 'Última viagem',
'atlas.nextTrip': 'Próxima viagem',
'atlas.daysLeft': 'dias restantes',
'atlas.streak': 'Sequência',
@@ -49,10 +52,13 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'Oceania',
'atlas.other': 'Outro',
'atlas.firstVisit': 'Primeira viagem',
'atlas.lastVisitLabel': 'Última viagem',
'atlas.tripSingular': 'Viagem',
'atlas.tripPlural': 'Viagens',
'atlas.placeVisited': 'Lugar visitado',
'atlas.placesVisited': 'Lugares visitados',
'atlas.planned': 'Planejado',
'atlas.showPlanned': 'Mostrar países planejados',
'atlas.plannedFor': 'Planejado para',
'atlas.antarctica': 'Antártida',
};
export default atlas;
+17
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const budget: TranslationStrings = {
'costs.confirm.deleteTitle': 'Excluir despesa?',
'costs.confirm.deleteBody': '"{name}" será excluído permanentemente.',
'budget.title': 'Orçamento',
'budget.exportCsv': 'Exportar CSV',
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
@@ -87,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Desfazer',
'costs.whatFor': 'Para que foi?',
'costs.namePlaceholder': 'ex.: jantar, lembranças, gasolina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'O que incluía, quem deve o quê…',
'costs.addNote': 'Adicionar nota',
'costs.showNote': 'Mostrar nota',
'costs.hideNote': 'Ocultar nota',
'costs.totalAmount': 'Valor total',
'costs.currency': 'Moeda',
'costs.day': 'Dia',
@@ -110,6 +117,8 @@ const budget: TranslationStrings = {
'costs.cat.fees': 'Taxas e ingressos',
'costs.cat.health': 'Saúde',
'costs.cat.tips': 'Gorjetas',
'costs.cat.fuel': 'Combustível',
'costs.cat.parking': 'Estacionamento',
'costs.cat.other': 'Outros',
'costs.daysCount': '{count} dias',
'costs.travelers': '{count} viajantes',
@@ -129,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Todas as categorias',
'costs.filter.allDays': 'Todos os dias',
'costs.expensesCount': '{count} despesas',
'costs.ticketItemName': 'Nome do item',
'costs.ticketSplitting': 'Dividindo entre:',
'costs.ticketAddItem': 'Adicionar item',
'costs.ticketShares': 'Parte de cada um',
'costs.excluded': 'Fora',
'costs.splitBalanced': 'A divisão bate com o total',
'costs.splitSumUnder': 'Soma das partes: {sum} de {total} (faltam {diff})',
'costs.splitSumOver': 'Soma das partes: {sum} de {total} (sobram {diff})',
};
export default budget;
+10
View File
@@ -1,6 +1,14 @@
import type { TranslationStrings } from '../types';
const collab: TranslationStrings = {
'collab.chat.readOnly': 'Acesso somente leitura a este chat',
'collab.chat.messageOptions': 'Opções da mensagem',
'collab.polls.confirmDeleteTitle': 'Excluir enquete?',
'collab.polls.confirmDeleteBody': 'Esta enquete será excluída permanentemente.',
'collab.polls.countdownDaysHours': 'Faltam {d}d {h}h',
'collab.polls.countdownHoursMinutes': 'Faltam {h}h {m}min',
'collab.polls.countdownMinutes': 'Faltam {m}min',
'collab.polls.clearDeadline': 'Remover prazo',
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notas',
'collab.tabs.polls': 'Enquetes',
@@ -29,6 +37,8 @@ const collab: TranslationStrings = {
'collab.notes.emptyHint': 'Comece a registrar ideias e planos',
'collab.notes.all': 'Todas',
'collab.notes.titlePlaceholder': 'Título da nota',
'collab.notes.content': 'Conteúdo',
'collab.notes.removeFile': 'Remover {name}',
'collab.notes.contentPlaceholder': 'Escreva algo...',
'collab.notes.categoryPlaceholder': 'Categoria',
'collab.notes.newCategory': 'Nova categoria...',
+33 -1
View File
@@ -10,7 +10,7 @@ const collection: TranslationStrings = {
'collections.role.editor': 'Editor',
'collections.role.admin': 'Administrador',
'collections.picker.allLists': 'Todas as listas',
'collections.select': 'Selecionar',
'collections.select': 'Escolher',
'collections.selectAll': 'Selecionar tudo',
'collections.deselectAll': 'Desmarcar tudo',
'collections.moveToList': 'Mover para lista',
@@ -36,6 +36,29 @@ const collection: TranslationStrings = {
'collections.addPlace': 'Adicionar um lugar',
'collections.addPlaceSearch': 'Pesquisar um lugar…',
'collections.addPlaceHint': 'Pesquise para adicionar um lugar a esta lista',
'collections.importFromTrip': 'Importar de uma viagem',
'collections.importTitle': 'Importar de uma viagem',
'collections.importDoneTitle': 'Importação concluída',
'collections.importPickTripHint': 'Escolha uma viagem e leve os lugares dela para {name}.',
'collections.importNoTrips': 'Nenhuma viagem ainda',
'collections.importPlacesCount': '{count} lugares',
'collections.importSearchPlaces': 'Buscar lugares…',
'collections.importOnlyNew': 'Apenas novos',
'collections.importSelectAll': 'Selecionar tudo',
'collections.importClearSelection': 'Limpar seleção',
'collections.importAlreadySaved': 'Já salvo',
'collections.importUnscheduled': 'Sem dia',
'collections.importOnDay': 'Dia {count}',
'collections.importSelectedCount': '{count} selecionados',
'collections.importAction': 'Importar {count}',
'collections.importEmptyTrip': 'Esta viagem ainda não tem lugares',
'collections.importNothingNew': 'Tudo desta viagem já está nesta lista',
'collections.importDone': '{count} lugares adicionados',
'collections.importDoneSkipped': '{count} já estavam na lista e ficaram como estavam',
'collections.importDoneClean': 'Agora estão todos em {name}',
'collections.sort.default': 'Padrão',
'collections.sort.nameAsc': 'Nome (A–Z)',
'collections.coordinates': 'Coordenadas',
'collections.showList': 'Mostrar lista',
'collections.expandMap': 'Ampliar mapa',
'collections.hero.mine': 'Minha coleção',
@@ -74,10 +97,18 @@ const collection: TranslationStrings = {
'collections.status.filterAll': 'Todos',
'collections.status.cycleHint': 'toque para alterar',
'collections.markVisited': 'Marcar como visitado',
'collections.markVisitedAll': 'Visitado em todas',
'collections.markVisitedSelection': 'Marcar como visitado nas suas listas',
'collections.markVisitedNone': 'Nenhum desses lugares está salvo em uma lista',
'collections.markedVisited': 'Marcado como visitado',
'collections.markedVisitedTrip': '{count} lugares marcados como visitados',
'collections.copyToTrip': 'Copiar para viagem',
'collections.copyToTripTitle': 'Copiar para viagem',
'collections.copyN': 'Copiar {count} para viagem',
'collections.copyToTripSearch': 'Buscar viagens',
'collections.searchLists': 'Buscar listas',
'collections.copyToTripConfirm': 'Copiar',
'collections.noTrips': 'Nenhuma viagem ainda',
'collections.alreadyInTrip': 'Já está nesta viagem',
@@ -107,6 +138,7 @@ const collection: TranslationStrings = {
'collections.noListsYet': 'Crie uma lista primeiro para salvar lugares.',
'collections.removedFromAll': 'Removido da coleção',
'collections.picker.title': 'Lugares salvos',
'collections.picker.showMore': 'Mostrar mais {count}',
'collections.picker.search': 'Buscar nos seus lugares salvos',
'collections.picker.empty': 'Nenhum lugar salvo para adicionar',
'collections.picker.hint': 'Escolha um lugar das suas coleções',
+10
View File
@@ -16,6 +16,7 @@ const common: TranslationStrings = {
'common.selectAll': 'Selecionar tudo',
'common.deselectAll': 'Desmarcar tudo',
'common.error': 'Erro',
'common.success': 'Sucesso',
'common.unknownError': 'Erro desconhecido',
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
'common.back': 'Voltar',
@@ -63,5 +64,14 @@ const common: TranslationStrings = {
'common.datepicker.typeDate': 'Type a date', // en-fallback
'common.datepicker.dialog': 'Date picker', // en-fallback
'common.datepicker.clearDate': 'Clear date', // en-fallback
'common.errorTitle': 'Un dra bennak a zo aet a-dreuz',
'common.errorBody': 'Al lodenn-mañ eus an arload n\'a ket en-dro ken. Hoc\'h roadennoù a zo diarvar.',
'common.errorPanelTitle': 'N\'eus ket bet gallet diskwel al lodenn-mañ',
'common.errorPanelBody': 'Peurrest ar bajenn a ya en-dro atav.',
'common.errorRetry': 'Klask en-dro',
'common.errorReload': 'Adkargañ ar bajenn',
'common.errorUpdateTitle': 'Un handelv nevez a zo hegerz',
'common.errorUpdateBody': 'TREK a zo bet hizivaet e-pad ma oa digor an ivinell-mañ. Adkargit evit kaout an handelv nevez.',
'common.errorPluginTitle': 'N\'eus ket bet gallet diskwel an enlugellad-mañ',
};
export default common;
+7
View File
@@ -10,6 +10,9 @@ const dashboard: TranslationStrings = {
'dashboard.subtitle.archivedSuffix': ' · {count} arquivadas',
'dashboard.newTrip': 'Nova viagem',
'dashboard.newTripSub': 'Planeje uma nova viagem do zero',
'dashboard.subscribeAllTrips': 'Assinar todas as viagens',
'dashboard.subscribeAllTripsDesc':
'Um feed de calendário para todas as suas viagens ativas, mantido em sincronia automaticamente. Exclui viagens arquivadas e viagens que terminaram há mais de 90 dias.',
'dashboard.gridView': 'Grade',
'dashboard.listView': 'Lista',
'dashboard.currency': 'Moeda',
@@ -101,6 +104,7 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.places': 'Lugares',
'dashboard.mobile.buddies': 'Companheiros',
'dashboard.mobile.newTrip': 'Nova viagem',
'dashboard.mobile.addCoverImage': 'Adicionar capa',
'dashboard.mobile.currency': 'Moeda',
'dashboard.mobile.timezone': 'Fuso horário',
'dashboard.mobile.upcomingTrips': 'Próximas viagens',
@@ -115,6 +119,9 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.tomorrow': 'Amanhã',
'dashboard.mobile.inDays': 'Em {count} dias',
'dashboard.mobile.inMonths': 'Em {count} meses',
'dashboard.mobile.spotlightDayOf': 'Dia {day} de {total}',
'dashboard.mobile.spotlightDayOne': '{count} dia',
'dashboard.mobile.spotlightDaysMany': '{count} dias',
'dashboard.mobile.completed': 'Concluído',
'dashboard.mobile.currencyConverter': 'Conversor de moedas',
'dashboard.filter.planned': 'Planejadas',
+37
View File
@@ -7,14 +7,40 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Adicionar nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteDelete': 'Excluir nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota do dia',
'dayplan.noteIcon': 'Ícone',
'notes.appearance': 'Aparência',
'notes.preview': 'Pré-visualização',
'notes.bodyPlaceholder': 'Detalhes, links, lembretes…',
'notes.markdownHint': 'Markdown suportado',
'notes.format.toolbar': 'Formatação',
'notes.format.bold': 'Negrito',
'notes.format.italic': 'Itálico',
'notes.format.strike': 'Tachado',
'notes.format.code': 'Código',
'notes.format.link': 'Link',
'notes.format.list': 'Lista com marcadores',
'notes.format.orderedList': 'Lista numerada',
'notes.format.quote': 'Citação',
'notes.color.label': 'Cor',
'notes.color.none': 'Sem cor',
'notes.color.red': 'Vermelho',
'notes.color.orange': 'Laranja',
'notes.color.amber': 'Âmbar',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Ciano',
'notes.color.blue': 'Azul',
'notes.color.purple': 'Roxo',
'dayplan.totalCost': 'Custo total',
'dayplan.days': 'Dias',
'dayplan.dayN': 'Dia {n}',
'dayplan.calculating': 'Calculando...',
'dayplan.route': 'Rota',
'dayplan.optimize': 'Otimizar',
'dayplan.transportMode.change': 'Alterar meio de transporte',
'dayplan.transportMode.useDefault': 'Usar padrão do dia',
'dayplan.optimized': 'Rota otimizada',
'dayplan.routeError': 'Falha ao calcular a rota',
'dayplan.toast.needTwoPlaces': 'São necessários pelo menos dois lugares para otimizar a rota',
@@ -23,8 +49,19 @@ const dayplan: TranslationStrings = {
'dayplan.toast.noGeoPlaces': 'Nenhum lugar com coordenadas para calcular a rota',
'dayplan.confirmed': 'Confirmada',
'dayplan.pendingRes': 'Pendente',
'dayplan.export': 'Exportar',
'dayplan.exportIntro': 'Leve seu plano com você: como documento, no calendário ou no GPS.',
'dayplan.exportDocument': 'Documento',
'dayplan.exportCalendar': 'Calendário',
'dayplan.exportMaps': 'Mapas e GPS',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
'dayplan.gpxTooltip': 'Exportar como GPX para mapas offline e aparelhos de GPS',
'dayplan.gpxAll': 'Viagem inteira',
'dayplan.gpxPlaces': 'Somente lugares',
'dayplan.gpxDays': 'Dias como rotas',
'dayplan.gpxEmpty': 'Ainda não há nada para exportar',
'dayplan.gpxFailed': 'Falha ao exportar o GPX',
'dayplan.pdfError': 'Falha ao exportar PDF',
'dayplan.cannotReorderTransport': 'Reservas com horário fixo não podem ser reordenadas',
'dayplan.confirmRemoveTimeTitle': 'Remover horário?',
@@ -28,6 +28,10 @@ const br: NotificationLocale = {
title: 'Convite Vacay Fusion',
body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.`,
}),
vacay_share: (p) => ({
title: 'Calendário Vacay compartilhado',
body: `${p.actor} compartilhou o calendário de férias com você. Abra o TREK para visualizar.`,
}),
collection_invite: (p) => ({
title: 'Convite para coleção',
body: `${p.actor} convidou você para compartilhar uma coleção. Abra o TREK para aceitar ou recusar.`,
@@ -48,6 +52,12 @@ const br: NotificationLocale = {
title: 'Nova versão do TREK disponível',
body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.`,
}),
replica_failure: (p) => ({
title: 'Falha na réplica de armazenamento',
body:
`Falha ao gravar na réplica '${p.backend}': ${p.op} de ${p.key} — ${p.error}.` +
(p.suppressed !== '0' ? ` Mais ${p.suppressed} falha(s) foram suprimidas desde a última notificação.` : ''),
}),
synology_session_cleared: () => ({
title: 'Sessão Synology encerrada',
body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.',
+6
View File
@@ -1,6 +1,12 @@
import type { TranslationStrings } from '../types';
const files: TranslationStrings = {
'files.filterStarred': 'Favoritos',
'files.link': 'Vincular',
'files.linkTitle': 'Vincular arquivo',
'files.linkEmpty': 'Ainda não há lugares ou reservas para vincular',
'files.menu': 'Mais opções',
'files.uploadErrorSize': 'O arquivo é muito grande (máx. 50 MB)',
'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}',
+18
View File
@@ -18,6 +18,14 @@ import login from './login';
import map from './map';
import members from './members';
import memories from './memories';
import mobileAdmin from './mobileAdmin';
import mobileAtlas from './mobileAtlas';
import mobileCollections from './mobileCollections';
import mobileJourney from './mobileJourney';
import mobileNav from './mobileNav';
import mobileSettings from './mobileSettings';
import mobileTrip from './mobileTrip';
import mobileVacay from './mobileVacay';
import nav from './nav';
import notif from './notif';
import notifications from './notifications';
@@ -34,6 +42,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import stats from './stats';
import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import transport from './transport';
@@ -86,5 +95,14 @@ const locale = {
...system_notice,
...transport,
...help,
...mobileTrip,
...mobileJourney,
...mobileVacay,
...mobileAtlas,
...mobileNav,
...mobileAdmin,
...mobileSettings,
...mobileCollections,
...storage,
};
export default locale;
+5
View File
@@ -13,6 +13,8 @@ const inspector: TranslationStrings = {
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente',
'inspector.google': 'Google Maps',
'inspector.navigation': 'Navegação',
'inspector.openWith': 'Abrir com',
'inspector.openStreetMap': 'OpenStreetMap',
'inspector.saveToCollection': 'Salvar na Coleção',
'inspector.savedToCollection': 'Salvo',
@@ -21,5 +23,8 @@ const inspector: TranslationStrings = {
'inspector.editRes': 'Editar reserva',
'inspector.participants': 'Participantes',
'inspector.trackStats': 'Dados da trilha',
'inspector.trackColor': 'Cor da trilha',
'inspector.trackColorAuto': 'Cor automática',
'inspector.trackColorCustom': 'Escolher cor personalizada',
};
export default inspector;
+366
View File
@@ -54,6 +54,7 @@ const journey: TranslationStrings = {
'journey.frontpage.subtitle': 'Transforme suas viagens em histórias que você nunca vai esquecer',
'journey.frontpage.createJourney': 'Criar jornada',
'journey.frontpage.activeJourney': 'Jornada ativa',
'journey.frontpage.latestJourney': 'Última jornada',
'journey.frontpage.allJourneys': 'Todas as jornadas',
'journey.frontpage.journeys': 'jornadas',
'journey.frontpage.createNew': 'Criar uma nova jornada',
@@ -75,6 +76,8 @@ const journey: TranslationStrings = {
'journey.detail.backToJourney': 'Voltar à jornada',
'journey.detail.syncedWithTrips': 'Sincronizado com viagens',
'journey.detail.addEntry': 'Adicionar entrada',
'journey.detail.jumpToTop': 'Voltar ao topo',
'journey.detail.jumpToLast': 'Ir para a última entrada',
'journey.detail.newEntry': 'Nova entrada',
'journey.detail.editEntry': 'Editar entrada',
'journey.detail.noEntries': 'Nenhuma entrada ainda',
@@ -124,6 +127,12 @@ const journey: TranslationStrings = {
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.editor.useCurrentLocation': 'Usar minha localização atual',
'journey.editor.locationPermissionDenied':
'O acesso à localização foi negado. Permita nas configurações do navegador e tente novamente.',
'journey.editor.locationTimeout': 'Tempo esgotado ao obter sua localização. Tente novamente.',
'journey.editor.locationUnavailable': 'Não foi possível determinar sua localização.',
'journey.editor.locationInsecureContext': 'A localização requer uma conexão segura (HTTPS).',
'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro',
@@ -227,5 +236,362 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
'journey.studio.title': 'TREK Studio', // en-fallback
'journey.studio.open': 'Studio', // en-fallback
'journey.studio.openAria': 'Open the photo book studio', // en-fallback
'journey.studio.backToJourney': 'Back to the journey', // en-fallback
'journey.studio.format': 'Page format', // en-fallback
'journey.studio.formatA4Landscape': 'A4 landscape', // en-fallback
'journey.studio.formatA4Portrait': 'A4 portrait', // en-fallback
'journey.studio.formatSquare21': 'Square 21 × 21 cm', // en-fallback
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
'journey.studio.pages': 'Pages', // en-fallback
'journey.studio.cover': 'Cover', // en-fallback
'journey.studio.inspector': 'Properties', // en-fallback
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
'journey.studio.emptySpread': 'This spread is still empty', // en-fallback
'journey.studio.autoLayout': 'Auto layout', // en-fallback
'journey.studio.export': 'Export', // en-fallback
'journey.studio.day': 'DIA',
'journey.studio.stations': 'Etapas',
'journey.studio.peersHere': 'aqui',
'journey.studio.folioAuto': 'Automático',
'journey.studio.exportLayout': 'Layout',
'journey.studio.exportPages': 'Páginas avulsas',
'journey.studio.exportPagesHint': 'Uma folha por página, na ordem de leitura. É o que a gráfica quer.',
'journey.studio.exportSpreads': 'Páginas duplas',
'journey.studio.exportSpreadsHint': 'Duas páginas por vez, como o livro abre. Para ler.',
'journey.studio.exportFinishing': 'Acabamento',
'journey.studio.exportMarks': 'Marcas de corte',
'journey.studio.exportMarksHint': 'Acrescenta {bleed} mm de sangria em cada borda e marca onde cortar',
'journey.studio.exportNote': '{sheets} folhas de {width} × {height} mm. O navegador transforma a visualização em PDF.',
'journey.studio.exportOpen': 'Visualizar impressão',
'journey.studio.exportSave': 'Salvar como PDF',
'journey.studio.exportPreparing': 'Preparando',
'journey.studio.exportSheetCount': '{count} folhas',
'journey.studio.undo': 'Undo', // en-fallback
'journey.studio.redo': 'Redo', // en-fallback
'journey.studio.zoomIn': 'Zoom in', // en-fallback
'journey.studio.zoomOut': 'Zoom out', // en-fallback
'journey.studio.zoomFit': 'Fit to view', // en-fallback
'journey.studio.downloadSpread': 'Baixar esta página dupla',
'journey.studio.downloadSpreadHint': 'Salva o design desta página dupla como arquivo, sem as fotos, para compartilhar ou reutilizar',
'journey.studio.importSpread': 'Importar',
'journey.studio.importSpreadHint': 'Adiciona uma página dupla a partir de um arquivo de design baixado',
'journey.studio.importSpreadFailed': 'Esse arquivo não é uma página do TREK Studio',
'journey.studio.desktopOnly': 'Studio needs a bigger screen', // en-fallback
'journey.studio.desktopOnlyHint': 'Montar um livro pede espaço de trabalho, então o Studio existe só no computador, e o PDF também. Todo o resto da sua viagem funciona aqui como sempre.', // en-fallback
'journey.studio.formatA5Landscape': 'A5 landscape', // en-fallback
'journey.studio.bookView': 'Book view', // en-fallback
'journey.studio.multiple': 'Several', // en-fallback
'journey.studio.kind.photo': 'Photo', // en-fallback
'journey.studio.kind.text': 'Text', // en-fallback
'journey.studio.kind.shape': 'Shape', // en-fallback
'journey.studio.position': 'Position', // en-fallback
'journey.studio.width': 'W', // en-fallback
'journey.studio.height': 'H', // en-fallback
'journey.studio.text': 'Text', // en-fallback
'journey.studio.typography': 'Type', // en-fallback
'journey.studio.leading': 'Line', // en-fallback
'journey.studio.colour': 'Colour', // en-fallback
'journey.studio.autoColour': 'Automático',
'journey.studio.countryNames': 'Nomes',
'journey.studio.crop': 'Crop', // en-fallback
'journey.studio.look': 'Look', // en-fallback
'journey.studio.radius': 'Corner', // en-fallback
'journey.studio.shape': 'Shape', // en-fallback
'journey.studio.arrange': 'Arrange', // en-fallback
'journey.studio.toFront': 'Bring to front', // en-fallback
'journey.studio.forward': 'Bring forward', // en-fallback
'journey.studio.backward': 'Send backward', // en-fallback
'journey.studio.toBack': 'Send to back', // en-fallback
'journey.studio.lock': 'Lock', // en-fallback
'journey.studio.unlock': 'Unlock', // en-fallback
'journey.studio.delete': 'Delete', // en-fallback
'journey.studio.pageHint': 'Page', // en-fallback
'journey.studio.boundHint': 'Follows the journal entry. Editing it here breaks that link.', // en-fallback
'journey.studio.fit.cover': 'Fill', // en-fallback
'journey.studio.fit.contain': 'Fit', // en-fallback
'journey.studio.filter.none': 'Original', // en-fallback
'journey.studio.filter.bw': 'Black & white', // en-fallback
'journey.studio.filter.warm': 'Warm', // en-fallback
'journey.studio.shapeKind.rect': 'Rectangle', // en-fallback
'journey.studio.shapeKind.ellipse': 'Ellipse', // en-fallback
'journey.studio.focalHint': 'Drag the point to choose what stays in frame.', // en-fallback
'journey.studio.backCover': 'Back cover', // en-fallback
'journey.studio.sections': 'Sections', // en-fallback
'journey.studio.content': 'Content', // en-fallback
'journey.studio.elements': 'Elements', // en-fallback
'journey.studio.templates': 'Layouts', // en-fallback
'journey.studio.photos': 'Photos', // en-fallback
'journey.studio.entries': 'Entries', // en-fallback
'journey.studio.addToPage': 'Add to this page', // en-fallback
'journey.studio.noPhotos': 'This journey has no photos yet.', // en-fallback
'journey.studio.untitled': 'Untitled', // en-fallback
'journey.studio.addTitle': 'Title', // en-fallback
'journey.studio.addStory': 'Story', // en-fallback
'journey.studio.addPlace': 'Place', // en-fallback
'journey.studio.shapes': 'Shapes', // en-fallback
'journey.studio.frames': 'Molduras', // en-fallback
'journey.studio.emptyFrame': 'Empty frame', // en-fallback
'journey.studio.frameHint': 'An empty frame marks where a picture goes. Drop one on it from Content.', // en-fallback
'journey.studio.shapeKind.line': 'Line', // en-fallback
'journey.studio.styleTitle': 'Heading', // en-fallback
'journey.studio.styleSubtitle': 'Subheading', // en-fallback
'journey.studio.styleBody': 'Body text', // en-fallback
'journey.studio.styleCaption': 'Caption', // en-fallback
'journey.studio.sampleHeading': 'A heading', // en-fallback
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
'journey.studio.sampleCaption': 'Caption', // en-fallback
'journey.studio.templatesCoverHint': 'Layouts apply to the inside spreads. The cover and the back are designed on their own.', // en-fallback
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
'journey.studio.tpl.grid4': 'Four up', // en-fallback
'journey.studio.tpl.grid6': 'Six up', // en-fallback
'journey.studio.tpl.strip': 'Strip and text', // en-fallback
'journey.studio.tpl.quietText': 'Text only', // en-fallback
'journey.studio.tpl.portraitPair': 'A pair', // en-fallback
'journey.studio.dropPhotoHere': 'Arraste sua foto\npara cá',
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
'journey.studio.decorations': 'Decoration', // en-fallback
'journey.studio.quoteMark': 'Quotation mark', // en-fallback
'journey.studio.circleOutline': 'Outlined circle', // en-fallback
'journey.studio.roundFrame': 'Rounded frame', // en-fallback
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
'journey.studio.travel': 'Viagem',
'journey.studio.travelEmpty': 'Os números desta jornada ainda não estão prontos.',
'journey.studio.grids': 'Grades',
'journey.studio.gridHint': 'Uma grade coloca um bloco de molduras vazias. Arraste fotos de Conteúdo para elas.',
'journey.studio.lines': 'Linhas',
'journey.studio.frameStyles': 'Estilos de moldura',
'journey.studio.frameShapes': 'Formas de moldura',
'journey.studio.plainFrame': 'Simples',
'journey.studio.polaroidFrame': 'Polaroid',
'journey.studio.whiteFrame': 'Borda branca',
'journey.studio.shadowFrame': 'Sombra',
'journey.studio.filmFrame': 'Filme',
'journey.studio.tapeFrame': 'Com fita',
'journey.studio.shapeGroup.basic': 'Básicas',
'journey.studio.shapeGroup.polygons': 'Polígonos',
'journey.studio.shapeGroup.stars': 'Estrelas',
'journey.studio.shapeGroup.arrows': 'Setas',
'journey.studio.shapeGroup.speech': 'Balões',
'journey.studio.shapeGroup.travel': 'Viagem',
'journey.studio.shapeGroup.decor': 'Decoração',
'journey.studio.shapeGroup.banners': 'Faixas',
'journey.studio.summary': 'Resumo',
'journey.studio.tripSummary': 'Resumo da viagem',
'journey.studio.statsRow': 'Uma linha',
'journey.studio.statsFull': 'Tudo',
'journey.studio.routeMap': 'Mapa do trajeto',
'journey.studio.mapStyle.minimal': 'Mínimo',
'journey.studio.mapStyle.outline': 'Contorno',
'journey.studio.mapStyle.paper': 'Papel',
'journey.studio.mapStyle.dark': 'Escuro',
'journey.studio.countries': 'Países',
'journey.studio.countryList': 'Lista de países',
'journey.studio.countryGrid': 'Grade de países',
'journey.studio.noCountries': 'Nenhum país identificado para esta jornada ainda.',
'journey.studio.noRoute': 'Ainda não há paradas com coordenadas.',
'journey.studio.marks': 'Marcas',
'journey.studio.dateMark': 'Data',
'journey.studio.dayMark': 'Contador de dias',
'journey.studio.dayWord': 'DIA',
'journey.studio.coordsMark': 'Coordenadas',
'journey.studio.coordsDms': 'Graus',
'journey.studio.coordsDecimal': 'Decimal',
'journey.studio.flagMark': 'Bandeira',
'journey.studio.distanceMark': 'Distância',
'journey.studio.metric.distance': 'Distância',
'journey.studio.metric.days': 'Dias',
'journey.studio.metric.steps': 'Paradas',
'journey.studio.metric.photos': 'Fotos',
'journey.studio.metric.countries': 'Países',
'journey.studio.metric.places': 'Lugares',
'journey.studio.metric.furthest': 'Mais longe',
'journey.studio.kind.map': 'Mapa',
'journey.studio.kind.stats': 'Números',
'journey.studio.kind.countries': 'Países',
'journey.studio.kind.badge': 'Marca',
'journey.studio.kind.list': 'Lista',
'journey.studio.kind.icon': 'Ícone',
'journey.studio.duplicate': 'Duplicar',
'journey.studio.style': 'Estilo',
'journey.studio.shows': 'Mostrar',
'journey.studio.size': 'Tamanho',
'journey.studio.weight': 'Peso',
'journey.studio.italic': 'Itálico',
'journey.studio.tracking': 'Espaçamento',
'journey.studio.rotation': 'Rotação',
'journey.studio.opacity': 'Opacidade',
'journey.studio.fill': 'Preenchimento',
'journey.studio.fillOn': 'Preenchido',
'journey.studio.stroke': 'Contorno',
'journey.studio.strokeWidth': 'Espessura',
'journey.studio.gradient': 'Degradê',
'journey.studio.gradientDown': 'Para baixo',
'journey.studio.gradientUp': 'Para cima',
'journey.studio.showIcons': 'Ícones',
'journey.studio.mapFit': 'Área',
'journey.studio.mapPadding': 'Espaço',
'journey.studio.mapShape': 'Forma',
'journey.studio.align.left': 'Esquerda',
'journey.studio.align.center': 'Centro',
'journey.studio.align.right': 'Direita',
'journey.studio.markStyle.plain': 'Simples',
'journey.studio.markStyle.chip': 'Etiqueta',
'journey.studio.markStyle.outline': 'Contornado',
'journey.studio.markStyle.stacked': 'Empilhado',
'journey.studio.icon': 'Ícone',
'journey.studio.iconAndLabel': 'Ícone e texto',
'journey.studio.iconOnly': 'Só ícone',
'journey.studio.labelOnly': 'Só texto',
'journey.studio.icons': 'Ícones',
'journey.studio.iconsForTravel': 'Para viagem',
'journey.studio.iconsAll': 'Todos os ícones',
'journey.studio.searchIcons': 'Buscar ícones',
'journey.studio.lineWidth': 'Espessura',
'journey.studio.mask': 'Recortar na forma',
'journey.studio.maskNone': 'Nenhuma',
'journey.studio.frameStyle': 'Moldura',
'journey.studio.mapLayers': 'Camadas',
'journey.studio.showLand': 'Países',
'journey.studio.showRoute': 'Trajeto',
'journey.studio.showPins': 'Paradas',
'journey.studio.showLabels': 'Rótulos',
'journey.studio.units': 'Unidades',
'journey.studio.metrics': 'Números',
'journey.studio.layout': 'Disposição',
'journey.studio.layoutGrid': 'Grade',
'journey.studio.layoutRow': 'Linha',
'journey.studio.layoutColumn': 'Coluna',
'journey.studio.layoutList': 'Lista',
'journey.studio.showOutline': 'Contornos',
'journey.studio.showFlag': 'Bandeiras',
'journey.studio.showName': 'Nomes',
'journey.studio.textScale': 'Tamanho do texto',
'journey.studio.accent': 'Destaque',
'journey.studio.refresh': 'Atualizar da jornada',
'journey.studio.staleHint': 'A jornada mudou desde que estes números foram obtidos.',
'journey.studio.align': 'Alinhamento',
'journey.studio.filter.cool': 'Frio',
'journey.studio.filter.fade': 'Desbotado',
'journey.studio.filter.contrast': 'Intenso',
'journey.studio.strokeStyle': 'Traço',
'journey.studio.strokeSolid': 'Contínuo',
'journey.studio.strokeDashed': 'Tracejado',
'journey.studio.strokeDotted': 'Pontilhado',
'journey.studio.singleFigures': 'Números avulsos',
'journey.studio.addPage': 'Adicionar página',
'journey.studio.addPageAfter': 'Inserir página depois',
'journey.studio.duplicatePage': 'Duplicar página',
'journey.studio.deletePage': 'Excluir página',
'journey.studio.movePageUp': 'Mover para antes',
'journey.studio.movePageDown': 'Mover para depois',
'journey.studio.beta': 'Beta',
'journey.studio.addProsCons': 'Prós e contras',
'journey.studio.showMarks': 'Marcas',
'journey.studio.formatCustom': 'Tamanho próprio',
'journey.studio.document': 'Documento',
'journey.studio.pageNumbers': 'Números de página',
'journey.studio.pageNumbersOn': 'Sim',
'journey.studio.pageNumbersOff': 'Não',
'journey.studio.folio.outer': 'Externo',
'journey.studio.folio.inner': 'Interno',
'journey.studio.folio.centre': 'Centralizado',
'journey.studio.folioStart': 'Começa em',
'journey.studio.folioMargin': 'Margem',
'journey.studio.relayoutSpread': 'Esta página',
'journey.studio.relayoutSpreadHint': 'Refazer a partir da entrada',
'journey.studio.relayoutSpreadNone': 'Esta página não vem de uma entrada',
'journey.studio.relayoutBook': 'O livro inteiro',
'journey.studio.relayoutBookHint': 'Substitui todas as páginas — reversível',
'journey.studio.tpl.coverFull': 'Sangria total',
'journey.studio.tpl.coverBand': 'Imagem e faixa',
'journey.studio.tpl.coverWindow': 'Emoldurado',
'journey.studio.tpl.coverQuiet': 'Só texto',
'journey.studio.tpl.coverHalf': 'Duas metades',
'journey.studio.tpl.fullText': 'Imagem e história',
'journey.studio.tpl.grid9': 'Nove',
'journey.studio.tpl.mosaic': 'Mosaico',
'journey.studio.tpl.bandQuote': 'Palavras no meio',
'journey.studio.tpl.staggerFour': 'Quatro escalonadas',
'journey.studio.weightMissing': 'Esta fonte não tem esse peso',
'journey.studio.mapSource': 'Origem do mapa',
'journey.studio.mapSourceVector': 'Contornos',
'journey.studio.mapSourceRelief': 'Relevo',
'journey.studio.routeLook': 'A linha',
'journey.studio.roads': 'Estradas',
'journey.studio.roadsFetch': 'Seguir as estradas',
'journey.studio.roadsFollow': 'Pela estrada',
'journey.studio.roadsDirect': 'Direta',
'journey.studio.recommended': 'recomendado',
'journey.studio.bleed': 'Sangria',
'journey.studio.safeArea': 'Segurança',
'journey.studio.roadsAgain': 'Buscar de novo',
'journey.studio.roadsClear': 'Limpar',
'journey.studio.roadsBusy': 'Consultando',
'journey.studio.roadsHint': 'Pede a um serviço de rotas o caminho percorrido em cada trecho. Os trechos longos ficam como estão.',
'journey.studio.roadsHave': 'As estradas ficam salvas neste livro, então ele imprime a mesma linha offline.',
'journey.studio.routeStyle': 'Aparência',
'journey.studio.routePlain': 'Lisa',
'journey.studio.routeDrawn': 'Desenhada',
'journey.studio.routeArc': 'Trechos longos',
'journey.studio.routeStraight': 'Retos',
'journey.studio.routeBow': 'Curvados',
'journey.studio.routeDashArcs': 'Tracejar os trechos curvados',
'journey.studio.mapStops': 'Paradas',
'journey.studio.pinDot': 'Pontos',
'journey.studio.pinPhoto': 'Fotos',
'journey.studio.pinPhotoNone': 'Ainda não há fotos nessas paradas, então elas aparecem como pontos.',
'journey.studio.mapSourceSatellite': 'Satélite',
'journey.studio.mapSourceSatelliteHint': 'Sentinel-2 sem nuvens, livre para impressão com o crédito. Nítido até o nível da rua.',
'journey.studio.mapSourceReliefHint': 'Relevo sombreado da NASA, livre para impressão. Ideal para um país ou um continente, grosseiro demais para uma cidade.',
'journey.studio.mapPrintDpi': 'Imprime com cerca de',
'journey.studio.mapPrintDpiLow': 'sem nitidez neste tamanho, use um enquadramento mais amplo ou outra origem',
'journey.studio.mapPerTrip': 'Uma viagem por vez',
'journey.studio.mapWholeJourney': 'Jornada inteira',
'journey.studio.mapScope': 'Mostrar',
'journey.studio.mapSourceTiles': 'Blocos de mapa',
'journey.studio.mapSourceStatic': 'Mapbox',
'journey.studio.mapSourceHint': 'Baixado ao renderizar e impresso com o crédito',
'journey.studio.mapZoom': 'Zoom',
'journey.studio.mapFraming': 'Enquadramento',
'journey.studio.mapFitStops': 'Paradas',
'journey.studio.mapFitCountry': 'País inteiro',
'journey.studio.mapPadTight': 'Justo',
'journey.studio.mapPadNormal': 'Normal',
'journey.studio.mapPadWide': 'Amplo',
'journey.studio.mapPadFar': 'Muito amplo',
'journey.studio.mapClipRect': 'Em moldura',
'journey.studio.mapClipCountry': 'Recortado',
'journey.studio.mapClipNeedsCountry': 'Precisa de um país para recortar',
'journey.studio.mapCutVector': 'Recorte',
'journey.studio.mapCutTiles': 'Mapa recortado',
'journey.studio.mapZoomAuto': 'Ajustar',
'journey.studio.saving': 'Salvando',
'journey.studio.saved': 'Salvo',
'journey.studio.saveFailed': 'Não salvo',
'journey.studio.saveRetry': 'Tentar de novo',
'journey.studio.saveConflict': 'Outra pessoa salvou este livro',
'journey.studio.saveTakeTheirs': 'A dela',
'journey.studio.saveKeepMine': 'A minha',
'journey.studio.rotate': 'Girar',
'journey.studio.rotateLeft': 'Girar à esquerda',
'journey.studio.rotateRight': 'Girar à direita',
'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo',
};
export default journey;
-17
View File
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
'login.error': 'Falha no login. Verifique suas credenciais.',
'login.tagline': 'Suas viagens.\nSeu plano.',
'login.description': 'Planeje viagens em equipe com mapas interativos, orçamento e sincronização em tempo real.',
'login.features.maps': 'Mapas interativos',
'login.features.mapsDesc': 'Google Places, rotas e agrupamento',
'login.features.realtime': 'Sincronização em tempo real',
'login.features.realtimeDesc': 'Planejem juntos via WebSocket',
'login.features.budget': 'Controle de custos',
'login.features.budgetDesc': 'Categorias, gráficos e custo por pessoa',
'login.features.collab': 'Colaboração',
'login.features.collabDesc': 'Vários usuários com viagens compartilhadas',
'login.features.packing': 'Listas de malas',
'login.features.packingDesc': 'Categorias, progresso e sugestões',
'login.features.bookings': 'Reservas',
'login.features.bookingsDesc': 'Voos, hotéis, restaurantes e mais',
'login.features.files': 'Documentos',
'login.features.filesDesc': 'Envie e gerencie documentos',
'login.features.routes': 'Rotas inteligentes',
'login.features.routesDesc': 'Otimize e exporte para o Google Maps',
'login.selfHosted': 'Auto-hospedado · Código aberto · Seus dados são seus',
'login.title': 'Entrar',
'login.subtitle': 'Bem-vindo de volta',
'login.signingIn': 'Entrando…',
+4
View File
@@ -15,5 +15,9 @@ const map: TranslationStrings = {
'poi.cat.activities': 'Atividades',
'map.showAllConnections': 'Mostrar todas as rotas de reservas',
'map.hideAllConnections': 'Ocultar todas as rotas de reservas',
'map.baseLayer.default': 'Mapa',
'map.baseLayer.satellite': 'Satélite',
'map.baseLayer.switchToSatellite': 'Mudar para vista de satélite',
'map.baseLayer.switchToDefault': 'Mudar para vista de mapa',
};
export default map;
+2 -1
View File
@@ -3,7 +3,8 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
'memories.notConnected': '{provider_name} não conectado',
'memories.notConnectedHint': 'Conecte sua instância {provider_name} nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedHint':
'Conecte sua instância {provider_name} nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedMultipleHint':
'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
+9
View File
@@ -0,0 +1,9 @@
import type { TranslationStrings } from '../types';
const mobileAdmin: TranslationStrings = {
'mobileAdmin.create': 'Novo usuário',
'mobileAdmin.updateAction': 'Atualizar',
'mobileAdmin.updateLine': 'Versão {version} disponível · você tem {current}',
};
export default mobileAdmin;
+8
View File
@@ -0,0 +1,8 @@
import type { TranslationStrings } from '../types';
const mobileAtlas: TranslationStrings = {
'mobileAtlas.visited': 'Visitado',
'mobileAtlas.planned': 'Planejado',
};
export default mobileAtlas;
+7
View File
@@ -0,0 +1,7 @@
import type { TranslationStrings } from '../types';
const mobileCollections: TranslationStrings = {
'mobileCollections.status': 'Status',
};
export default mobileCollections;
+17
View File
@@ -0,0 +1,17 @@
import type { TranslationStrings } from '../types';
const mobileJourney: TranslationStrings = {
'mobileJourney.latestJourney': 'Última viagem',
'mobileJourney.otherJourneys': 'Outras viagens',
'mobileJourney.entriesCount': '{count} entradas',
'mobileJourney.photosCount': '{count} fotos',
'mobileJourney.placesCount': '{count} lugares',
'mobileJourney.daysCount': '{count} dias',
'mobileJourney.time': 'Hora',
'mobileJourney.tags': 'Tags',
'mobileJourney.addTag': 'Adicionar tag',
'mobileJourney.uploadFromDevice': 'Enviar do dispositivo',
'mobileJourney.browseProvider': 'Navegar em {name}',
};
export default mobileJourney;
+9
View File
@@ -0,0 +1,9 @@
import type { TranslationStrings } from '../types';
const mobileNav: TranslationStrings = {
'mobileNav.journeySub': 'Diário de viagem',
'mobileNav.collectionsSub': 'Lugares salvos',
'mobileNav.more': 'Mais',
};
export default mobileNav;
+7
View File
@@ -0,0 +1,7 @@
import type { TranslationStrings } from '../types';
const mobileSettings: TranslationStrings = {
'mobileSettings.blurBookingCodesHint': 'Ocultar códigos de reserva até você tocar para revelar',
};
export default mobileSettings;
+49
View File
@@ -0,0 +1,49 @@
import type { TranslationStrings } from '../types';
const mobileTrip: TranslationStrings = {
'mobileTrip.addBookingShort': 'Reserva',
'mobileTrip.addNoteShort': 'Nota',
'mobileTrip.addPlaceShort': 'Lugar',
'mobileTrip.addToDayQuestion': 'Adicionar a um dia?',
'mobileTrip.addTransportShort': 'Transporte',
'mobileTrip.assignedDays': 'Dias atribuídos',
'mobileTrip.bookingsEmpty': 'Nenhuma reserva ainda',
'mobileTrip.collabFeatureDisabled': 'Esse recurso está desativado para esta viagem',
'mobileTrip.compactView': 'Visualização compacta',
'mobileTrip.dayAnnounce': 'Dia {current} de {total}',
'mobileTrip.dayTitlePlaceholder': 'Título do dia',
'mobileTrip.export': 'Exportar',
'mobileTrip.filesEmpty': 'Nenhum arquivo ainda',
'mobileTrip.filesNext': 'Próximo arquivo',
'mobileTrip.filesPrev': 'Arquivo anterior',
'mobileTrip.googleMaps': 'Google Maps',
'mobileTrip.coMaps': 'CoMaps',
'mobileTrip.icsDownload': 'Baixar .ics',
'mobileTrip.icsSubscribe': 'Assinar calendário',
'mobileTrip.icsSubscribeSub': 'Atualiza automaticamente no seu app de calendário',
'mobileTrip.importListSub': 'Cole o link de uma lista compartilhada',
'mobileTrip.importPlaces': 'Importar locais',
'mobileTrip.inCountdown': 'em {time}',
'mobileTrip.listView': 'Visualização em lista',
'mobileTrip.mapView': 'Visualização em mapa',
'mobileTrip.more': 'Mais',
'mobileTrip.newCategory': 'Nova categoria',
'mobileTrip.newReservation': 'Nova reserva',
'mobileTrip.notes': 'Notas',
'mobileTrip.onMap': 'No mapa',
'mobileTrip.profileDriving': 'De carro',
'mobileTrip.profileWalking': 'A pé',
'mobileTrip.renameDay': 'Renomear dia',
'mobileTrip.resBadge': 'Reserva',
'mobileTrip.showOnMap': 'Mostrar no mapa',
'mobileTrip.statDocuments': '{count} arquivos',
'mobileTrip.statPeople': '{count} pessoas',
'mobileTrip.stay': 'Estadia',
'mobileTrip.tapAgainToDelete': 'Toque novamente para excluir',
'mobileTrip.todoOpenCount': '{count} em aberto',
'mobileTrip.travel': 'Viagem',
'mobileTrip.upNext': 'A seguir',
'mobileTrip.viewDetails': 'Ver detalhes',
'mobileTrip.transportsEmpty': 'Nenhum transporte ainda',
};
export default mobileTrip;
+19
View File
@@ -0,0 +1,19 @@
import type { TranslationStrings } from '../types';
const mobileVacay: TranslationStrings = {
'mobileVacay.prevYear': 'Ano anterior',
'mobileVacay.nextYear': 'Próximo ano',
'mobileVacay.prevMonth': 'Mês anterior',
'mobileVacay.nextMonth': 'Próximo mês',
'mobileVacay.usedCount': '{count} usados',
'mobileVacay.leftCount': '{count} restantes',
'mobileVacay.left': 'restantes',
'mobileVacay.modeCompany': 'Empresa',
'mobileVacay.decreaseAllowance': 'Diminuir cota',
'mobileVacay.increaseAllowance': 'Aumentar cota',
'mobileVacay.companyLegend': 'Férias coletivas',
'mobileVacay.editCalendar': 'Editar calendário',
'mobileVacay.viewYear': 'Ver ano',
};
export default mobileVacay;
+5
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.vacay_share.title': 'Calendário Vacay compartilhado',
'notif.vacay_share.text': '{actor} compartilhou o calendário de férias com você',
'notif.collection_invite.title': 'Convite para coleção',
'notif.collection_invite.text': '{actor} convidou você para uma coleção',
'notif.photos_shared.title': 'Fotos compartilhadas',
@@ -25,6 +27,9 @@ const notif: TranslationStrings = {
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
'notif.version_available.title': 'Nova versão disponível',
'notif.version_available.text': 'TREK {version} está disponível',
'notif.replica_failure.title': 'Falha na réplica de armazenamento',
'notif.replica_failure.text': "Falha ao gravar na réplica '{backend}': {op} de {key} — {error}",
'notif.replica_failure.textSuppressed': "Falha ao gravar na réplica '{backend}': {op} de {key} — {error}. Mais {suppressed} falha(s) foram suprimidas desde a última notificação.",
'notif.action.view_trip': 'Ver viagem',
'notif.action.view_collab': 'Ver mensagens',
'notif.action.view_packing': 'Ver bagagem',
+7
View File
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
const oauth: TranslationStrings = {
'oauth.scope.group.trips': 'Viagens',
'oauth.scope.group.places': 'Locais',
'oauth.scope.group.collections': 'Coleções',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Bagagem',
'oauth.scope.group.todos': 'Tarefas',
@@ -26,6 +27,12 @@ const oauth: TranslationStrings = {
'oauth.scope.places:read.description': 'Ler locais, atribuições de dias, tags e categorias',
'oauth.scope.places:write.label': 'Gerenciar locais',
'oauth.scope.places:write.description': 'Criar, atualizar e excluir locais, atribuições e tags',
'oauth.scope.collections:read.label': 'Ver coleções',
'oauth.scope.collections:read.description':
'Ler coleções de lugares salvos, seus lugares, avaliações, etiquetas e membros',
'oauth.scope.collections:write.label': 'Gerenciar coleções',
'oauth.scope.collections:write.description':
'Criar/editar coleções, salvar, avaliar, etiquetar e copiar lugares, e compartilhar listas',
'oauth.scope.atlas:read.label': 'Ver Atlas',
'oauth.scope.atlas:read.description': 'Ler países visitados, regiões e lista de desejos',
'oauth.scope.atlas:write.label': 'Gerenciar Atlas',
+11
View File
@@ -1,6 +1,15 @@
import type { TranslationStrings } from '../types';
const packing: TranslationStrings = {
'packing.actions': 'Ações',
'packing.editDone': 'Concluído',
'packing.personalEmptyHint': 'Sua lista pessoal está vazia — copie itens compartilhados ou adicione os seus.',
'packing.assignMembers': 'Atribuir membros',
'packing.categoryOptions': 'Opções da lista',
'packing.editItem': 'Editar item',
'packing.itemName': 'Nome',
'packing.itemQuantity': 'Quantidade',
'packing.itemWeight': 'Peso (g)',
'packing.title': 'Lista de mala',
'packing.empty': 'A lista de mala está vazia',
'packing.import': 'Importar',
@@ -197,5 +206,7 @@ const packing: TranslationStrings = {
'packing.alsoBring': 'Eu também posso levar',
'packing.alsoBringingStop': 'Não vou levar',
'packing.cloneToMine': 'Copiar para minha lista',
'packing.bagLimit': 'Limite de peso',
'packing.setBagLimit': 'Definir limite',
};
export default packing;
+1
View File
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Custo',
'pdf.preview': 'Pré-visualização do PDF',
'pdf.saveAsPdf': 'Salvar como PDF',
'pdf.pageBreakPerDay': 'Quebra de página por dia',
};
export default pdf;
+40 -2
View File
@@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types';
const places: TranslationStrings = {
'places.addPlace': 'Adicionar lugar/atividade',
'places.importFile': 'Importar arquivo',
'places.trackIndicator': 'Trilha / rota',
'places.sidebarDrop': 'Solte para importar',
'places.importFileHint':
'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.',
@@ -42,7 +43,11 @@ const places: TranslationStrings = {
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
'places.unplanned': 'Não planejados',
'places.planned': 'Planejados',
'places.filterTracks': 'Trilhas',
'places.filterByRating': 'Filtrar por avaliação',
'places.yourRating': 'Sua avaliação',
'places.notRated': 'Ainda sem avaliações',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas as categorias',
'places.categoriesSelected': 'categorias',
@@ -62,6 +67,7 @@ const places: TranslationStrings = {
'places.formLng': 'Longitude (ex.: -46.6333)',
'places.formCategory': 'Categoria',
'places.noCategory': 'Sem categoria',
'places.newCategory': 'Nova categoria',
'places.categoryNamePlaceholder': 'Nome da categoria',
'places.formTime': 'Horário',
'places.startTime': 'Início',
@@ -78,15 +84,47 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Carregando detalhes do lugar…',
'places.osmHint':
'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.osmActive':
'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
'places.osmActive': 'Usando o OpenStreetMap. Uma chave de API do Google adiciona avaliações e horários.',
'places.details.title': 'Detalhes do lugar',
'places.details.empty': 'Escolha um resultado para ver imagens e uma descrição.',
'places.details.loading': 'Carregando detalhes...',
'places.details.error': 'Não foi possível carregar os detalhes.',
'places.details.disabled': 'O enriquecimento está desativado pelo administrador.',
'places.details.noPhotos': 'Nenhuma imagem encontrada para este lugar.',
'places.details.pickImage': 'Escolher uma imagem',
'places.details.description': 'Descrição',
'places.details.adopt': 'Usar este texto',
'places.details.adoptBlocked': 'Esvazie primeiro o campo de descrição',
'places.details.facts': 'Bom saber',
'places.details.nothing': 'Nada encontrado para este lugar.',
'places.details.noKeyTitle': 'Mais detalhes disponíveis',
'places.details.noKeyHint': 'Sem uma chave de API do Google, esta instância usa apenas fontes livres. Peça ao administrador do TREK para adicionar uma para avaliações, horários e fotos.',
'places.details.aboutBrand': 'Sobre a rede',
'places.details.aboutBrandNote': 'Descreve a rede, não esta unidade.',
'places.details.fact.rating': 'Avaliação',
'places.details.fact.cuisine': 'Cozinha',
'places.details.fact.openingHours': 'Horário',
'places.details.fact.menu': 'Cardápio',
'places.details.fact.outdoorSeating': 'Área externa',
'places.details.fact.takeaway': 'Para viagem',
'places.details.fact.delivery': 'Entrega',
'places.details.fact.wheelchair': 'Acesso sem degraus',
'places.details.fact.vegetarian': 'Vegetariano',
'places.details.fact.vegan': 'Vegano',
'places.details.fact.internetAccess': 'Wi-Fi',
'places.categoryCreateError': 'Falha ao criar categoria',
'places.nameRequired': 'Digite um nome',
'places.saveError': 'Falha ao salvar',
'places.createExpenseHint': 'Salva o local e depois abre o editor de despesas.',
'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim',
'places.enrichOnImport': 'Enriquecer lugares via Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
'places.uploadImage': 'Enviar imagem',
'places.changeImage': 'Alterar imagem',
'places.removeImage': 'Remover imagem',
'places.imageUploadError': 'Não foi possível enviar a imagem',
'places.imageRemoveError': 'Não foi possível remover a imagem',
};
export default places;
+3 -2
View File
@@ -29,8 +29,8 @@ const planner: TranslationStrings = {
'planner.noEntries': 'Nenhuma entrada neste dia',
'planner.addPlace': 'Adicionar lugar/atividade',
'planner.addPlaceShort': '+ Adicionar lugar/atividade',
'planner.resPending': 'Reserva pendente · ',
'planner.resConfirmed': 'Reserva confirmada · ',
'planner.resPending': 'Reserva pendente',
'planner.resConfirmed': 'Reserva confirmada',
'planner.notePlaceholder': 'Nota…',
'planner.noteTimePlaceholder': 'Horário (opcional)',
'planner.noteExamplePlaceholder': 'ex.: metrô às 14:30 da estação central, barco do cais 7, pausa para almoço…',
@@ -47,6 +47,7 @@ const planner: TranslationStrings = {
'planner.route': 'Rota',
'planner.optimize': 'Otimizar',
'planner.openGoogleMaps': 'Abrir no Google Maps',
'planner.openCoMaps': 'Abrir no CoMaps',
'planner.selectDayHint': 'Selecione um dia na lista à esquerda para ver o plano do dia',
'planner.noPlacesForDay': 'Nenhum lugar neste dia ainda',
'planner.addPlacesLink': 'Adicionar lugares →',
+7
View File
@@ -58,6 +58,7 @@ const reservations: TranslationStrings = {
'reservations.type.cruise': 'Cruzeiro',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Passeio',
'reservations.type.parking': 'Estacionamento',
'reservations.type.other': 'Outro',
'reservations.type.bus': 'Ônibus',
'reservations.type.ferry': 'Balsa',
@@ -115,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Chegada',
'reservations.span.inTransit': 'Em trânsito',
'reservations.span.pickup': 'Retirada',
'reservations.span.dropOff': 'Entrega',
'reservations.span.return': 'Devolução',
'reservations.span.active': 'Ativo',
'reservations.span.start': 'Início',
@@ -149,6 +151,8 @@ const reservations: TranslationStrings = {
'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.',
'reservations.airtrail.notSynced': 'Não sincronizado',
'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.',
'reservations.airtrail.layoverHint':
'Importado do AirTrail. Um voo de vários trechos com conexão não tem um único voo do AirTrail para sincronizar, então permanece como uma importação única.',
'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.',
'reservations.airtrail.imported': '{count} voo(s) importado(s)',
'reservations.airtrail.skippedDuplicate': '{count} já nesta viagem, ignorado(s)',
@@ -168,5 +172,8 @@ const reservations: TranslationStrings = {
'reservations.removeExpense': 'Remove expense',
'reservations.urlLabel': 'Link',
'reservations.urlPlaceholder': 'https://...',
'reservations.travelers.label': 'Viajantes',
'reservations.travelers.assign': 'Atribuir viajantes',
'reservations.travelers.none': 'Nenhum viajante na viagem ainda.',
};
export default reservations;
+42 -5
View File
@@ -24,7 +24,6 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Modelo de mapa',
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL do modelo de blocos do mapa',
'settings.mapProvider': 'Provedor de mapa',
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
@@ -35,6 +34,11 @@ const settings: TranslationStrings = {
'settings.mapMapboxToken': 'Token de acesso Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
'settings.mapCartoKey': 'Chave de API do CARTO',
'settings.mapCartoKeyHint': "Os mapas base do CARTO exibem uma marca d'água sem chave. Gratuita e sem conta, em",
'settings.mapCartoKeyLink': 'chave de API de mapas base do carto.com',
'settings.mapCartoKeyMissing':
'Este modelo é um mapa base do CARTO. Sem uma chave, o CARTO estampa "API KEY REQUIRED" em cada bloco.',
'settings.mapStyle': 'Estilo do mapa',
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
@@ -80,6 +84,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyVacayShare': 'Compartilhamentos de calendário Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
@@ -97,6 +102,10 @@ const settings: TranslationStrings = {
'settings.about.featureRequest': 'Solicitar recurso',
'settings.about.featureRequestHint': 'Sugira um novo recurso',
'settings.about.wikiHint': 'Documentação e guias',
'settings.about.descriptionManaged':
'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.',
'settings.about.sourceTitle': 'Source code',
'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0',
'settings.about.supporters.badge': 'Apoiadores Mensais',
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
'settings.about.supporters.subtitle':
@@ -147,6 +156,7 @@ const settings: TranslationStrings = {
'settings.avatarUploaded': 'Foto do perfil atualizada',
'settings.avatarRemoved': 'Foto do perfil removida',
'settings.avatarError': 'Falha no envio',
'settings.avatarRemoveError': 'Falha ao remover',
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
'settings.mfa.description':
'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
@@ -299,8 +309,9 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currency': 'Moeda de exibição',
'settings.currencyHint':
'Os valores em Custos são exibidos convertidos para esta moeda apenas para visualização — os valores originais permanecem inalterados.',
'settings.currencyTrip': 'Moeda da viagem',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
@@ -348,11 +359,13 @@ const settings: TranslationStrings = {
'settings.airtrail.test.failed': 'Falha na conexão',
'settings.aiParsing.title': 'Análise por IA',
'settings.aiParsing.hint':
'Use seu próprio modelo de IA para extrair reservas dos arquivos enviados. Isso se aplica apenas quando o administrador não configurou um modelo para toda a instância.',
'Escolha o modelo de IA usado para extrair reservas dos arquivos enviados. Isso se aplica apenas quando o administrador não configurou um modelo para toda a instância.',
'settings.aiParsing.provider': 'Provedor',
'settings.aiParsing.providerLocal': 'Local (Ollama)',
'settings.aiParsing.providerOpenai': 'OpenAI',
'settings.aiParsing.providerAnthropic': 'Anthropic',
'settings.aiParsing.localAdminOnly':
'Um endpoint local (Ollama) é configurado uma única vez para toda a instância nas configurações de administração. Aqui você ainda pode usar sua própria chave da OpenAI ou da Anthropic.',
'settings.aiParsing.model': 'Modelo',
'settings.aiParsing.baseUrl': 'URL base',
'settings.aiParsing.baseUrlHint':
@@ -428,8 +441,31 @@ const settings: TranslationStrings = {
'settings.appearance.example.normal': 'Place names, descriptions',
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barra inferior',
'settings.appearance.mobileNav.hint':
'Escolha quais itens aparecem na barra e quais ficam em “Mais”. O Dashboard vem sempre primeiro.',
'settings.appearance.mobileNav.inBar': 'Na barra',
'settings.appearance.mobileNav.underMore': 'Em “Mais”',
'settings.appearance.mobileNav.moreEmpty': 'Nada aqui ainda — tudo cabe na barra.',
'settings.appearance.mobileNav.pinned': 'Fixado',
'settings.appearance.mobileNav.toMore': 'Mover para “Mais”',
'settings.appearance.mobileNav.toBar': 'Mover para a barra',
'settings.appearance.dashOrder': 'Ordem do painel',
'settings.appearance.dashOrder.hint':
'Reorganize como a lista de viagens e os widgets se empilham no painel do celular. A viagem em destaque fica sempre no topo.',
'settings.appearance.dashOrder.trips': 'Viagens',
'settings.appearance.dashOrder.hidden': 'Oculto',
'settings.general.languageRegion': 'Language & region',
'settings.general.travelMap': 'Travel & map',
'settings.general.startup': 'Inicialização',
'settings.startPage': 'Página inicial',
'settings.startPageDashboard': 'Painel',
'settings.startPageActiveTrip': 'Viagem ativa',
'settings.startPageHint':
'O TREK abre direto na viagem que está acontecendo, ou na próxima que começa. É a mesma viagem que o painel destaca.',
'settings.startTripTab': 'Aba inicial',
'settings.startTripTabHint':
'A aba com que a viagem abre. Se ela pertencer a um complemento desativado, abre a visão de plano.',
// ── Offline (#1135)
'settings.offline.cache.title': 'Cache offline',
@@ -494,7 +530,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Quando',
'settings.pluginActivity.columns.status': 'Resultado',
'settings.alwaysShowRoutes': 'Sempre mostrar rotas de reserva',
'settings.alwaysShowRoutesHint': 'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
'settings.alwaysShowRoutesHint':
'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
};
export default settings;
+138
View File
@@ -0,0 +1,138 @@
import type { TranslationStrings } from '../types';
const storage: TranslationStrings = {
'storage.field.root': 'Diretório raiz',
'storage.help.root': 'Caminho absoluto no servidor onde este backend armazena seus objetos.',
'storage.field.endpoint': 'URL do endpoint',
'storage.help.endpoint':
'URL base do serviço compatível com S3, ex.: https://s3.example.com ou http://127.0.0.1:9000.',
'storage.field.bucket': 'Bucket',
'storage.field.accessKeyId': 'ID da chave de acesso',
'storage.field.secretAccessKey': 'Chave de acesso secreta',
'storage.field.region': 'Região',
'storage.help.region': 'Mantenha o padrão, a menos que seu provedor exija uma região específica.',
'storage.field.keyPrefix': 'Prefixo de chave',
'storage.help.keyPrefix': 'Prefixo opcional adicionado a cada chave de objeto, ex.: trek/prod.',
'storage.field.retries': 'Tentativas',
'storage.field.timeoutMs': 'Tempo limite (ms)',
'storage.field.primary': 'Backend primário',
'storage.field.replicas': 'Réplicas',
'storage.title': 'Armazenamento',
'storage.description': 'Onde o TREK guarda arquivos, fotos e backups enviados. Nada muda até você salvar.',
'storage.loading': 'Carregando…',
'storage.saved': 'Configuração de armazenamento salva',
'storage.save': 'Salvar alterações',
'storage.unsaved': 'Alterações não salvas',
'storage.saveConflict':
'A configuração de armazenamento mudou desde que foi carregada, por isso suas alterações não foram salvas. Descarte-as e recarregue a configuração salva para recomeçar.',
'storage.discardAndReload': 'Descartar minhas alterações e recarregar',
'storage.configError.banner':
'Falha ao carregar as configurações de armazenamento salvas — salvar irá substituí-las: {error}',
'storage.backends.title': 'Backends',
'storage.backends.add': 'Adicionar backend',
'storage.backends.usedBy': 'Usado por: {categories}',
'storage.backends.unused': 'Não atribuído a nenhuma categoria',
'storage.backends.envReadOnly': 'Definido por uma variável de ambiente — somente leitura',
'storage.source.built-in': 'Integrado',
'storage.source.env': 'Ambiente',
'storage.source.settings': 'Configurações',
'storage.type.local': 'Local',
'storage.type.s3': 'S3',
'storage.type.mirror': 'Espelho',
'storage.actions.test': 'Testar',
'storage.actions.edit': 'Editar',
'storage.actions.remove': 'Remover',
'storage.test.running': 'Testando…',
'storage.test.ok': 'Conexão OK',
'storage.test.failed': 'Teste falhou',
'storage.remove.title': 'Remover backend',
'storage.remove.body': 'Remover {name} da configuração? O servidor rejeita o salvamento se algo ainda depender dele.',
'storage.remove.stillAssigned': 'Ainda atribuído a: {categories}',
'storage.form.addTitle': 'Adicionar backend',
'storage.form.editTitle': 'Editar backend',
'storage.form.name': 'Nome',
'storage.form.type': 'Tipo',
'storage.form.apply': 'Aplicar',
'storage.form.cancel': 'Cancelar',
'storage.form.duplicateName': 'Já existe um backend chamado {name}',
'storage.categories.title': 'Categorias',
'storage.categories.default': 'padrão',
'storage.categories.reassignWarning':
'Objetos existentes não são movidos: novos objetos vão para o backend recém-atribuído, os antigos permanecem onde estão.',
'storage.category.files': 'Documentos da viagem',
'storage.category.journey': 'Fotos da jornada',
'storage.category.covers': 'Imagens de capa',
'storage.category.avatars': 'Fotos de perfil',
'storage.category.places': 'Imagens de locais',
'storage.category.photos-google': 'Cache de fotos do Google',
'storage.category.photos-trek': 'Cache de fotos do TREK',
'storage.category.backups': 'Backups',
// What each category stores — rendered under the label in the category map.
'storage.categoryDesc.files':
'Anexos enviados para viagens — passagens, PDFs, confirmações de reserva e arquivos compartilhados no chat da viagem.',
'storage.categoryDesc.journey': 'Fotos e miniaturas anexadas às entradas da jornada.',
'storage.categoryDesc.covers': 'Imagens de capa de viagens e coleções, incluindo capas obtidas do Unsplash.',
'storage.categoryDesc.avatars': 'Fotos de perfil das contas de usuário.',
'storage.categoryDesc.places': 'Imagens anexadas a locais e locais de coleções — enviadas ou importadas.',
'storage.categoryDesc.photos-google':
'Cópias em cache de fotos do Google Places — podem ser buscadas novamente, seguro perdê-las.',
'storage.categoryDesc.photos-trek':
'Fotos em cache do serviço de fotos do TREK usado pelas Fotos (Memórias) — podem ser buscadas novamente, seguro perdê-las.',
'storage.categoryDesc.backups': 'Arquivos de backup do servidor criados pelo painel de Backup ou pela agenda.',
'storage.health.title': 'Integridade',
'storage.health.allClear': 'Nenhuma falha de réplica registrada.',
'storage.health.seedFile':
'Um arquivo semente storage-config.json está presente, mas é ignorado — já existem linhas de configuração. Gerencie o armazenamento aqui.',
'storage.health.failureLine': '{op} de {key} em {backend} falhou: {error}',
// Replicas-on-primary mirror UX (2026-08-20 spec)
'storage.mirror.targets': 'Destinos do espelho',
'storage.mirror.targetsHelp': 'Cada gravação neste backend também é copiada para cada destino selecionado.',
'storage.mirror.latencyNote':
'As réplicas são gravadas uma após a outra durante cada envio — um destino lento ou inacessível torna mais lento todo envio de toda categoria neste backend.',
'storage.mirror.mirroredTo': 'Espelhado para: {targets}',
'storage.mirror.replicaOf': 'Réplica de: {primaries}',
'storage.mirror.cacheWarning':
'Não recomendado: esta categoria contém conteúdo que pode ser buscado novamente — replicá-lo geralmente é um desperdício.',
'storage.mirror.degenerate.duplicate-mirror':
'Um segundo espelho envolve {primary} — o painel gerencia apenas o primeiro; remova este para gerenciar o espelhamento a partir de {primary}.',
'storage.mirror.degenerate.env-primary': 'Envolve um backend definido por variável de ambiente — não editável aqui.',
'storage.mirror.degenerate.missing-primary': 'Referencia um backend que não existe mais.',
'storage.remove.usedAsReplicaBy': 'Usado como réplica por: {primaries}',
// Backfill + usage (backfill/stats/notifications spec)
'storage.sync.now': 'Sincronizar agora',
'storage.sync.running': 'Sincronizando… {done}/{total}',
'storage.sync.counts': '{copied} copiados · {skipped} ignorados · {failed} falharam',
'storage.sync.cancel': 'Cancelar sincronização',
'storage.sync.done': 'Sincronização concluída: {copied} copiados, {deleted} excluídos, {failed} falharam',
'storage.sync.cancelled': 'Sincronização cancelada',
'storage.sync.error': 'Falha na sincronização: {error}',
'storage.sync.prompt': 'Os objetos existentes ainda não foram replicados — sincronizar agora?',
'storage.sync.dismiss': 'Dispensar',
'storage.usage.line': '{objects} objetos · {size}',
'storage.usage.computed': 'Uso calculado {age}',
'storage.usage.never': 'Uso ainda não calculado',
'storage.usage.refresh': 'Atualizar',
'storage.usage.compute': 'Calcular agora',
'storage.usage.legacyNote': 'inclui a biblioteca de fotos legada',
// Category migration (copy → flip → delta sweep)
'storage.migrate.promptTitle': 'Mover objetos existentes para o novo backend?',
'storage.migrate.promptLine': '{category}: {objects} objetos ({size}) de {from} para {to}',
'storage.migrate.promptLineUnknown': '{category}: tamanho desconhecido (uso ainda não calculado) de {from} para {to}',
'storage.migrate.move': 'Mover objetos existentes',
'storage.migrate.routeOnly': 'Apenas rotear novas gravações',
'storage.migrate.running': 'Movendo {category}… {done}/{total}',
'storage.migrate.done': 'Movimentação concluída: {copied} copiados, {skipped} ignorados',
'storage.migrate.doneFailures': '{failed} falharam — esses objetos não foram copiados para o novo backend',
'storage.migrate.failed': 'Falha ao mover: {error} — a categoria não foi alterada',
'storage.migrate.cancelled': 'Movimentação cancelada — nada foi alterado',
'storage.migrate.reclaimable': '{objects} objetos ({size}) permanecem em {from} — recupere manualmente',
'storage.migrate.cancel': 'Cancelar movimentação',
'storage.migrate.promptCancel': 'Cancelar',
'storage.migrate.queued': 'Na fila: {categories}',
'storage.migrate.queueDropped': 'Não foi possível iniciar a próxima migração — a fila restante foi limpa: {categories}',
};
export default storage;
+36
View File
@@ -54,5 +54,41 @@ const system_notice: TranslationStrings = {
'system_notice.v3014_whitespace_collision.title': 'Ação necessária: conflito de conta de usuário',
'system_notice.v3014_whitespace_collision.body':
'A atualização 3.0.14 detectou um ou mais conflitos de nome de usuário ou e-mail causados por espaços em branco no início ou fim dos valores armazenados. As contas afetadas foram renomeadas automaticamente. Verifique os logs do servidor por linhas começando com **[migration] WHITESPACE COLLISION** para identificar quais contas precisam de revisão.',
// 4.0.0 release modal — the release on the left, the note from the maintainer on the right
'system_notice.release_400.eyebrow': 'Atualização instalada',
'system_notice.release_400.tag': 'Versão',
'system_notice.release_400.headline': 'A maior versão que o TREK já teve.',
'system_notice.release_400.intro':
'O TREK ganha um celular e um livro. Dezenove pessoas escreveram esta — e com ela foram uns cento e cinquenta bugs relatados.',
'system_notice.release_400.feature_mobile_title': 'TREK no celular',
'system_notice.release_400.feature_mobile_body':
'Tudo abaixo de 768px agora é uma interface própria — um dock de vidro, seus próprios painéis, seu próprio planejador. Abra o TREK no celular.',
'system_notice.release_400.feature_studio_title': 'TREK Studio',
'system_notice.release_400.feature_studio_badge': 'Beta',
'system_notice.release_400.feature_studio_body':
'O PDF do Journey virou um designer de álbuns de fotos. Ele monta o álbum quando você pede e depois sai do caminho.',
'system_notice.release_400.feature_vacay_title': 'Vacay aprende o resto',
'system_notice.release_400.feature_vacay_body':
'Meios dias, banco de horas e dias flexíveis, férias escolares na grade — e um ano de férias que não precisa começar em janeiro.',
'system_notice.release_400.feature_places_title': 'Lugares que se mostram, arquivos que saem',
'system_notice.release_400.feature_places_body':
'Fotos e descrição se preenchem sozinhas antes de você salvar um lugar. E seus uploads não precisam mais viver no disco onde o TREK roda.',
'system_notice.release_400.footnote':
'E estes são quatro deles. A 4.0.0 traz centenas de outras mudanças, de Collections e Atlas até todo o servidor por baixo.',
'system_notice.release_400.note_eyebrow': 'Uma nota do mantenedor',
'system_notice.release_400.note_title': 'Obrigado por usar o TREK.',
'system_notice.release_400.note_body':
'O TREK começou como uma ferramentinha para as minhas próprias viagens, escrita no tempo livre. E continua sendo: noites, fins de semana, as horas ao lado de um trabalho em tempo integral.\n\nPor um tempo era só eu. Não mais — dezenove pessoas entregaram esta versão, e milhares de pessoas chegaram com estrelas, issues, traduções e pull requests. Sou grato por cada parte disso.',
'system_notice.release_400.promise_label': 'A promessa',
'system_notice.release_400.promise_text':
'O lado open source do TREK continua gratuito, para sempre. Sem planos pagos, sem assinaturas, sem pegadinhas. Prometido.',
'system_notice.release_400.note_body_after':
'A 4.0.0 levou semanas de noites longas — um app de celular, um designer de álbuns, uma migração de servidor, quase tudo escrito entre meia-noite e duas. Não é reclamação: eu amo construir isso. É só a resposta honesta de como uma versão desse tamanho sai de um projeto de tempo livre.',
'system_notice.release_400.note_closing': 'Obrigado por estar aqui.',
'system_notice.release_400.note_signature': '— Maurice',
'system_notice.release_400.support_text':
'O apoio é o que mantém isso de pé — servidores, domínios e as noites longas que viram versões como esta. Se o TREK vale algo para você, um café é o jeito mais direto de manter isso vivo.',
'system_notice.release_400.cta_bmc': 'Buy me a coffee',
'system_notice.release_400.cta_kofi': 'Apoiar no Ko-fi',
};
export default system_notice;
+1
View File
@@ -1,6 +1,7 @@
import type { TranslationStrings } from '../types';
const todo: TranslationStrings = {
'todo.emptyFiltered': 'Nenhuma tarefa corresponde a este filtro',
'todo.subtab.packing': 'Lista de bagagem',
'todo.subtab.todo': 'A fazer',
'todo.completed': 'concluído(s)',

Some files were not shown because too many files have changed in this diff Show More