mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'فئة جديدة...',
|
||||
|
||||
@@ -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': 'اختر مكانًا من مجموعاتك',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'تبديل العرض',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'جارٍ تسجيل الدخول…',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileAdmin: TranslationStrings = {
|
||||
'mobileAdmin.create': 'مستخدم جديد',
|
||||
'mobileAdmin.updateAction': 'تحديث',
|
||||
'mobileAdmin.updateLine': 'الإصدار {version} متاح · لديك {current}',
|
||||
};
|
||||
|
||||
export default mobileAdmin;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileAtlas: TranslationStrings = {
|
||||
'mobileAtlas.visited': 'تمت الزيارة',
|
||||
'mobileAtlas.planned': 'مُخطط',
|
||||
};
|
||||
|
||||
export default mobileAtlas;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileCollections: TranslationStrings = {
|
||||
'mobileCollections.status': 'الحالة',
|
||||
};
|
||||
|
||||
export default mobileCollections;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileNav: TranslationStrings = {
|
||||
'mobileNav.journeySub': 'مذكرات السفر',
|
||||
'mobileNav.collectionsSub': 'الأماكن المحفوظة',
|
||||
'mobileNav.more': 'المزيد',
|
||||
};
|
||||
|
||||
export default mobileNav;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileSettings: TranslationStrings = {
|
||||
'mobileSettings.blurBookingCodesHint': 'إخفاء رموز الحجز حتى تنقر لإظهارها',
|
||||
};
|
||||
|
||||
export default mobileSettings;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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': 'عرض التعبئة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,5 +6,6 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'التكلفة',
|
||||
'pdf.preview': 'معاينة PDF',
|
||||
'pdf.saveAsPdf': 'حفظ كـ PDF',
|
||||
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
|
||||
};
|
||||
export default pdf;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'إضافة أماكن ←',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,6 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const todo: TranslationStrings = {
|
||||
'todo.emptyFiltered': 'لا توجد مهام مطابقة لهذا الفلتر',
|
||||
'todo.subtab.packing': 'قائمة الأمتعة',
|
||||
'todo.subtab.todo': 'المهام',
|
||||
'todo.completed': 'مكتمل',
|
||||
|
||||
@@ -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': 'رحلة بالنقل العام',
|
||||
|
||||
@@ -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': 'تمت إضافة المكان',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileAtlas: TranslationStrings = {
|
||||
'mobileAtlas.visited': 'Visitado',
|
||||
'mobileAtlas.planned': 'Planejado',
|
||||
};
|
||||
|
||||
export default mobileAtlas;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileCollections: TranslationStrings = {
|
||||
'mobileCollections.status': 'Status',
|
||||
};
|
||||
|
||||
export default mobileCollections;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 →',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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
Reference in New Issue
Block a user