mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 20:15:59 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74abaa9d7c | |||
| 091505d17c | |||
| aee275e2b0 | |||
| ef86bf2660 | |||
| 0139c46fb4 | |||
| a0a7fcdbb9 | |||
| 186dd94b61 | |||
| ad721a1ac6 | |||
| 87441f269d | |||
| a0943f8226 |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.0
|
||||
version: 5.0.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.0"
|
||||
appVersion: "5.0.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -212,7 +212,7 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
id="tour-planner-planned-duration"
|
||||
label={t('tours.planner.plannedTotalDuration')}
|
||||
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
|
||||
invalid={planner.plannedDurationInvalid}
|
||||
invalid={planner.plannedDurationInvalid || planner.plannedTotalInvalid}
|
||||
errorId="tour-planner-planned-duration-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setPlannedDurationMinutes}
|
||||
@@ -220,6 +220,10 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
{planner.plannedDurationInvalid && (
|
||||
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
{/* #2869: over a day a stage Save waits, and says why rather than only greying out. */}
|
||||
{!planner.plannedDurationInvalid && planner.plannedTotalInvalid && (
|
||||
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.plannedTotalOverDay')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -337,7 +341,11 @@ export function TourDetailsSection({
|
||||
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(readDetailsOpen);
|
||||
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
|
||||
const forcedOpen =
|
||||
planner.websiteInvalid ||
|
||||
planner.plannedDurationInvalid ||
|
||||
planner.breakAdditionalInvalid ||
|
||||
planner.plannedTotalInvalid;
|
||||
const expanded = open || forcedOpen;
|
||||
const toggle = () => {
|
||||
const next = !expanded;
|
||||
|
||||
@@ -526,6 +526,18 @@ describe('TourPlannerRail', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
|
||||
})
|
||||
|
||||
it('says why Save waits on a tour over a day a stage, and opens the details to say it (#2869)', () => {
|
||||
window.localStorage.clear()
|
||||
render(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, canSave: false,
|
||||
walkingDurationMinutes: 1500, plannedTotalMinutes: 1500, plannedTotalInvalid: true,
|
||||
})} />)
|
||||
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
|
||||
)
|
||||
})
|
||||
|
||||
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
|
||||
render(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
|
||||
|
||||
@@ -135,6 +135,16 @@ describe('useTripCoverPicker', () => {
|
||||
expect(result.current.searchError).toBe('offline');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPCOVER-010b: a server Unsplash turned away says the admin can add the key (#1449)', async () => {
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockRejectedValueOnce({
|
||||
response: { status: 502, data: { error: 'Unsplash refused this server.', code: 'unsplash_key_needed' } },
|
||||
});
|
||||
const { result } = picker();
|
||||
act(() => result.current.setSearchQuery('Lisbon'));
|
||||
await act(() => result.current.search());
|
||||
expect(result.current.searchError).toBe('dashboard.unsplashKeyNeeded');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPCOVER-011: only the latest search applies its results', async () => {
|
||||
let resolveFirst!: (v: { photos: (typeof photo)[] }) => void;
|
||||
vi.spyOn(tripsApi, 'searchCoverImages')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tripsApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import { getApiErrorCode } from '../../utils/apiError';
|
||||
import { normalizeImageFile } from '../../utils/convertHeic';
|
||||
import { refusedOffline } from '../../utils/needsConnection';
|
||||
import { useToast } from '../shared/Toast';
|
||||
@@ -149,7 +150,12 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO
|
||||
if (photos.length === 0) setSearchError(t('dashboard.unsplashNoResults'));
|
||||
} catch (err: unknown) {
|
||||
if (seq !== coverSearchSeq.current) return;
|
||||
setSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError')));
|
||||
// #1449: Unsplash turns a datacenter address away until an admin enters the free key.
|
||||
setSearchError(
|
||||
getApiErrorCode(err) === 'unsplash_key_needed'
|
||||
? t('dashboard.unsplashKeyNeeded')
|
||||
: getApiErrorMessage(err, t('dashboard.coverSearchError'))
|
||||
);
|
||||
} finally {
|
||||
if (seq === coverSearchSeq.current) setSearching(false);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
|
||||
* The book being edited.
|
||||
*
|
||||
* History is snapshot-based rather than patch-based, and deliberately so: a
|
||||
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
|
||||
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
|
||||
* disagree with the document beats a patch stack that can. It is capped so a
|
||||
* snapshot shares every page it did not change with the one before it, so even
|
||||
* a book of hundreds of pages costs one page per step, and a snapshot stack
|
||||
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
|
||||
* long session does not grow without limit.
|
||||
*
|
||||
* Granularity is the part that matters for how it *feels*: a drag is one undo
|
||||
|
||||
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
|
||||
})
|
||||
|
||||
describe('the document as a whole', () => {
|
||||
it('stays within the schema cap on spreads', () => {
|
||||
it('lays out a long journey whole, its last page and back cover after the entries', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
|
||||
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
|
||||
const doc = build({ entries: many })
|
||||
expect(doc.spreads.length).toBeGreaterThan(150)
|
||||
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
|
||||
})
|
||||
|
||||
it('gives every spread and every element an id of its own', () => {
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -25005,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25384,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
|
||||
* every autosave — deliberately, see book-store.schema.ts, because a patch
|
||||
* protocol would need an ordering guarantee and a merge rule per field — and
|
||||
* the contract already caps it at 150 spreads of 90 elements. A real book of
|
||||
* a fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* a book has as many pages as its journey needs. A real book of a
|
||||
* fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* for road geometry, and what the ceiling produces is not an error message
|
||||
* but a save that quietly fails and an editor that says "not saved" without
|
||||
* saying why.
|
||||
*
|
||||
* So the book route is measured against the size a book can actually be.
|
||||
* An auto laid page is two to five kilobytes, so eight megabytes hold a
|
||||
* book of a few thousand pages: the page count has no limit of its own.
|
||||
*
|
||||
* A list file is the same story at a smaller scale. Its contract allows a
|
||||
* megabyte and the import posts it whole, yet a list of a few hundred places
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
import type { AiChatClient } from './ai-chat-client.interface';
|
||||
import { AI_BASE_URLS } from './ai-connections.constants';
|
||||
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
|
||||
import type { ResolvedAiConnection } from './ai-connections.types';
|
||||
import { ChatgptAuthService } from './chatgpt-auth.service';
|
||||
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
|
||||
@@ -67,7 +67,7 @@ export class AiChatClientService {
|
||||
return new OpenAiChatClient(
|
||||
kind,
|
||||
model,
|
||||
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
|
||||
ownServerChatBase(connection.baseUrl),
|
||||
credential.apiKey,
|
||||
adminServerFetch,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,26 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an own server's Chat Completions live. An address with a path, such as
|
||||
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
|
||||
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
|
||||
* llama.cpp and LiteLLM all answer there, while Ollama answers
|
||||
* `/chat/completions` at its root with a 404. Connecting already found the
|
||||
* model list of such an address under the root, so the chat failed where the
|
||||
* connection had worked.
|
||||
*/
|
||||
export function ownServerChatBase(baseUrl: string | null): string {
|
||||
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
|
||||
try {
|
||||
const url = new URL(entered);
|
||||
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
|
||||
} catch {
|
||||
// An address that does not parse fails on the request, as it did before.
|
||||
}
|
||||
return entered;
|
||||
}
|
||||
|
||||
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
|
||||
export function openrouterOAuthAvailable(): boolean {
|
||||
return getAppUrl().startsWith('https://');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
bookSpreadPhotoIds,
|
||||
compareCodeUnits,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDesignSkipped,
|
||||
type BookDocument,
|
||||
@@ -176,7 +175,7 @@ export function applyDesignDraft(
|
||||
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
|
||||
spreads[at] = page;
|
||||
} else {
|
||||
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
|
||||
if (at >= 0) return skip(row.spreadId, 'changed_since');
|
||||
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
|
||||
}
|
||||
applied.push(row.spreadId);
|
||||
|
||||
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
|
||||
baseVersion: draft.baseVersion,
|
||||
expiresAt: new Date(draft.expiresAt).toISOString(),
|
||||
document: draft.doc,
|
||||
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
|
||||
plan: draft.plan.map(planRowOut),
|
||||
selection: draft.selection,
|
||||
};
|
||||
}
|
||||
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
|
||||
expires_at: new Date(draft.expiresAt).toISOString(),
|
||||
plan: rows,
|
||||
more_rows: draft.plan.length - rows.length,
|
||||
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
|
||||
selected: draft.selection,
|
||||
style: draft.style,
|
||||
photos: {
|
||||
used: draft.usedPhotos,
|
||||
|
||||
@@ -86,15 +86,66 @@ export async function reclaimPhotoCache(
|
||||
|
||||
export interface DedupSet {
|
||||
names: Set<string>;
|
||||
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
|
||||
trackNames?: Set<string>;
|
||||
/**
|
||||
* The places with a line, keyed by name and course (`trackKey`): a track to import
|
||||
* matches only these (#2810), and only one that runs the same way (#2870). Absent
|
||||
* where none is read.
|
||||
*/
|
||||
tracks?: Set<string>;
|
||||
coords: Array<{ lat: number; lng: number }>;
|
||||
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
|
||||
externalIds: Set<string>;
|
||||
}
|
||||
|
||||
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
|
||||
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
|
||||
/**
|
||||
* A candidate for the dedup set; `track` when it is a line (a GPX track or route, a
|
||||
* KML path), with that line in `routeGeometry`.
|
||||
*/
|
||||
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean; routeGeometry?: string | null };
|
||||
|
||||
/**
|
||||
* Where a line starts, ends and how far it reaches, to about a hundred metres: two
|
||||
* hikes a watch names alike ("Afternoon Hike") differ here, while the same file read
|
||||
* again gives the same answer however its points were rounded or thinned. Empty for
|
||||
* a line that does not parse.
|
||||
*/
|
||||
function lineCourse(routeGeometry: string | null | undefined): string {
|
||||
let points: unknown;
|
||||
try {
|
||||
points = JSON.parse(routeGeometry ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (!Array.isArray(points)) return '';
|
||||
const line = points.filter(
|
||||
(p): p is [number, number] => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]),
|
||||
);
|
||||
if (line.length === 0) return '';
|
||||
// A loop rather than Math.min(...): a recorded day holds more points than a call takes arguments.
|
||||
let [south, west] = line[0];
|
||||
let [north, east] = line[0];
|
||||
for (const [lat, lng] of line) {
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
west = Math.min(west, lng);
|
||||
east = Math.max(east, lng);
|
||||
}
|
||||
const [first, last] = [line[0], line[line.length - 1]];
|
||||
return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(',');
|
||||
}
|
||||
|
||||
/** An imported row as the dedup set reads it: a line when it carries one. */
|
||||
export function lineCandidate(
|
||||
name: string | null | undefined,
|
||||
row: { lat: number | null; lng: number | null; routeGeometry?: string | null },
|
||||
): DedupCandidate {
|
||||
return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry };
|
||||
}
|
||||
|
||||
/** A track's entry in `DedupSet.tracks`: its normalized name and its course. */
|
||||
function trackKey(name: string, routeGeometry: string | null | undefined): string {
|
||||
return `${name}\u0000${lineCourse(routeGeometry)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a candidate place is already represented in the dedup set.
|
||||
@@ -114,9 +165,14 @@ export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): bo
|
||||
if (strategy.by === 'externalId') {
|
||||
if (dedup.externalIds.has(strategy.id)) return true;
|
||||
} else if (strategy.by === 'name') {
|
||||
// A track is the same as a track of that name, never as a sight of that name:
|
||||
// a watch names a hike after the place it went to (#2810).
|
||||
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
|
||||
// A track is the same as a track of that name that runs the same way, never as a
|
||||
// sight of that name: a watch names a hike after the place it went to (#2810), and
|
||||
// two hikes of one day alike (#2870).
|
||||
if (candidate.track) {
|
||||
if (dedup.tracks?.has(trackKey(strategy.name, candidate.routeGeometry))) return true;
|
||||
} else if (dedup.names.has(strategy.name)) {
|
||||
return true;
|
||||
}
|
||||
} else if (
|
||||
dedup.coords.some(
|
||||
(c) =>
|
||||
@@ -143,19 +199,19 @@ export function dedupSetOf(
|
||||
>,
|
||||
): DedupSet {
|
||||
const names = new Set<string>();
|
||||
const trackNames = new Set<string>();
|
||||
const tracks = new Set<string>();
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
const externalIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const id of externalIdsOf(row)) externalIds.add(id);
|
||||
if (row.name) {
|
||||
names.add(row.name.trim().toLowerCase());
|
||||
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
|
||||
if (row.route_geometry) tracks.add(trackKey(row.name.trim().toLowerCase(), row.route_geometry));
|
||||
} else if (row.lat != null && row.lng != null) {
|
||||
coords.push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
}
|
||||
return { names, trackNames, coords, externalIds };
|
||||
return { names, tracks, coords, externalIds };
|
||||
}
|
||||
|
||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||
@@ -164,7 +220,7 @@ export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet):
|
||||
const normalizedName = normalizePlaceName(place.name);
|
||||
if (normalizedName) {
|
||||
dedup.names.add(normalizedName);
|
||||
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
|
||||
if (place.track) (dedup.tracks ??= new Set()).add(trackKey(normalizedName, place.routeGeometry));
|
||||
} else if (place.lat != null && place.lng != null) {
|
||||
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
buildCategoryNameLookup,
|
||||
escapeLikePattern,
|
||||
dedupSetOf,
|
||||
isPlaceDuplicate,
|
||||
isPlaceDuplicate, lineCandidate,
|
||||
mapWithConcurrency,
|
||||
pickEnrichmentMatch,
|
||||
reclaimPhotoCache,
|
||||
@@ -758,7 +758,7 @@ export class PlacesService {
|
||||
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
return rows.map((wp) => {
|
||||
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
|
||||
const candidate = lineCandidate(wp.name, wp);
|
||||
if (isPlaceDuplicate(candidate, dedup)) return true;
|
||||
trackInsertedInDedupSet(candidate, dedup);
|
||||
return false;
|
||||
@@ -925,7 +925,7 @@ export class PlacesService {
|
||||
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
|
||||
// sees the still-uncommitted row.
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
|
||||
if (isPlaceDuplicate(lineCandidate(wp.name, wp), dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -960,7 +960,7 @@ export class PlacesService {
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
|
||||
trackInsertedInDedupSet(lineCandidate(wp.name, wp), dedup);
|
||||
}
|
||||
|
||||
return { places: created, count: created.length, skipped };
|
||||
@@ -1033,7 +1033,7 @@ export class PlacesService {
|
||||
const fallbackName = `Placemark ${fallbackIndex}`;
|
||||
const name = parsedPlacemark.name || fallbackName;
|
||||
|
||||
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
|
||||
if (isPlaceDuplicate(lineCandidate(name, parsedPlacemark), dedup)) {
|
||||
summary.skippedCount += 1;
|
||||
dupCount++;
|
||||
fallbackIndex += 1;
|
||||
@@ -1079,7 +1079,7 @@ export class PlacesService {
|
||||
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
|
||||
trackInsertedInDedupSet(lineCandidate(name, parsedPlacemark), dedup);
|
||||
summary.createdCount += 1;
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,16 @@ const MAX_COVER_BYTES = 15 * 1024 * 1024;
|
||||
// Unsplash is a third party on the request path; safeFetch adds no deadline of
|
||||
// its own, so both the search and the cover download carry one.
|
||||
const UNSPLASH_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* What a search is told when the keyless web endpoint turns this server away, as it
|
||||
* does a datacenter (VPS) address: the free Access Key is the way out, and the admin
|
||||
* who can enter it is the one to tell (#1449). The code lets the client say it in
|
||||
* the user's language.
|
||||
*/
|
||||
const UNSPLASH_KEY_NEEDED =
|
||||
'Unsplash refused this server. An administrator can add a free Unsplash Access Key in Admin > Settings.';
|
||||
const UNSPLASH_KEY_NEEDED_CODE = 'unsplash_key_needed';
|
||||
|
||||
const COVER_EXT_BY_TYPE: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
@@ -120,10 +130,18 @@ export class UnsplashService {
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
},
|
||||
});
|
||||
// Turned away without a key: a 401 or 403, or an ok answer that is a challenge page, not JSON.
|
||||
const refused = () =>
|
||||
new DomainError(502, UNSPLASH_KEY_NEEDED, {
|
||||
code: UNSPLASH_KEY_NEEDED_CODE,
|
||||
details: { code: UNSPLASH_KEY_NEEDED_CODE },
|
||||
});
|
||||
if (!accessKey && (response.status === 401 || response.status === 403)) throw refused();
|
||||
let data: UnsplashSearchResponse;
|
||||
try {
|
||||
data = (await response.json()) as UnsplashSearchResponse;
|
||||
} catch {
|
||||
if (!accessKey && response.ok) throw refused();
|
||||
throw new DomainError(response.ok ? 502 : response.status, 'Unsplash search unavailable');
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,16 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
let adminId: number;
|
||||
|
||||
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
|
||||
function connectAt(path: string) {
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
return request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}${path}`, model: 'fake-model' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
@@ -110,13 +120,8 @@ beforeAll(async () => {
|
||||
orm = nestApp.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
await setAddonEnabled(orm, 'atlas', true);
|
||||
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
const res = await request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
|
||||
expect(res.status).toBe(200);
|
||||
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -342,4 +347,23 @@ describe('Home assistant e2e', () => {
|
||||
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
|
||||
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
|
||||
});
|
||||
|
||||
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
|
||||
const h = await ownerWithTrips();
|
||||
expect((await connectAt('')).status).toBe(200);
|
||||
try {
|
||||
provider.script = [say('Hello from Ollama.')];
|
||||
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(provider.requests).toHaveLength(1);
|
||||
expect(
|
||||
ofType(res.chunks, 'text-delta')
|
||||
.map((c) => c.delta)
|
||||
.join(''),
|
||||
).toBe('Hello from Ollama.');
|
||||
expect(ofType(res.chunks, 'error')).toEqual([]);
|
||||
} finally {
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,6 +535,22 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
expect(await countRows(orm, Tours)).toBe(1);
|
||||
});
|
||||
|
||||
it('imports two different tracks of the same name, and still skips either one the second time (#2870)', async () => {
|
||||
// A second hike the watch called "Alpine loop" too, somewhere else entirely.
|
||||
const other = TRACK_GPX.replace(/lat="49(\.\d+)?" lon="12/g, (m) => m.replace('49', '47').replace('12', '11'));
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).body).toMatchObject({ skipped: 0 });
|
||||
expect((await previewGpx(OWNER, other)).body.tracks[0]).toMatchObject({ duplicate: false });
|
||||
const second = await importGpx(OWNER, other);
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.body).toMatchObject({ skipped: 0 });
|
||||
expect(second.body.tours).toHaveLength(1);
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect((await importGpx(OWNER, other)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
});
|
||||
|
||||
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
|
||||
// 4.x stored a GPX track as a plain place: a line, no tours row.
|
||||
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
aiHostOf,
|
||||
aiRefusal,
|
||||
openrouterOAuthAvailable,
|
||||
ownServerChatBase,
|
||||
providerRefusal,
|
||||
} from '../../../../src/nest/ai-connections/ai-connections.constants';
|
||||
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
|
||||
@@ -145,6 +146,16 @@ describe('constants', () => {
|
||||
expect(aiHostOf('custom', null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
|
||||
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
|
||||
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
|
||||
expect(ownServerChatBase('not a url')).toBe('not a url');
|
||||
expect(ownServerChatBase(null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
|
||||
vi.stubEnv('APP_URL', 'https://trip.example.org');
|
||||
expect(openrouterOAuthAvailable()).toBe(true);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
|
||||
expect(out.doc.spreads).toEqual(base.spreads);
|
||||
});
|
||||
|
||||
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
|
||||
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const full = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
|
||||
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const long = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
|
||||
const out = applyDesignDraft(full, draft, new Set());
|
||||
const out = applyDesignDraft(long, draft, new Set());
|
||||
|
||||
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
|
||||
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
|
||||
expect([out.applied, out.skipped]).toEqual([['n'], []]);
|
||||
expect(out.doc.spreads).toHaveLength(402);
|
||||
});
|
||||
|
||||
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
|
||||
|
||||
@@ -33,7 +33,9 @@ describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
|
||||
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
|
||||
]);
|
||||
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
|
||||
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true }, dedup)).toBe(true);
|
||||
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true, routeGeometry: '[[1,2],[3,4]]' }, dedup)).toBe(
|
||||
true,
|
||||
);
|
||||
// A pin still matches every place of its name, a track included.
|
||||
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
|
||||
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
|
||||
@@ -46,7 +48,37 @@ describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
|
||||
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
|
||||
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
|
||||
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
|
||||
expect([...(bare.trackNames ?? [])]).toEqual(['lake']);
|
||||
expect([...(bare.tracks ?? [])]).toEqual(['lake\u0000']);
|
||||
});
|
||||
|
||||
it('keeps two tracks of one name apart when they run differently (#2870)', () => {
|
||||
// Two watch hikes, both called "Afternoon Hike": one up the valley, one along the coast.
|
||||
const valley = '[[46.5,8.0,1200],[46.52,8.03,1500],[46.55,8.06,1900]]';
|
||||
const coast = '[[43.7,7.25],[43.72,7.3],[43.74,7.35]]';
|
||||
const dedup = dedupSetOf([{ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, route_geometry: valley }]);
|
||||
expect(
|
||||
isPlaceDuplicate({ name: 'afternoon hike', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast }, dedup),
|
||||
).toBe(false);
|
||||
// The same file again is still the same track, its points rounded or thinned as they may be.
|
||||
const valleyAgain = '[[46.50001,8.00002],[46.55,8.06]]';
|
||||
expect(
|
||||
isPlaceDuplicate({ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, track: true, routeGeometry: valleyAgain }, dedup),
|
||||
).toBe(true);
|
||||
|
||||
// Within one file: the first of the two goes in, the second is not taken for it.
|
||||
const batch = emptyDedup();
|
||||
const first = { name: 'Day 1', lat: 46.5, lng: 8.0, track: true, routeGeometry: valley };
|
||||
const second = { name: 'Day 1', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast };
|
||||
expect(isPlaceDuplicate(first, batch)).toBe(false);
|
||||
trackInsertedInDedupSet(first, batch);
|
||||
expect(isPlaceDuplicate(second, batch)).toBe(false);
|
||||
trackInsertedInDedupSet(second, batch);
|
||||
expect(isPlaceDuplicate({ ...second }, batch)).toBe(true);
|
||||
|
||||
// A line that does not parse falls back to the name alone, on both sides.
|
||||
const broken = dedupSetOf([{ name: 'Loop', lat: 1, lng: 2, route_geometry: 'not json' }]);
|
||||
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: '{}' }, broken)).toBe(true);
|
||||
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: coast }, broken)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a named place case- and whitespace-insensitively', () => {
|
||||
|
||||
@@ -418,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
});
|
||||
|
||||
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
|
||||
// The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870).
|
||||
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg></trk></gpx>`);
|
||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk></gpx>`);
|
||||
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
|
||||
broadcast.mockClear();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
|
||||
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service';
|
||||
import { asLegacyResult } from '../../../helpers/domain-error';
|
||||
import { makeStorageFixture } from '../../../helpers/storage-fixture';
|
||||
@@ -40,6 +41,9 @@ const getUnsplashKey = svc.getUnsplashKey.bind(svc);
|
||||
const saveUnsplashCover = svc.saveUnsplashCover.bind(svc);
|
||||
const isUnsplashCoverUrl = svc.isUnsplashCoverUrl.bind(svc);
|
||||
|
||||
const KEY_NEEDED =
|
||||
'Unsplash refused this server. An administrator can add a free Unsplash Access Key in Admin > Settings.';
|
||||
|
||||
const ORIGINAL_UNSPLASH_ENV = process.env.UNSPLASH_ACCESS_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -99,13 +103,39 @@ describe('unsplashService.searchUnsplashPhotos', () => {
|
||||
it('UNSPLASH-016: a 200 carrying unparseable JSON becomes a 502, not a crash', async () => {
|
||||
// The web endpoint answers 200 with an HTML challenge page when it decides the
|
||||
// caller is a datacenter (#1449), so a parse failure on an ok response is an
|
||||
// upstream problem, not the caller's.
|
||||
// upstream problem, not the caller's: without a key, the key is the way out.
|
||||
const bad = fakeRes({ ok: true, type: 'text/html' });
|
||||
(bad as { json: () => Promise<unknown> }).json = async () => {
|
||||
throw new SyntaxError('Unexpected token < in JSON');
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
|
||||
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 502 });
|
||||
expect(await searchUnsplashPhotos('paris')).toMatchObject({ error: KEY_NEEDED, status: 502 });
|
||||
// With a key the official API answered, so no key is named.
|
||||
expect(await searchUnsplashPhotos('paris', 9, 'key')).toEqual({
|
||||
error: 'Unsplash search unavailable',
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('UNSPLASH-016b: a keyless search turned away with 401 or 403 says the Access Key is the way out (#1449)', async () => {
|
||||
const refusedWith = (status: number) => {
|
||||
const refused = fakeRes({ ok: false, status, type: 'text/plain' });
|
||||
(refused as { json: () => Promise<unknown> }).json = async () => {
|
||||
throw new SyntaxError('Unexpected token A in JSON');
|
||||
};
|
||||
return refused;
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(refusedWith(401)).mockResolvedValueOnce(refusedWith(403)));
|
||||
const errors = [
|
||||
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
|
||||
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
|
||||
];
|
||||
for (const err of errors) {
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
expect((err as DomainError).getStatus()).toBe(502);
|
||||
expect(err).toMatchObject({ publicMessage: KEY_NEEDED, code: 'unsplash_key_needed' });
|
||||
expect((err as DomainError).toBody()).toEqual({ error: KEY_NEEDED, code: 'unsplash_key_needed' });
|
||||
}
|
||||
});
|
||||
|
||||
it('UNSPLASH-017: a failing response with unparseable JSON keeps its own status', async () => {
|
||||
@@ -134,7 +164,8 @@ describe('unsplashService.searchUnsplashPhotos', () => {
|
||||
fakeRes({ ok: false, status: 401, type: 'application/json', json: { error: 'Bad credentials' } }),
|
||||
),
|
||||
);
|
||||
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Bad credentials', status: 401 });
|
||||
// A key the official API refuses is named by Unsplash itself.
|
||||
expect(await searchUnsplashPhotos('paris', 9, 'stale-key')).toEqual({ error: 'Bad credentials', status: 401 });
|
||||
});
|
||||
|
||||
it('UNSPLASH-004: returns normalised photos on success and drops entries missing a url/thumb', async () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
bookPageOpSchema,
|
||||
bookSpreadIdSchema,
|
||||
} from '../../book/book-ops.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardText } from './card-parts';
|
||||
|
||||
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
|
||||
export const bookCardPageSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
/** Where it stands in the book, the cover at 0. */
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
index: z.number().int().min(0),
|
||||
role: bookSpreadSchema.shape.role.unwrap(),
|
||||
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
|
||||
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
number: z.number().int().min(1).nullable(),
|
||||
/** Photos placed on it. */
|
||||
photos: cardCountSchema,
|
||||
});
|
||||
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
|
||||
/** How many pages the change adds or deletes; for a new book, its pages. */
|
||||
count: cardCountSchema,
|
||||
/** Where a moved page lands among the double pages, from 1. */
|
||||
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
movedTo: z.number().int().min(1).nullable(),
|
||||
/**
|
||||
* `edit_book_page`'s operations, to draw the page after where the card has
|
||||
* no `after` or the Studio's book moved on from `baseVersion`; empty for
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookElementId } from '../book-ids';
|
||||
import { blankBook, blankSpread } from '../book-pages';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import {
|
||||
STATIONS_PER_PAGE,
|
||||
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
|
||||
version: 1,
|
||||
title: input.title.slice(0, MAX_BOOK_TITLE),
|
||||
page: withFirstPage(input.page),
|
||||
spreads: spreads.slice(0, MAX_SPREADS),
|
||||
spreads,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,21 @@ describe('the auto layout moved from the Studio', () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('AUTOPAR-005: lays out every entry of a long journey, the last page and the back cover after them', () => {
|
||||
const fx = fixtures.A as Fixture;
|
||||
const entries = Array.from({ length: 40 }, (_, round) =>
|
||||
fx.input.entries.map((e) => ({ ...e, id: e.id + 100000 * (round + 1) })),
|
||||
).flat();
|
||||
let n = 0;
|
||||
const doc = buildBook({ ...fx.input, entries, newId: (p) => `${p}-c${++n}` });
|
||||
expect(doc.spreads.length).toBeGreaterThan(300);
|
||||
expect(doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
const laid = new Set(doc.spreads.map((s) => s.entryId));
|
||||
const withPages = new Set(buildBook({ ...fx.input, newId: (p) => `${p}-d${++n}` }).spreads.map((s) => s.entryId));
|
||||
const expected = entries.filter((e) => withPages.has(e.id % 100000));
|
||||
expect(expected.every((e) => laid.has(e.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOPAR-004: counts the day of an entry in calendar days, a change to summer time between', () => {
|
||||
const stats: JourneyStats = {
|
||||
journeyId: 1,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BOOK_DESIGN_LIMITS,
|
||||
applyBookDesignRequestSchema,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
@@ -70,10 +69,9 @@ describe('the design tools', () => {
|
||||
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
|
||||
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
|
||||
it('BKDTOOL-004: the selection is a closed list of page ids, as long as the book it picks from', () => {
|
||||
const ids = Array.from({ length: 1200 }, (_, i) => `sp-${i}`);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
bookWriteResultSchema,
|
||||
editBookPageArgs,
|
||||
} from './book-ops.schema';
|
||||
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
|
||||
import { BOOK_FONTS_IDS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
|
||||
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
|
||||
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
|
||||
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
|
||||
@@ -34,8 +34,6 @@ export const BOOK_DESIGN_LIMITS = {
|
||||
notes: 20,
|
||||
/** Pages of the book an apply answers with; every applied id is still named. */
|
||||
pages: 24,
|
||||
/** Pages a selection names at most: every page of a book and every page it drops. */
|
||||
selection: MAX_SPREADS * 2,
|
||||
/** Revisions a journey keeps, the newest first. */
|
||||
revisions: 5,
|
||||
} as const;
|
||||
@@ -82,11 +80,7 @@ export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSc
|
||||
/** One page of a design's plan, as an answer and the draft route carry it. */
|
||||
export const bookDesignPlanRowSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS * 2),
|
||||
index: z.number().int().min(0),
|
||||
action: z.enum(BOOK_DESIGN_ACTIONS),
|
||||
beat: z.enum(BOOK_DESIGN_BEATS),
|
||||
chapter: z.string().max(80).nullable(),
|
||||
@@ -121,7 +115,7 @@ export const bookDesignAnswerSchema = z.object({
|
||||
/** Plan rows past those listed. */
|
||||
more_rows: z.number().int().nonnegative(),
|
||||
/** The pages apply_book_design writes unless the person takes some out. */
|
||||
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selected: z.array(bookSpreadIdSchema),
|
||||
style: bookDesignStyleSchema,
|
||||
photos: z.object({
|
||||
used: z.number().int().nonnegative(),
|
||||
@@ -159,19 +153,19 @@ export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
|
||||
design_id: bookDesignIdSchema,
|
||||
/** The revision that takes the apply back; null when the journey had no book before it. */
|
||||
revision_id: idSchema.nullable(),
|
||||
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
applied: z.array(bookSpreadIdSchema),
|
||||
skipped: z.array(bookDesignSkippedSchema),
|
||||
});
|
||||
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
|
||||
|
||||
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
|
||||
export const bookDesignSelectionRequestSchema = z.strictObject({
|
||||
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
spreadIds: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
|
||||
|
||||
export const bookDesignSelectionResponseSchema = z.object({
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selection: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
|
||||
|
||||
@@ -181,7 +175,7 @@ export const bookDesignDraftResponseSchema = z.object({
|
||||
baseVersion: version.nullable(),
|
||||
expiresAt: z.string(),
|
||||
document: bookDocumentSchema,
|
||||
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
plan: z.array(bookDesignPlanRowSchema),
|
||||
selection: z.array(bookSpreadIdSchema),
|
||||
});
|
||||
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { bookElementPhotoIds, checkRestorablePhotos } from './book-photo-refs';
|
||||
import { bookLayoutFromSpread, nextBookLayoutName } from './book-saved-layouts';
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS,
|
||||
MAX_SPREADS,
|
||||
bookLayoutSchema,
|
||||
type BookDocument,
|
||||
type BookLayout,
|
||||
@@ -107,9 +106,6 @@ export function duplicateBookPage(doc: BookDocument, args: unknown, ctx: BookOps
|
||||
if (!canEditSpread(source)) {
|
||||
throw failOp('protected_spread', `${source.id} is the ${source.role} page and cannot be copied`, null);
|
||||
}
|
||||
if (doc.spreads.length + 1 > MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const fresh = <E extends { id: string; kind: string }>(e: E): E => ({ ...e, id: newId(e.kind.charAt(0)) });
|
||||
const copy = checkedSpread(
|
||||
{ ...source, id: newId('sp'), elements: source.elements.map(fresh), parked: source.parked.map(fresh) },
|
||||
|
||||
@@ -32,7 +32,6 @@ export const BOOK_OP_ERROR_CODES = [
|
||||
'layout_not_found',
|
||||
'layout_role',
|
||||
'too_many_elements',
|
||||
'too_many_spreads',
|
||||
'parked_full',
|
||||
'empty_frame',
|
||||
'caption_missing',
|
||||
|
||||
@@ -20,7 +20,6 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
|
||||
import { bookPaintInSchema } from './book-paint.schema';
|
||||
import {
|
||||
MAX_BOOK_TITLE,
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
@@ -273,11 +272,7 @@ export const addBookPagesArgs = {
|
||||
};
|
||||
export const moveBookPageArgs = {
|
||||
spreadId: bookSpreadIdSchema,
|
||||
toIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
toIndex: z.number().int().min(0),
|
||||
expectedVersion,
|
||||
};
|
||||
export const deleteBookPagesArgs = {
|
||||
@@ -302,11 +297,7 @@ export const createBookArgs = {
|
||||
* it goes back when it is gone, a page still in the book is replaced where it is.
|
||||
*/
|
||||
export const bookPreviousSpreadSchema = z.object({
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
index: z.number().int().min(0),
|
||||
spread: bookSpreadSchema,
|
||||
});
|
||||
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
|
||||
@@ -625,7 +616,7 @@ export const bookWriteResultSchema = z.object({
|
||||
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
|
||||
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
|
||||
/** The pages the write touched as it left them, for an undo that checks only those (`expectedPages`). */
|
||||
page_digests: z.array(bookPageDigestSchema).max(MAX_SPREADS).optional(),
|
||||
page_digests: z.array(bookPageDigestSchema).optional(),
|
||||
from_index: z.number().int().nonnegative().optional(),
|
||||
/** From `set_book_setup`: the setup as it was, to set it back. */
|
||||
previous_setup: bookPageSetupSchema.optional(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { BookEditResult } from './book-ops-core';
|
||||
import { addBookPages, deleteBookPages, moveBookPage, restoreBookPage, setBookCover } from './book-page-ops';
|
||||
import { blankBook, blankSpread, bookPageSetupForPreset, canEditSpread, innerSpreadBounds } from './book-pages';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
bookDocumentSchema,
|
||||
bookPageSetupSchema,
|
||||
bookPhotoElementSchema,
|
||||
@@ -129,10 +128,13 @@ describe('add, move and delete', () => {
|
||||
expect(refused(stray)).toBe('entry_not_found');
|
||||
});
|
||||
|
||||
it('BOOKPG-007: refuses an unknown page, more than ten pages, and a book at its page cap', () => {
|
||||
it('BOOKPG-007: refuses an unknown page and more than ten pages, and grows a book of any length', () => {
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-nope' }))).toBe('spread_not_found');
|
||||
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 11 }))).toBe('invalid_op');
|
||||
expect(refused(addBookPages(book(MAX_SPREADS - 4), { afterSpreadId: 'sp-in1' }))).toBe('too_many_spreads');
|
||||
const long = book(400);
|
||||
const grown = ok(addBookPages(long, { afterSpreadId: 'sp-in400', count: 10 }, { newId }));
|
||||
expect(grown.spreads).toHaveLength(long.spreads.length + 10);
|
||||
expect(grown.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
});
|
||||
|
||||
it('BOOKPG-008: moves an inner spread within the inner range', () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
|
||||
import { bookSpreadPhotoIds, checkRestorablePhotos } from './book-photo-refs';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
bookSpreadSchema,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
@@ -57,9 +56,6 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
|
||||
return caught(() => {
|
||||
const after = indexOf(doc, afterSpreadId);
|
||||
if (entryId !== undefined) journeyEntry(entryId, ctx, null);
|
||||
if (doc.spreads.length + count > MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const at = Math.min(Math.max(after + 1, first), limit);
|
||||
const added = Array.from({ length: count }, () =>
|
||||
@@ -184,9 +180,6 @@ function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocu
|
||||
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
|
||||
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
|
||||
}
|
||||
if (doc.spreads.length >= MAX_SPREADS) {
|
||||
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
|
||||
}
|
||||
const { first, limit } = innerSpreadBounds(doc.spreads);
|
||||
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
|
||||
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
|
||||
|
||||
@@ -20,10 +20,10 @@ import { z } from 'zod';
|
||||
*
|
||||
* ── 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
|
||||
* Not a patch. A book is a few kilobytes of JSON a page, a few megabytes for
|
||||
* the longest journey's, 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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
|
||||
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
|
||||
* bounds, where the salvage pass reads it too.
|
||||
*/
|
||||
export const MAX_SPREADS = 150;
|
||||
export const MAX_BOOK_TITLE = 200;
|
||||
export const MAX_TEXT_LENGTH = 8000;
|
||||
/** Codes on a map or a country page, and the names printed beside them. */
|
||||
@@ -1122,7 +1121,11 @@ const bookDocumentObject = z.object({
|
||||
schemaRev: z.number().int().min(1).optional().catch(undefined),
|
||||
title: z.string().max(MAX_BOOK_TITLE).default(''),
|
||||
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
|
||||
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
|
||||
/**
|
||||
* As many as the journey needs: a long trip's book runs to hundreds of pages,
|
||||
* and the save route's body limit is the only bound on its size.
|
||||
*/
|
||||
spreads: z.array(bookSpreadSchema).default([]),
|
||||
/** The book's own layouts (#2316). Optional so a document from before still reads. */
|
||||
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
|
||||
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { BookPhotoInfo } from '../book-ops-core';
|
||||
import { blankBook } from '../book-pages';
|
||||
import { isStale } from '../book-travel';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
bookPhotoElementSchema,
|
||||
@@ -367,10 +366,10 @@ describe('design_book', () => {
|
||||
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
|
||||
});
|
||||
|
||||
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
|
||||
it('BOOKDSN-014: designs every page beside hundreds of kept pages, the closing pages kept', () => {
|
||||
const stored = storedBook();
|
||||
const lockedPage = stored.spreads[2] as BookSpread;
|
||||
const many = Array.from({ length: MAX_SPREADS - 6 }, (_, i): BookSpread => ({
|
||||
const many = Array.from({ length: 300 }, (_, i): BookSpread => ({
|
||||
...lockedPage,
|
||||
id: `sp-l${i}`,
|
||||
elements: lockedPage.elements.map((e) => ({ ...e, id: `t-l${i}` })),
|
||||
@@ -383,10 +382,10 @@ describe('design_book', () => {
|
||||
for (const style of ['minimal', 'classic'] as const) {
|
||||
const result = designBook(full, input('rich'), { style });
|
||||
checkStructure(result);
|
||||
expect(result.doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
|
||||
expect(inner(result.doc).length).toBeLessThanOrEqual(MAX_SPREADS - 4);
|
||||
expect(inner(result.doc).length).toBeGreaterThan(many.length);
|
||||
expect(many.every((p) => result.doc.spreads.some((s) => s.id === p.id))).toBe(true);
|
||||
expect(result.notes.length).toBeGreaterThan(0);
|
||||
expect(result.doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
|
||||
expect(result.notes.some((n) => n.includes('left out'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
|
||||
import type { BookFinding } from '../book-ops.schema';
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import type { BookTravelContext } from '../book-travel';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import type { PhotoPickCandidate } from '../photo-pick';
|
||||
import { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing';
|
||||
import { composeDesignedInner, type FramingNeeds } from './design-inner';
|
||||
import { isLivelyBeat } from './design-lively-pacing';
|
||||
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { applyPageOverrides } from './design-overrides';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import {
|
||||
@@ -140,23 +140,6 @@ function classicOnly(d: BookDesignDirectives): boolean {
|
||||
return d.chapters === 'entry' && d.density === 'balanced';
|
||||
}
|
||||
|
||||
/**
|
||||
* The designed pages within the spread cap: inner pages past `room` are left
|
||||
* off the end (the framing pages always stay), and the note says how many.
|
||||
*/
|
||||
function withinRoom(designed: readonly DesignedSpread[], room: number, notes: string[]): DesignedSpread[] {
|
||||
const inner = designed.filter((d) => d.spread.role === 'inner');
|
||||
const over = inner.length - Math.max(0, room);
|
||||
if (over <= 0) return [...designed];
|
||||
const cut = new Set(inner.slice(inner.length - over).map((d) => d.spread.id));
|
||||
notes.push(`the book holds ${MAX_SPREADS} spreads, so the last ${over} designed pages are left out`);
|
||||
return designed.filter((d) => !cut.has(d.spread.id));
|
||||
}
|
||||
|
||||
/** Inner spreads the book has room for once the stored pages it keeps are counted. */
|
||||
const roomFor = (doc: BookDocument | null, keep: BookDesignDirectives['keep']) =>
|
||||
MAX_SPREADS - 4 - (doc?.spreads ?? []).filter((s) => s.role === 'inner' && keepsStored(s, keep)).length;
|
||||
|
||||
/** The travel context the travel elements read: the journey's figures, route, entries and labels. */
|
||||
function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTravelContext {
|
||||
return {
|
||||
@@ -205,14 +188,13 @@ function classicDesign(
|
||||
chapter: entries.find((e) => e.id === spread.entryId)?.title ?? null,
|
||||
findings: pageFindings(spread, page, input.photos),
|
||||
}));
|
||||
const designed = withinRoom(all, roomFor(doc, directives.keep), notes);
|
||||
const merged = mergeDesign(doc, designed, directives.keep);
|
||||
const merged = mergeDesign(doc, all, directives.keep);
|
||||
const finalDoc: BookDocument = {
|
||||
...built,
|
||||
spreads: merged.spreads,
|
||||
...(doc?.layouts ? { layouts: doc.layouts } : {}),
|
||||
};
|
||||
const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
const residual = all.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
return {
|
||||
doc: finalDoc,
|
||||
plan: merged.plan,
|
||||
@@ -274,7 +256,7 @@ export function designBook(
|
||||
notes: [],
|
||||
art,
|
||||
};
|
||||
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
|
||||
const needs: FramingNeeds = { perStations: stationsPerSpread(c) };
|
||||
const inner = composeDesignedInner(c, placed, choice, input, needs);
|
||||
|
||||
const words = {
|
||||
@@ -547,8 +529,7 @@ function templatedDesign(
|
||||
raw: BookDesignDirectivesInput,
|
||||
): BookDesignResult {
|
||||
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
|
||||
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
|
||||
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
|
||||
const inner = composeTemplatedInner(c, placed, choice, input, { archetype, calm, rest });
|
||||
const usedPhotos = [...choice.byEntry.values()].flat();
|
||||
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
|
||||
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
|
||||
@@ -569,8 +550,7 @@ interface TemplatedParts {
|
||||
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
|
||||
const { c, finish, placed, notes } = parts;
|
||||
const { look, directives, page } = c;
|
||||
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
|
||||
const laid = mergeDesign(doc, designed, directives.keep);
|
||||
const laid = mergeDesign(doc, parts.done, directives.keep);
|
||||
const built: BookDocument = {
|
||||
version: 1,
|
||||
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
|
||||
@@ -586,7 +566,7 @@ function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts
|
||||
: { doc: built, plan: laid.plan, notes: [] };
|
||||
const residual = overrides.length
|
||||
? redesignedFindings(merged.doc, merged.plan, input.photos)
|
||||
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
: parts.done.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
const judged = briefOf(c);
|
||||
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
|
||||
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
|
||||
import { bookPageOpSchema } from '../book-ops.schema';
|
||||
import { bookLockupIdSchema } from '../book-text-ops.schema';
|
||||
import { MAX_SPREADS } from '../book.schema';
|
||||
import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -97,12 +96,7 @@ const overrideTemplateSchema = z
|
||||
|
||||
export const bookDesignPageOverrideSchema = z
|
||||
.strictObject({
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(MAX_SPREADS - 1)
|
||||
.describe('The page by its index in the plan design_book answered; the cover is 0.'),
|
||||
index: z.number().int().min(2).describe('The page by its index in the plan design_book answered; the cover is 0.'),
|
||||
beat: z
|
||||
.enum(BOOK_DESIGN_OVERRIDE_BEATS)
|
||||
.describe(
|
||||
|
||||
@@ -41,8 +41,6 @@ import { decorateSpread } from './design-stickers';
|
||||
*/
|
||||
|
||||
export interface FramingNeeds {
|
||||
/** Inner spreads the book has room for, the kept stored pages taken off. */
|
||||
room: number;
|
||||
/** Bare stops one stations spread holds. */
|
||||
perStations: number;
|
||||
}
|
||||
@@ -70,9 +68,8 @@ function framingInner(c: ComposeCtx): FramingKind[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The most inner spreads the design may make: `pages.max`, and never past the room the book has. */
|
||||
const innerCap = (c: ComposeCtx, needs: FramingNeeds) =>
|
||||
Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
|
||||
/** The most inner spreads the design may make: `pages.max` when the directives set one, otherwise as many as it needs. */
|
||||
const innerCap = (c: ComposeCtx) => Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
|
||||
|
||||
/**
|
||||
* The framing pages that fit beside at least one chapter spread: past the cap
|
||||
@@ -316,7 +313,7 @@ export function composeDesignedInner(
|
||||
): InnerResult {
|
||||
const notes: string[] = [];
|
||||
const leftOut: BookDesignLeftOut[] = [];
|
||||
const cap = innerCap(c, needs);
|
||||
const cap = innerCap(c);
|
||||
const wanted = framingInner(c);
|
||||
const framing = fittingFraming(wanted, cap);
|
||||
if (framing.length < wanted.length) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const hasLock = (spread: BookSpread) => spread.elements.some((e) => e.locked);
|
||||
* inner page only while the person keeps locked pages); with `edited` also a
|
||||
* page made by hand (no entry behind it, not blank).
|
||||
*/
|
||||
export function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
|
||||
function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
|
||||
if (spread.role !== 'inner') return hasLock(spread);
|
||||
if (keep === 'nothing') return false;
|
||||
if (hasLock(spread)) return true;
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
{
|
||||
"A/fabric-classic/head": "74bb0439e4575ed7",
|
||||
"A/fabric-classic/plan": "95b55b052b9ff07b",
|
||||
"A/fabric-classic/spread/0": "a4d090166287cbe5",
|
||||
"A/fabric-classic/spread/0": "36ce28aa969ee78e",
|
||||
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
|
||||
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
|
||||
"A/fabric-classic/spread/11": "26253da4f1f688a0",
|
||||
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
|
||||
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
|
||||
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
|
||||
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
|
||||
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
|
||||
"A/fabric-classic/spread/17": "de849a3986a487c1",
|
||||
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
|
||||
"A/fabric-classic/spread/19": "9df44ff280b76f64",
|
||||
"A/fabric-classic/spread/10": "f6f5a5bfbb97c674",
|
||||
"A/fabric-classic/spread/11": "c1e942bd73455a86",
|
||||
"A/fabric-classic/spread/12": "c390827640a1bc65",
|
||||
"A/fabric-classic/spread/13": "f49a2ec83099a556",
|
||||
"A/fabric-classic/spread/14": "f13bb412f61a778c",
|
||||
"A/fabric-classic/spread/15": "7ec17a9b9e9955f8",
|
||||
"A/fabric-classic/spread/16": "029964006e41bbdb",
|
||||
"A/fabric-classic/spread/17": "78ae60620ae287a3",
|
||||
"A/fabric-classic/spread/18": "c73e813888a28cd1",
|
||||
"A/fabric-classic/spread/19": "d98cc9a891bf2141",
|
||||
"A/fabric-classic/spread/2": "8278d3c2507527f9",
|
||||
"A/fabric-classic/spread/20": "972976260c457cd3",
|
||||
"A/fabric-classic/spread/21": "9102a5fa147693fe",
|
||||
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
|
||||
"A/fabric-classic/spread/20": "e1dfa3eeb056189b",
|
||||
"A/fabric-classic/spread/21": "b18be5f5e1696caf",
|
||||
"A/fabric-classic/spread/22": "98a8cab370aeb8fa",
|
||||
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
|
||||
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
|
||||
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
|
||||
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
|
||||
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
|
||||
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
|
||||
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
|
||||
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
|
||||
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
|
||||
"A/fabric-classic/spread/4": "d0eba1475d8d9f04",
|
||||
"A/fabric-classic/spread/5": "468dc734d5aae63f",
|
||||
"A/fabric-classic/spread/6": "776a926951bfdbe5",
|
||||
"A/fabric-classic/spread/7": "16f3ec7630e52f5d",
|
||||
"A/fabric-classic/spread/8": "f64487735a6b5be1",
|
||||
"A/fabric-classic/spread/9": "70dba74ad0223875",
|
||||
"A/midnight/head": "ce0d82281cbc3e1f",
|
||||
"A/midnight/plan": "7fde812131c9b570",
|
||||
"A/midnight/spread/0": "409beb5fbb1accce",
|
||||
"A/midnight/spread/0": "1ea6071685899f8f",
|
||||
"A/midnight/spread/1": "d9f5d78659332b45",
|
||||
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
|
||||
"A/midnight/spread/11": "888c0c66730d32dc",
|
||||
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
|
||||
"A/midnight/spread/13": "fc0673a56f5b4d4b",
|
||||
"A/midnight/spread/14": "33db0be88fd469a7",
|
||||
"A/midnight/spread/15": "72105c668088bc4c",
|
||||
"A/midnight/spread/16": "bd11f4dc18e03108",
|
||||
"A/midnight/spread/17": "2e7a0f09822c3556",
|
||||
"A/midnight/spread/18": "f4264c3ee121b8d1",
|
||||
"A/midnight/spread/19": "4f27a48a457d95bb",
|
||||
"A/midnight/spread/10": "b42ae04363ff591c",
|
||||
"A/midnight/spread/11": "a7271ec809fb43f3",
|
||||
"A/midnight/spread/12": "e2a46e3248f9f7f2",
|
||||
"A/midnight/spread/13": "674814c68bdfc64e",
|
||||
"A/midnight/spread/14": "9131f08ae5ee964d",
|
||||
"A/midnight/spread/15": "516667160a8ea844",
|
||||
"A/midnight/spread/16": "7f572ace66e550eb",
|
||||
"A/midnight/spread/17": "f75b6c80be848ac6",
|
||||
"A/midnight/spread/18": "029d7d07ce5cb3c3",
|
||||
"A/midnight/spread/19": "14c04aa51da497cd",
|
||||
"A/midnight/spread/2": "b00839cd0dc1451b",
|
||||
"A/midnight/spread/20": "dff6e523732c8481",
|
||||
"A/midnight/spread/21": "26d666dde7be6641",
|
||||
"A/midnight/spread/20": "ea8aa8da12cad00a",
|
||||
"A/midnight/spread/21": "68057d81fd4903b4",
|
||||
"A/midnight/spread/22": "eba329fd185958b3",
|
||||
"A/midnight/spread/23": "9664fbd24505a632",
|
||||
"A/midnight/spread/3": "742dfb4105263101",
|
||||
"A/midnight/spread/4": "ea9613c918951560",
|
||||
"A/midnight/spread/5": "8aa571b4d51a5234",
|
||||
"A/midnight/spread/6": "2275359a3c03f457",
|
||||
"A/midnight/spread/7": "3ef3c265b38598e2",
|
||||
"A/midnight/spread/8": "3a77eec4f6c9f860",
|
||||
"A/midnight/spread/9": "5bdff7027bea103c",
|
||||
"A/midnight/spread/4": "6d89cdaeac29b8b6",
|
||||
"A/midnight/spread/5": "25f6a66be27c2415",
|
||||
"A/midnight/spread/6": "14c828e117eb72a4",
|
||||
"A/midnight/spread/7": "8fac481cb6c1d93d",
|
||||
"A/midnight/spread/8": "8730ae9ad717bbe5",
|
||||
"A/midnight/spread/9": "9792ae63b36eff3e",
|
||||
"A/riso/head": "7d43d0900fbe0f57",
|
||||
"A/riso/plan": "f802ad819f1fb262",
|
||||
"A/riso/spread/0": "1f4502d29eca9017",
|
||||
"A/riso/spread/0": "50a4cc5fafbfad6e",
|
||||
"A/riso/spread/1": "8de92788de29277b",
|
||||
"A/riso/spread/10": "2a92b79192575cd0",
|
||||
"A/riso/spread/11": "5785a9cc59ddd735",
|
||||
"A/riso/spread/12": "cd4f6004153fe694",
|
||||
"A/riso/spread/13": "b1f87a92cb7f775c",
|
||||
"A/riso/spread/14": "873bf5f7a8404fe4",
|
||||
"A/riso/spread/15": "c1aab0214328ef3b",
|
||||
"A/riso/spread/10": "d468165710581556",
|
||||
"A/riso/spread/11": "1997a6c3ae1c8570",
|
||||
"A/riso/spread/12": "655767248f707020",
|
||||
"A/riso/spread/13": "15e7ddcf0d397509",
|
||||
"A/riso/spread/14": "88cf12b3fa6b6be9",
|
||||
"A/riso/spread/15": "d2bb88a721cc8320",
|
||||
"A/riso/spread/16": "d58f70c381d9f6ae",
|
||||
"A/riso/spread/17": "00caa407cb2a0677",
|
||||
"A/riso/spread/18": "ed41aca064456614",
|
||||
"A/riso/spread/19": "36647f06c594e715",
|
||||
"A/riso/spread/18": "def2a9dde53f3e26",
|
||||
"A/riso/spread/19": "3cea577870cdc23d",
|
||||
"A/riso/spread/2": "5c9a93de9e6467d6",
|
||||
"A/riso/spread/20": "8518ca61c110fd8a",
|
||||
"A/riso/spread/21": "d4ba78b2ffd5ae04",
|
||||
"A/riso/spread/22": "de5116307e12ce61",
|
||||
"A/riso/spread/20": "c851d704ccca068e",
|
||||
"A/riso/spread/21": "be8c5194461daa01",
|
||||
"A/riso/spread/22": "dcd4a1dfde54cef9",
|
||||
"A/riso/spread/23": "a748ada7a028818e",
|
||||
"A/riso/spread/24": "9abf456403732463",
|
||||
"A/riso/spread/3": "20a199a47535dea7",
|
||||
"A/riso/spread/4": "93b84be6e48097b5",
|
||||
"A/riso/spread/5": "6d5ae8e80c2f0d21",
|
||||
"A/riso/spread/6": "e7f5c928785be48d",
|
||||
"A/riso/spread/7": "b672fd04b61ea7b9",
|
||||
"A/riso/spread/8": "d9e84bbad744e450",
|
||||
"A/riso/spread/9": "017d695be27dc988",
|
||||
"A/riso/spread/4": "1b46e4d88f0a0db5",
|
||||
"A/riso/spread/5": "dd07d5451021f0f5",
|
||||
"A/riso/spread/6": "ae0f0bbad2f029e3",
|
||||
"A/riso/spread/7": "5029fc77bc6a7bca",
|
||||
"A/riso/spread/8": "6eecfd5fd8319fe6",
|
||||
"A/riso/spread/9": "3ba63e382035e668",
|
||||
"B/fabric-classic/head": "6af0d11dd09eae78",
|
||||
"B/fabric-classic/plan": "cae85517611abbca",
|
||||
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
|
||||
"B/fabric-classic/spread/0": "9da4b641b0f81264",
|
||||
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
|
||||
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
|
||||
"B/fabric-classic/spread/3": "110c2e11d8079468",
|
||||
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
|
||||
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
|
||||
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
|
||||
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
|
||||
"B/fabric-classic/spread/3": "bdb01dc38f94e0d0",
|
||||
"B/fabric-classic/spread/4": "4e949ebeb4098598",
|
||||
"B/fabric-classic/spread/5": "fa7289a87111f6a2",
|
||||
"B/fabric-classic/spread/6": "e1367526818066fb",
|
||||
"B/fabric-classic/spread/7": "a5c72a73d9305e41",
|
||||
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
|
||||
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
|
||||
"B/midnight/head": "cfd309bdf9e41811",
|
||||
"B/midnight/plan": "b3e4cb03c2d620a4",
|
||||
"B/midnight/spread/0": "7fdd20dcda48881f",
|
||||
"B/midnight/spread/0": "ee90988cd45ab2c4",
|
||||
"B/midnight/spread/1": "d9f5d78659332b45",
|
||||
"B/midnight/spread/10": "db97ebe8475e0371",
|
||||
"B/midnight/spread/2": "a094503b82019808",
|
||||
"B/midnight/spread/3": "6e3e57121d8b8683",
|
||||
"B/midnight/spread/4": "468100c3bebad30d",
|
||||
"B/midnight/spread/5": "ca669ba76ca4d9bd",
|
||||
"B/midnight/spread/6": "60b4cb974e35c55d",
|
||||
"B/midnight/spread/7": "46fe0841298e4f7b",
|
||||
"B/midnight/spread/8": "25e4510c5fb81817",
|
||||
"B/midnight/spread/3": "b9fb3f915cffb57d",
|
||||
"B/midnight/spread/4": "9f0467b6921de638",
|
||||
"B/midnight/spread/5": "7e3d7de6ef19180c",
|
||||
"B/midnight/spread/6": "d42de16c6d174c6b",
|
||||
"B/midnight/spread/7": "dba5f8de6c557d9e",
|
||||
"B/midnight/spread/8": "835870aa7e88f095",
|
||||
"B/midnight/spread/9": "eba329fd185958b3",
|
||||
"B/riso/head": "69a6c3112de2a047",
|
||||
"B/riso/plan": "d8906a53b3532db8",
|
||||
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
|
||||
"B/riso/spread/0": "867edf084f05a873",
|
||||
"B/riso/spread/1": "8de92788de29277b",
|
||||
"B/riso/spread/2": "40f8d2b093d0fc8d",
|
||||
"B/riso/spread/3": "1fc8aedc7486cbeb",
|
||||
"B/riso/spread/4": "311c9fcde888d578",
|
||||
"B/riso/spread/4": "cedfcf22d3331d4a",
|
||||
"B/riso/spread/5": "51562d8f08f46011",
|
||||
"B/riso/spread/6": "c114a720b5c5beff",
|
||||
"B/riso/spread/6": "68783b660e0ffa6a",
|
||||
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
|
||||
"B/riso/spread/8": "a748ada7a028818e",
|
||||
"B/riso/spread/9": "877657cfa95e3cac",
|
||||
"C/fabric-classic/head": "ddd21691c506a600",
|
||||
"C/fabric-classic/plan": "c31dcaa9b3603acc",
|
||||
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
|
||||
"C/fabric-classic/spread/0": "2ab15684c1a8df5a",
|
||||
"C/fabric-classic/spread/1": "7094bacb28cc210a",
|
||||
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
|
||||
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
|
||||
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
|
||||
"C/fabric-classic/spread/3": "a964aa1e7f057294",
|
||||
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
|
||||
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
|
||||
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
|
||||
"C/fabric-classic/spread/7": "71330d722ab54efd",
|
||||
"C/fabric-classic/spread/2": "d0f1544330a8501f",
|
||||
"C/fabric-classic/spread/3": "3600be0a98413583",
|
||||
"C/fabric-classic/spread/4": "d551cdf96c385341",
|
||||
"C/fabric-classic/spread/5": "177741ca8b5c1758",
|
||||
"C/fabric-classic/spread/6": "2c42a8276092879e",
|
||||
"C/fabric-classic/spread/7": "fd5861b762afd36b",
|
||||
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
|
||||
"C/fabric-classic/spread/9": "31665bce59abeafe",
|
||||
"C/fabric-classic/spread/9": "2a5631a4b55fd15a",
|
||||
"C/midnight/head": "f1e6077175cb2e20",
|
||||
"C/midnight/plan": "14fb454d02caf6f9",
|
||||
"C/midnight/spread/0": "79eae9202a85d430",
|
||||
"C/midnight/spread/0": "10706dcfb990de4c",
|
||||
"C/midnight/spread/1": "d9f5d78659332b45",
|
||||
"C/midnight/spread/10": "6694684925255e94",
|
||||
"C/midnight/spread/11": "247394453c45c63c",
|
||||
"C/midnight/spread/12": "abe0197797669d57",
|
||||
"C/midnight/spread/10": "29566f40839aa633",
|
||||
"C/midnight/spread/11": "063bb3a68427c348",
|
||||
"C/midnight/spread/12": "68223f7894a7f842",
|
||||
"C/midnight/spread/13": "eba329fd185958b3",
|
||||
"C/midnight/spread/14": "ebbba57a4116faa3",
|
||||
"C/midnight/spread/2": "7b99390522f39fe5",
|
||||
"C/midnight/spread/3": "7391268691e50ede",
|
||||
"C/midnight/spread/4": "4f08a3fc6da821a5",
|
||||
"C/midnight/spread/5": "6d06a2d469c2d5e2",
|
||||
"C/midnight/spread/6": "6238c8bc28c5964e",
|
||||
"C/midnight/spread/7": "8a5cc2233372bb47",
|
||||
"C/midnight/spread/8": "a445fe1b9090293d",
|
||||
"C/midnight/spread/9": "e2af9bfdb8789b4b",
|
||||
"C/midnight/spread/2": "894a97de13c021ed",
|
||||
"C/midnight/spread/3": "97fb835ff2105cdc",
|
||||
"C/midnight/spread/4": "87961fc9d8aa3915",
|
||||
"C/midnight/spread/5": "4c4a134559a3516a",
|
||||
"C/midnight/spread/6": "3358ec65f7d7240a",
|
||||
"C/midnight/spread/7": "b7abfc765f918ddb",
|
||||
"C/midnight/spread/8": "52a02b23cbe1e01a",
|
||||
"C/midnight/spread/9": "9ccf347142b626ca",
|
||||
"C/riso/head": "d19997b07778d1dc",
|
||||
"C/riso/plan": "175dadeb8bb730a6",
|
||||
"C/riso/spread/0": "8453dd7b94afe68d",
|
||||
"C/riso/spread/0": "11bb39722782ce9d",
|
||||
"C/riso/spread/1": "8de92788de29277b",
|
||||
"C/riso/spread/10": "6b5aa1dac73ab777",
|
||||
"C/riso/spread/11": "c20262603c86d43e",
|
||||
"C/riso/spread/12": "8e263c445c0418d2",
|
||||
"C/riso/spread/10": "df3b209ca6a45afb",
|
||||
"C/riso/spread/11": "48d8e38b39721c2b",
|
||||
"C/riso/spread/12": "2326a58882d7c326",
|
||||
"C/riso/spread/13": "a748ada7a028818e",
|
||||
"C/riso/spread/14": "81e4b4a0dcc6961a",
|
||||
"C/riso/spread/2": "5e43386b045d926c",
|
||||
"C/riso/spread/3": "9587f2fe2fc9cd04",
|
||||
"C/riso/spread/2": "b25ef24c052d72a3",
|
||||
"C/riso/spread/3": "1edc86f0be5c9422",
|
||||
"C/riso/spread/4": "edb0ebe3a843a937",
|
||||
"C/riso/spread/5": "f8a935188537c224",
|
||||
"C/riso/spread/6": "d2ca03597f3732c6",
|
||||
"C/riso/spread/7": "7a4374ec7906855f",
|
||||
"C/riso/spread/8": "2dd867bf81ca7901",
|
||||
"C/riso/spread/9": "aea9baf94aa13bc8",
|
||||
"C/riso/spread/5": "1c86ab49df7921b5",
|
||||
"C/riso/spread/6": "66fe84b4051bbb45",
|
||||
"C/riso/spread/7": "05ed926557fdc48d",
|
||||
"C/riso/spread/8": "1599830b16195a51",
|
||||
"C/riso/spread/9": "dafb4c953a1c6693",
|
||||
"D/fabric-classic/head": "54fa8987fea87ce4",
|
||||
"D/fabric-classic/plan": "a2d0423833838dcc",
|
||||
"D/fabric-classic/spread/0": "1136daea45c98c7d",
|
||||
"D/fabric-classic/spread/0": "58ae71edfa473b8c",
|
||||
"D/fabric-classic/spread/1": "931682bc5827ad8a",
|
||||
"D/fabric-classic/spread/10": "98449436f064fc7c",
|
||||
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
|
||||
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
|
||||
"D/fabric-classic/spread/13": "b512aff5192eae71",
|
||||
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
|
||||
"D/fabric-classic/spread/10": "f53e366c56f5026d",
|
||||
"D/fabric-classic/spread/11": "36816b8e9c3e9745",
|
||||
"D/fabric-classic/spread/12": "1f512d9c851fd489",
|
||||
"D/fabric-classic/spread/13": "8e885c24ae9c4796",
|
||||
"D/fabric-classic/spread/14": "8ed5b5e96e4f5696",
|
||||
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
|
||||
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
|
||||
"D/fabric-classic/spread/2": "86dd9a101af346f4",
|
||||
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
|
||||
"D/fabric-classic/spread/4": "80d31e5986b670e2",
|
||||
"D/fabric-classic/spread/5": "664cd81373c14243",
|
||||
"D/fabric-classic/spread/6": "c1d232a5aed51318",
|
||||
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
|
||||
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
|
||||
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
|
||||
"D/fabric-classic/spread/4": "9fcb7ff2c09e7e05",
|
||||
"D/fabric-classic/spread/5": "512f64afa749875d",
|
||||
"D/fabric-classic/spread/6": "d909172ca9306881",
|
||||
"D/fabric-classic/spread/7": "b267abc83d4faf93",
|
||||
"D/fabric-classic/spread/8": "29c43d7551fdcedb",
|
||||
"D/fabric-classic/spread/9": "932c06500ebaf54c",
|
||||
"D/midnight/head": "da510cf5f95bad40",
|
||||
"D/midnight/plan": "3bd0d52ed4e476c7",
|
||||
"D/midnight/spread/0": "440b7d69daa18a7a",
|
||||
"D/midnight/spread/0": "cc12862834fdbf96",
|
||||
"D/midnight/spread/1": "d9f5d78659332b45",
|
||||
"D/midnight/spread/10": "824d62135a0fe827",
|
||||
"D/midnight/spread/11": "03bbcf527fcbce9f",
|
||||
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
|
||||
"D/midnight/spread/13": "7025aef32f444ab2",
|
||||
"D/midnight/spread/14": "581f0008497c1143",
|
||||
"D/midnight/spread/15": "f86784f4b061a31e",
|
||||
"D/midnight/spread/16": "ec6c98d1713ff353",
|
||||
"D/midnight/spread/17": "03888512d15a5895",
|
||||
"D/midnight/spread/10": "d25808fa155f157e",
|
||||
"D/midnight/spread/11": "b984e665960f60b6",
|
||||
"D/midnight/spread/12": "a0985fcab9c614a2",
|
||||
"D/midnight/spread/13": "3f2670ca15c1797c",
|
||||
"D/midnight/spread/14": "b49cc00919f7a621",
|
||||
"D/midnight/spread/15": "67a512730030f450",
|
||||
"D/midnight/spread/16": "90864ccfce090575",
|
||||
"D/midnight/spread/17": "69924ede00c784f9",
|
||||
"D/midnight/spread/18": "eba329fd185958b3",
|
||||
"D/midnight/spread/19": "6e6e6c877443c5e7",
|
||||
"D/midnight/spread/2": "cef4235690d44e53",
|
||||
"D/midnight/spread/3": "cd34cb85119476ee",
|
||||
"D/midnight/spread/4": "f9782906a14ae092",
|
||||
"D/midnight/spread/5": "597d729fb1cfb962",
|
||||
"D/midnight/spread/6": "0023fe5672ad9141",
|
||||
"D/midnight/spread/7": "f79ae699d0684ce5",
|
||||
"D/midnight/spread/8": "ea1a0157b2405cff",
|
||||
"D/midnight/spread/9": "5d38d38d1226fe1e",
|
||||
"D/midnight/spread/4": "f332e826ccb518b8",
|
||||
"D/midnight/spread/5": "545524d900a6d68e",
|
||||
"D/midnight/spread/6": "ea5db0eab1087429",
|
||||
"D/midnight/spread/7": "da860dcfdcffc997",
|
||||
"D/midnight/spread/8": "d4a871126c6b324e",
|
||||
"D/midnight/spread/9": "533ceb3478922532",
|
||||
"D/riso/head": "1c8f99d99acdea28",
|
||||
"D/riso/plan": "f2a8704019582093",
|
||||
"D/riso/spread/0": "7b0d816ffda1dd01",
|
||||
"D/riso/spread/0": "7cded5c557f2e1ee",
|
||||
"D/riso/spread/1": "8de92788de29277b",
|
||||
"D/riso/spread/10": "c85c0634205e3232",
|
||||
"D/riso/spread/11": "f97635aee763f93e",
|
||||
"D/riso/spread/12": "37f5d0dc381cfdb4",
|
||||
"D/riso/spread/13": "7083d486cc5556c5",
|
||||
"D/riso/spread/14": "711a3787fce72914",
|
||||
"D/riso/spread/15": "27d0db240d32de9a",
|
||||
"D/riso/spread/10": "1d403f4e5dc74e1e",
|
||||
"D/riso/spread/11": "7b563aff540a0a2c",
|
||||
"D/riso/spread/12": "c6cf876fbc756d21",
|
||||
"D/riso/spread/13": "735d4882d5230331",
|
||||
"D/riso/spread/14": "8df18b93ab68235d",
|
||||
"D/riso/spread/15": "4be16c00138d027d",
|
||||
"D/riso/spread/16": "a748ada7a028818e",
|
||||
"D/riso/spread/17": "ed87c0a4c77f241c",
|
||||
"D/riso/spread/2": "78f24755fb7ad38a",
|
||||
"D/riso/spread/3": "047741da3e1adc20",
|
||||
"D/riso/spread/4": "294d0c2a0600ed4a",
|
||||
"D/riso/spread/5": "b1f6c2832a5f5737",
|
||||
"D/riso/spread/6": "a898defea41081ec",
|
||||
"D/riso/spread/7": "027026c49ea68a27",
|
||||
"D/riso/spread/4": "42d13c4cc22154a9",
|
||||
"D/riso/spread/5": "c2dcc1ba31041587",
|
||||
"D/riso/spread/6": "93846db9c48faa0f",
|
||||
"D/riso/spread/7": "42f55e08792400e9",
|
||||
"D/riso/spread/8": "4ff93cb4d24f729c",
|
||||
"D/riso/spread/9": "988a20c009c9d562"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
|
||||
import { normalizeBookDocument } from '../book-normalize';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
} from '../book.schema';
|
||||
import { MAX_SPREAD_ELEMENTS, bookDocumentSchema, type BookDocument, type BookSpread } from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import { findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots } from '../templates/template-format';
|
||||
@@ -101,13 +95,12 @@ function single(photos: number, story: boolean): BookDesignInput {
|
||||
}
|
||||
|
||||
describe('the book on the designed templates', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the element cap', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const { doc } = laid(input, theme);
|
||||
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
|
||||
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
|
||||
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
|
||||
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { BookTemplateWord } from '../templates/template-words';
|
||||
import type { BookDesignInput } from './design-book';
|
||||
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
|
||||
import { composeStations } from './design-framing';
|
||||
import type { FramingNeeds } from './design-inner';
|
||||
import type { DesignedSpread } from './design-merge';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import { buildChapters, type DesignChapter } from './design-pacing';
|
||||
@@ -438,12 +437,11 @@ export function composeTemplatedInner(
|
||||
entries: readonly AutoEntry[],
|
||||
choice: DesignPhotoChoice,
|
||||
input: BookDesignInput,
|
||||
needs: FramingNeeds,
|
||||
plan: TemplatedPlan,
|
||||
): TemplatedInnerResult {
|
||||
const notes: string[] = [];
|
||||
const leftOut: BookDesignLeftOut[] = [];
|
||||
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
|
||||
const cap = Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
|
||||
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
|
||||
const base: PickBase = {
|
||||
archetype: plan.archetype,
|
||||
|
||||
@@ -124,6 +124,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'أدخل عبارة بحث',
|
||||
'dashboard.unsplashNoResults': 'لم يتم العثور على صور',
|
||||
'dashboard.coverSearchError': 'فشل البحث في Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'رفض Unsplash هذا الخادم. يمكن للمسؤول إضافة مفتاح Unsplash API مجاني في إعدادات المسؤول لتعمل عملية البحث عن الصور.',
|
||||
'dashboard.useUnsplashPhoto': 'استخدم صورة من Unsplash بواسطة {photographer}',
|
||||
'dashboard.titleRequired': 'العنوان مطلوب',
|
||||
'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية',
|
||||
|
||||
@@ -157,6 +157,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'حول المدة الإجمالية المخططة',
|
||||
'tours.planner.plannedTotalManual': 'تعديل يدوي',
|
||||
'tours.planner.timeValueInvalid': 'أدخل قيمة صحيحة من 0 إلى 1440 دقيقة.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'وقت المشي مع الاستراحات يزيد على 24 ساعة في اليوم. اجعلها جولة لعدة أيام عبر «إنهاء المرحلة هنا» عند إحدى المحطات، أو أدخل مدة إجمالية مخططة أقصر.',
|
||||
'tours.planner.walkingTimeUnknown': 'غير محسوب',
|
||||
'tours.planner.walkingShort': 'مشي',
|
||||
'tours.planner.breaksShort': 'استراحات',
|
||||
|
||||
@@ -111,6 +111,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Axtarış sözü daxil edin',
|
||||
'dashboard.unsplashNoResults': 'Şəkil tapılmadı',
|
||||
'dashboard.coverSearchError': 'Unsplash-da axtarış aparmaq mümkün olmadı',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash bu serveri rədd etdi. Şəkil axtarışının işləməsi üçün administrator admin ayarlarında pulsuz Unsplash API açarı əlavə edə bilər.',
|
||||
'dashboard.useUnsplashPhoto': '{photographer} tərəfindən çəkilmiş Unsplash şəklindən istifadə et',
|
||||
'dashboard.titleRequired': 'Başlıq tələb olunur',
|
||||
'dashboard.endDateError': 'Bitmə tarixi başlama tarixindən sonra olmalıdır',
|
||||
|
||||
@@ -142,6 +142,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Planlaşdırılmış ümumi müddət haqqında',
|
||||
'tours.planner.plannedTotalManual': 'Əl ilə dəyişdirmə',
|
||||
'tours.planner.timeValueInvalid': '0 ilə 1440 dəqiqə arasında tam dəyər daxil edin.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Yeriş vaxtı və fasilələr gündə 24 saatdan çox edir. Dayanacaqda “Mərhələni burada bitir” ilə onu çoxgünlük tura çevirin və ya daha qısa planlaşdırılmış ümumi müddət daxil edin.',
|
||||
'tours.planner.walkingTimeUnknown': 'Hesablanmayıb',
|
||||
'tours.planner.walkingShort': 'Gəzinti',
|
||||
'tours.planner.breaksShort': 'Fasilələr',
|
||||
|
||||
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Digite um termo de busca',
|
||||
'dashboard.unsplashNoResults': 'Nenhuma imagem encontrada',
|
||||
'dashboard.coverSearchError': 'Falha ao buscar no Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'O Unsplash recusou este servidor. Um administrador pode adicionar uma chave de API gratuita do Unsplash nas configurações de administração para a busca de imagens funcionar.',
|
||||
'dashboard.useUnsplashPhoto': 'Usar foto do Unsplash por {photographer}',
|
||||
'dashboard.titleRequired': 'O título é obrigatório',
|
||||
'dashboard.endDateError': 'A data final deve ser depois da inicial',
|
||||
|
||||
@@ -146,6 +146,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Sobre a duração total planejada',
|
||||
'tours.planner.plannedTotalManual': 'Ajuste manual',
|
||||
'tours.planner.timeValueInvalid': 'Informe um valor inteiro de 0 a 1440 minutos.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'O tempo de caminhada com as pausas passa de 24 horas por dia. Transforme em um passeio de vários dias com “Encerrar etapa aqui” em uma parada, ou informe uma duração total planejada menor.',
|
||||
'tours.planner.walkingTimeUnknown': 'Não calculado',
|
||||
'tours.planner.walkingShort': 'Caminhada',
|
||||
'tours.planner.breaksShort': 'Pausas',
|
||||
|
||||
@@ -186,6 +186,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Introdueix un terme de cerca',
|
||||
'dashboard.unsplashNoResults': "No s'han trobat imatges",
|
||||
'dashboard.coverSearchError': 'Error en cercar a Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash ha rebutjat aquest servidor. Un administrador pot afegir una clau d’API gratuïta d’Unsplash a la configuració d’administració perquè funcioni la cerca d’imatges.',
|
||||
'dashboard.useUnsplashPhoto': "Utilitzar foto d'Unsplash de {photographer}",
|
||||
'dashboard.dayCountRequired': 'El nombre de dies és obligatori',
|
||||
'dashboard.dateShiftTitle': "Nova data d'inici",
|
||||
|
||||
@@ -146,6 +146,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Sobre la durada total planificada',
|
||||
'tours.planner.plannedTotalManual': 'Valor manual',
|
||||
'tours.planner.timeValueInvalid': 'Introduïu un valor enter de 0 a 1440 minuts.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'El temps de marxa i les pauses sumen més de 24 hores al dia. Feu-ne una ruta de diversos dies amb “Acaba l’etapa aquí” en una parada, o introduïu una durada total planificada més curta.',
|
||||
'tours.planner.walkingTimeUnknown': 'No calculat',
|
||||
'tours.planner.walkingShort': 'Caminada',
|
||||
'tours.planner.breaksShort': 'Pauses',
|
||||
|
||||
@@ -103,6 +103,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Zadejte hledaný výraz',
|
||||
'dashboard.unsplashNoResults': 'Nebyly nalezeny žádné obrázky',
|
||||
'dashboard.coverSearchError': 'Nepodařilo se vyhledat na Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash tento server odmítl. Správce může v nastavení administrace přidat bezplatný klíč Unsplash API, aby vyhledávání obrázků fungovalo.',
|
||||
'dashboard.useUnsplashPhoto': 'Použít fotku z Unsplash od {photographer}',
|
||||
'dashboard.titleRequired': 'Název je povinný',
|
||||
'dashboard.endDateError': 'Datum konce musí být po datu začátku',
|
||||
|
||||
@@ -144,6 +144,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'O plánované celkové době',
|
||||
'tours.planner.plannedTotalManual': 'Ruční úprava',
|
||||
'tours.planner.timeValueInvalid': 'Zadejte celou hodnotu od 0 do 1440 minut.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Doba chůze a přestávky dávají dohromady víc než 24 hodin denně. Udělejte z ní vícedenní túru pomocí „Ukončit etapu zde“ u zastávky, nebo zadejte kratší plánovanou celkovou dobu.',
|
||||
'tours.planner.walkingTimeUnknown': 'Nevypočteno',
|
||||
'tours.planner.walkingShort': 'Chůze',
|
||||
'tours.planner.breaksShort': 'Pauzy',
|
||||
|
||||
@@ -97,6 +97,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Suchbegriff eingeben',
|
||||
'dashboard.unsplashNoResults': 'Keine Bilder gefunden',
|
||||
'dashboard.coverSearchError': 'Unsplash-Suche fehlgeschlagen',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash hat diesen Server abgewiesen. Ein Administrator kann in den Admin-Einstellungen einen kostenlosen Unsplash-API-Schlüssel eintragen, dann funktioniert die Bildsuche.',
|
||||
'dashboard.useUnsplashPhoto': 'Unsplash-Foto von {photographer} verwenden',
|
||||
'dashboard.titleRequired': 'Titel ist erforderlich',
|
||||
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
|
||||
|
||||
@@ -144,6 +144,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.breaksAdditional': 'Pausen / zusätzliche Zeit',
|
||||
'tours.planner.plannedTotalManual': 'Manuell angepasst',
|
||||
'tours.planner.timeValueInvalid': 'Ganze Minuten von 0 bis 1.440 eingeben.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Gehzeit und Pausen ergeben mehr als 24 Stunden am Tag. Mach eine Mehrtagestour daraus, mit „Etappe hier beenden“ an einem Halt, oder gib eine kürzere geplante Gesamtdauer ein.',
|
||||
'tours.planner.walkingTimeUnknown': 'Noch nicht berechnet',
|
||||
'tours.planner.walkingShort': 'Gehzeit',
|
||||
'tours.planner.breaksShort': 'Pausen',
|
||||
|
||||
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Enter a search term',
|
||||
'dashboard.unsplashNoResults': 'No images found',
|
||||
'dashboard.coverSearchError': 'Failed to search Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash turned this server away. An administrator can add a free Unsplash API Key in the admin settings to make image search work.',
|
||||
'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}',
|
||||
'dashboard.titleRequired': 'Title is required',
|
||||
'dashboard.endDateError': 'End date must be after start date',
|
||||
|
||||
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'About planned total duration',
|
||||
'tours.planner.plannedTotalManual': 'Manual override',
|
||||
'tours.planner.timeValueInvalid': 'Enter a whole value from 0 to 1,440 minutes.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
|
||||
'tours.planner.walkingTimeUnknown': 'Not calculated',
|
||||
'tours.planner.walkingShort': 'Walk',
|
||||
'tours.planner.breaksShort': 'Breaks',
|
||||
|
||||
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Introduce un término de búsqueda',
|
||||
'dashboard.unsplashNoResults': 'No se encontraron imágenes',
|
||||
'dashboard.coverSearchError': 'No se pudo buscar en Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash ha rechazado este servidor. Un administrador puede añadir una clave de API gratuita de Unsplash en los ajustes de administración para que funcione la búsqueda de imágenes.',
|
||||
'dashboard.useUnsplashPhoto': 'Usar foto de Unsplash de {photographer}',
|
||||
'dashboard.titleRequired': 'El título es obligatorio',
|
||||
'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
|
||||
|
||||
@@ -147,6 +147,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Acerca de la duración total planificada',
|
||||
'tours.planner.plannedTotalManual': 'Ajuste manual',
|
||||
'tours.planner.timeValueInvalid': 'Introduce un valor entero de 0 a 1440 minutos.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'El tiempo de marcha y las pausas suman más de 24 horas al día. Conviértela en una ruta de varios días con «Terminar etapa aquí» en una parada, o introduce una duración total planificada más corta.',
|
||||
'tours.planner.walkingTimeUnknown': 'No calculado',
|
||||
'tours.planner.walkingShort': 'Caminata',
|
||||
'tours.planner.breaksShort': 'Pausas',
|
||||
|
||||
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Sisesta otsingusõna',
|
||||
'dashboard.unsplashNoResults': 'Pilte ei leitud',
|
||||
'dashboard.coverSearchError': 'Unsplashi otsing ebaõnnestus',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash lükkas selle serveri tagasi. Administraator saab halduse seadetes lisada tasuta Unsplash API võtme, et pildiotsing töötaks.',
|
||||
'dashboard.useUnsplashPhoto': 'Kasuta Unsplashi fotot autorilt {photographer}',
|
||||
'dashboard.titleRequired': 'Pealkiri on kohustuslik',
|
||||
'dashboard.endDateError': 'Lõppkuupäev peab olema alguskuupäevast hilisem',
|
||||
|
||||
@@ -143,6 +143,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Planeeritud kogukestusest',
|
||||
'tours.planner.plannedTotalManual': 'Käsitsi määratud',
|
||||
'tours.planner.timeValueInvalid': 'Sisestage täisarv vahemikus 0–1440 minutit.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Liikumisaeg koos pausidega on üle 24 tunni päevas. Tee sellest mitmepäevane matk, valides peatuses „Lõpeta etapp siin“, või sisesta lühem planeeritud kogukestus.',
|
||||
'tours.planner.walkingTimeUnknown': 'Arvutamata',
|
||||
'tours.planner.walkingShort': 'Kõnd',
|
||||
'tours.planner.breaksShort': 'Pausid',
|
||||
|
||||
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Saisissez un terme de recherche',
|
||||
'dashboard.unsplashNoResults': 'Aucune image trouvée',
|
||||
'dashboard.coverSearchError': 'Échec de la recherche Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash a refusé ce serveur. Un administrateur peut ajouter une clé API Unsplash gratuite dans les paramètres d’administration pour que la recherche d’images fonctionne.',
|
||||
'dashboard.useUnsplashPhoto': 'Utiliser la photo Unsplash de {photographer}',
|
||||
'dashboard.titleRequired': 'Le titre est obligatoire',
|
||||
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
|
||||
|
||||
@@ -148,6 +148,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'À propos de la durée totale prévue',
|
||||
'tours.planner.plannedTotalManual': 'Valeur manuelle',
|
||||
'tours.planner.timeValueInvalid': 'Saisissez une valeur entière de 0 à 1440 minutes.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Le temps de marche et les pauses dépassent 24 heures par jour. Faites-en un itinéraire de plusieurs jours avec « Terminer l’étape ici » sur un arrêt, ou saisissez une durée totale prévue plus courte.',
|
||||
'tours.planner.walkingTimeUnknown': 'Non calculé',
|
||||
'tours.planner.walkingShort': 'Marche',
|
||||
'tours.planner.breaksShort': 'Pauses',
|
||||
|
||||
@@ -108,6 +108,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Πληκτρολογήστε όρο αναζήτησης',
|
||||
'dashboard.unsplashNoResults': 'Δεν βρέθηκαν εικόνες',
|
||||
'dashboard.coverSearchError': 'Αποτυχία αναζήτησης στο Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Το Unsplash απέρριψε αυτόν τον διακομιστή. Ένας διαχειριστής μπορεί να προσθέσει ένα δωρεάν κλειδί Unsplash API στις ρυθμίσεις διαχείρισης ώστε να λειτουργεί η αναζήτηση εικόνων.',
|
||||
'dashboard.useUnsplashPhoto': 'Χρήση φωτογραφίας Unsplash από {photographer}',
|
||||
'dashboard.titleRequired': 'Ο τίτλος είναι υποχρεωτικός',
|
||||
'dashboard.endDateError': 'Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία έναρξης',
|
||||
|
||||
@@ -148,6 +148,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Σχετικά με τη συνολική προγραμματισμένη διάρκεια',
|
||||
'tours.planner.plannedTotalManual': 'Χειροκίνητη τιμή',
|
||||
'tours.planner.timeValueInvalid': 'Εισαγάγετε ακέραια τιμή από 0 έως 1440 λεπτά.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Ο χρόνος πεζοπορίας μαζί με τις στάσεις ξεπερνά τις 24 ώρες την ημέρα. Κάντε τη πολυήμερη με «Τέλος σκέλους εδώ» σε μια στάση ή εισαγάγετε μικρότερη συνολική προγραμματισμένη διάρκεια.',
|
||||
'tours.planner.walkingTimeUnknown': 'Δεν έχει υπολογιστεί',
|
||||
'tours.planner.walkingShort': 'Περπάτημα',
|
||||
'tours.planner.breaksShort': 'Διαλείμματα',
|
||||
|
||||
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Adj meg egy keresési kifejezést',
|
||||
'dashboard.unsplashNoResults': 'Nem található kép',
|
||||
'dashboard.coverSearchError': 'Nem sikerült keresni az Unsplashen',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Az Unsplash elutasította ezt a szervert. Egy rendszergazda az adminisztrációs beállításokban megadhat egy ingyenes Unsplash API-kulcsot, és akkor működik a képkeresés.',
|
||||
'dashboard.useUnsplashPhoto': '{photographer} Unsplash-fotójának használata',
|
||||
'dashboard.titleRequired': 'A cím megadása kötelező',
|
||||
'dashboard.endDateError': 'A záró dátumnak a kezdő dátum után kell lennie',
|
||||
|
||||
@@ -143,6 +143,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'A tervezett teljes időtartamról',
|
||||
'tours.planner.plannedTotalManual': 'Kézi érték',
|
||||
'tours.planner.timeValueInvalid': 'Adjon meg egész értéket 0 és 1440 perc között.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'A menetidő a pihenőkkel együtt napi 24 óránál több. Tegye többnapossá a „Szakasz vége itt” lehetőséggel egy megállónál, vagy adjon meg rövidebb tervezett teljes időtartamot.',
|
||||
'tours.planner.walkingTimeUnknown': 'Nincs kiszámítva',
|
||||
'tours.planner.walkingShort': 'Séta',
|
||||
'tours.planner.breaksShort': 'Szünetek',
|
||||
|
||||
@@ -89,6 +89,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Masukkan istilah pencarian',
|
||||
'dashboard.unsplashNoResults': 'Tidak ada gambar ditemukan',
|
||||
'dashboard.coverSearchError': 'Gagal mencari di Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash menolak server ini. Administrator dapat menambahkan kunci API Unsplash gratis di pengaturan admin agar pencarian gambar berfungsi.',
|
||||
'dashboard.useUnsplashPhoto': 'Gunakan foto Unsplash oleh {photographer}',
|
||||
'dashboard.titleRequired': 'Judul wajib diisi',
|
||||
'dashboard.endDateError': 'Tanggal selesai harus setelah tanggal mulai',
|
||||
|
||||
@@ -139,6 +139,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Tentang total durasi yang direncanakan',
|
||||
'tours.planner.plannedTotalManual': 'Penggantian manual',
|
||||
'tours.planner.timeValueInvalid': 'Masukkan nilai bulat dari 0 hingga 1440 menit.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Waktu jalan ditambah istirahat lebih dari 24 jam sehari. Jadikan tur beberapa hari dengan “Akhiri etape di sini” di sebuah perhentian, atau masukkan total durasi rencana yang lebih singkat.',
|
||||
'tours.planner.walkingTimeUnknown': 'Belum dihitung',
|
||||
'tours.planner.walkingShort': 'Jalan kaki',
|
||||
'tours.planner.breaksShort': 'Istirahat',
|
||||
|
||||
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Inserisci un termine di ricerca',
|
||||
'dashboard.unsplashNoResults': 'Nessuna immagine trovata',
|
||||
'dashboard.coverSearchError': 'Ricerca su Unsplash non riuscita',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash ha rifiutato questo server. Un amministratore può aggiungere una chiave API Unsplash gratuita nelle impostazioni di amministrazione per far funzionare la ricerca di immagini.',
|
||||
'dashboard.useUnsplashPhoto': 'Usa foto Unsplash di {photographer}',
|
||||
'dashboard.titleRequired': 'Il titolo è obbligatorio',
|
||||
'dashboard.endDateError': 'La data di fine deve essere successiva alla data di inizio',
|
||||
|
||||
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Informazioni sulla durata totale pianificata',
|
||||
'tours.planner.plannedTotalManual': 'Valore manuale',
|
||||
'tours.planner.timeValueInvalid': 'Inserisci un valore intero da 0 a 1440 minuti.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Il tempo di cammino e le pause superano le 24 ore al giorno. Rendilo un itinerario di più giorni con “Termina la tappa qui” su una sosta, oppure inserisci una durata totale pianificata più breve.',
|
||||
'tours.planner.walkingTimeUnknown': 'Non calcolato',
|
||||
'tours.planner.walkingShort': 'Cammino',
|
||||
'tours.planner.breaksShort': 'Pause',
|
||||
|
||||
@@ -104,6 +104,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': '検索語を入力してください',
|
||||
'dashboard.unsplashNoResults': '画像が見つかりません',
|
||||
'dashboard.coverSearchError': 'Unsplashの検索に失敗しました',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash がこのサーバーを拒否しました。管理者が管理設定で無料の Unsplash API キーを追加すると、画像検索が使えるようになります。',
|
||||
'dashboard.useUnsplashPhoto': '{photographer}さんのUnsplash写真を使用',
|
||||
'dashboard.titleRequired': 'タイトルは必須です',
|
||||
'dashboard.endDateError': '終了日は開始日より後にしてください',
|
||||
|
||||
@@ -137,6 +137,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': '予定の合計所要時間について',
|
||||
'tours.planner.plannedTotalManual': '手動で上書き',
|
||||
'tours.planner.timeValueInvalid': '0〜1440分の整数を入力してください。',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'歩行時間と休憩の合計が1日24時間を超えています。立ち寄り先で「ここでステージを終える」を選んで複数日のツアーにするか、予定の合計所要時間を短く入力してください。',
|
||||
'tours.planner.walkingTimeUnknown': '未計算',
|
||||
'tours.planner.walkingShort': '徒歩',
|
||||
'tours.planner.breaksShort': '休憩',
|
||||
|
||||
@@ -104,6 +104,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': '검색어를 입력하세요',
|
||||
'dashboard.unsplashNoResults': '이미지를 찾을 수 없습니다',
|
||||
'dashboard.coverSearchError': 'Unsplash 검색에 실패했습니다',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash가 이 서버를 거부했습니다. 관리자가 관리자 설정에서 무료 Unsplash API 키를 추가하면 이미지 검색이 작동합니다.',
|
||||
'dashboard.useUnsplashPhoto': '{photographer}님의 Unsplash 사진 사용',
|
||||
'dashboard.titleRequired': '제목을 입력하세요',
|
||||
'dashboard.endDateError': '종료일은 시작일 이후여야 합니다',
|
||||
|
||||
@@ -134,6 +134,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': '예상 총 소요 시간 정보',
|
||||
'tours.planner.plannedTotalManual': '직접 입력',
|
||||
'tours.planner.timeValueInvalid': '0~1440분 사이의 정수 값을 입력하세요.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'걷는 시간과 휴식을 합치면 하루 24시간을 넘습니다. 경유지에서 “여기서 구간 종료”로 여러 날 투어로 나누거나, 더 짧은 예상 총 소요 시간을 입력하세요.',
|
||||
'tours.planner.walkingTimeUnknown': '계산되지 않음',
|
||||
'tours.planner.walkingShort': '도보',
|
||||
'tours.planner.breaksShort': '휴식',
|
||||
|
||||
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Voer een zoekterm in',
|
||||
'dashboard.unsplashNoResults': 'Geen afbeeldingen gevonden',
|
||||
'dashboard.coverSearchError': 'Zoeken op Unsplash mislukt',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash heeft deze server geweigerd. Een beheerder kan in de beheerinstellingen een gratis Unsplash-API-sleutel toevoegen, dan werkt het zoeken naar afbeeldingen.',
|
||||
'dashboard.useUnsplashPhoto': 'Unsplash-foto van {photographer} gebruiken',
|
||||
'dashboard.titleRequired': 'Titel is verplicht',
|
||||
'dashboard.endDateError': 'Einddatum moet na de startdatum liggen',
|
||||
|
||||
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Over de geplande totale duur',
|
||||
'tours.planner.plannedTotalManual': 'Handmatige waarde',
|
||||
'tours.planner.timeValueInvalid': 'Voer een geheel getal in van 0 tot 1440 minuten.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Looptijd en pauzes komen samen op meer dan 24 uur per dag. Maak er een meerdaagse tocht van met ‘Etappe hier beëindigen’ bij een stop, of vul een kortere geplande totale duur in.',
|
||||
'tours.planner.walkingTimeUnknown': 'Niet berekend',
|
||||
'tours.planner.walkingShort': 'Wandelen',
|
||||
'tours.planner.breaksShort': 'Pauzes',
|
||||
|
||||
@@ -102,6 +102,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Wpisz szukaną frazę',
|
||||
'dashboard.unsplashNoResults': 'Nie znaleziono obrazów',
|
||||
'dashboard.coverSearchError': 'Nie udało się wyszukać w Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash odrzucił ten serwer. Administrator może dodać darmowy klucz Unsplash API w ustawieniach administracyjnych, aby wyszukiwanie obrazów działało.',
|
||||
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
|
||||
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
|
||||
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
|
||||
|
||||
@@ -154,6 +154,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'O planowanym całkowitym czasie',
|
||||
'tours.planner.plannedTotalManual': 'Wartość ręczna',
|
||||
'tours.planner.timeValueInvalid': 'Wpisz liczbę całkowitą od 0 do 1440 minut.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Czas marszu i przerwy dają razem ponad 24 godziny na dzień. Zrób z tego trasę wielodniową przez „Zakończ etap tutaj” na postoju albo wpisz krótszy planowany całkowity czas.',
|
||||
'tours.planner.walkingTimeUnknown': 'Nieobliczono',
|
||||
'tours.planner.walkingShort': 'Marsz',
|
||||
'tours.planner.breaksShort': 'Przerwy',
|
||||
|
||||
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Введите поисковый запрос',
|
||||
'dashboard.unsplashNoResults': 'Изображения не найдены',
|
||||
'dashboard.coverSearchError': 'Не удалось выполнить поиск в Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash отклонил этот сервер. Администратор может добавить бесплатный ключ Unsplash API в настройках администрирования, чтобы заработал поиск изображений.',
|
||||
'dashboard.useUnsplashPhoto': 'Использовать фото Unsplash от {photographer}',
|
||||
'dashboard.titleRequired': 'Название обязательно',
|
||||
'dashboard.endDateError': 'Дата окончания должна быть позже даты начала',
|
||||
|
||||
@@ -155,6 +155,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'О запланированной общей длительности',
|
||||
'tours.planner.plannedTotalManual': 'Ручное значение',
|
||||
'tours.planner.timeValueInvalid': 'Введите целое значение от 0 до 1440 минут.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Время в пути вместе с перерывами превышает 24 часа в день. Сделайте маршрут многодневным через «Завершить этап здесь» на остановке или введите меньшую запланированную общую длительность.',
|
||||
'tours.planner.walkingTimeUnknown': 'Не рассчитано',
|
||||
'tours.planner.walkingShort': 'Ходьба',
|
||||
'tours.planner.breaksShort': 'Перерывы',
|
||||
|
||||
@@ -117,6 +117,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Zadajte hľadaný výraz',
|
||||
'dashboard.unsplashNoResults': 'Nenašli sa žiadne obrázky',
|
||||
'dashboard.coverSearchError': 'Nepodarilo sa vyhľadať na Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash tento server odmietol. Správca môže v nastaveniach administrácie pridať bezplatný kľúč Unsplash API, aby vyhľadávanie obrázkov fungovalo.',
|
||||
'dashboard.useUnsplashPhoto': 'Použiť fotku z Unsplash od {photographer}',
|
||||
'dashboard.titleRequired': 'Názov je povinný',
|
||||
'dashboard.endDateError': 'Dátum konca musí byť po dátume začiatku',
|
||||
|
||||
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'O plánovanej celkovej dobe',
|
||||
'tours.planner.plannedTotalManual': 'Ručná úprava',
|
||||
'tours.planner.timeValueInvalid': 'Zadajte celú hodnotu od 0 do 1440 minút.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Čas chôdze a prestávky dávajú spolu viac než 24 hodín denne. Urobte z nej viacdňovú túru cez „Ukončiť etapu tu“ pri zastávke alebo zadajte kratšiu plánovanú celkovú dobu.',
|
||||
'tours.planner.walkingTimeUnknown': 'Nevypočítané',
|
||||
'tours.planner.walkingShort': 'Chôdza',
|
||||
'tours.planner.breaksShort': 'Prestávky',
|
||||
|
||||
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Ange en sökterm',
|
||||
'dashboard.unsplashNoResults': 'Inga bilder hittades',
|
||||
'dashboard.coverSearchError': 'Det gick inte att söka på Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash avvisade den här servern. En administratör kan lägga till en gratis Unsplash-API-nyckel i administratörsinställningarna så att bildsökningen fungerar.',
|
||||
'dashboard.useUnsplashPhoto': 'Använd Unsplash-foto av {photographer}',
|
||||
'dashboard.titleRequired': 'Titel är obligatoriskt',
|
||||
'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet',
|
||||
|
||||
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Om planerad total tid',
|
||||
'tours.planner.plannedTotalManual': 'Manuellt värde',
|
||||
'tours.planner.timeValueInvalid': 'Ange ett helt värde från 0 till 1440 minuter.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Gångtid och pauser blir tillsammans mer än 24 timmar per dag. Gör det till en flerdagarstur med ”Avsluta etappen här” vid ett stopp, eller ange en kortare planerad total tid.',
|
||||
'tours.planner.walkingTimeUnknown': 'Inte beräknad',
|
||||
'tours.planner.walkingShort': 'Gång',
|
||||
'tours.planner.breaksShort': 'Pauser',
|
||||
|
||||
@@ -103,6 +103,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'ป้อนคำค้นหา',
|
||||
'dashboard.unsplashNoResults': 'ไม่พบภาพ',
|
||||
'dashboard.coverSearchError': 'ไม่สามารถค้นหา Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash ปฏิเสธเซิร์ฟเวอร์นี้ ผู้ดูแลระบบสามารถเพิ่มคีย์ Unsplash API ฟรีในการตั้งค่าผู้ดูแลระบบเพื่อให้การค้นหารูปภาพใช้งานได้',
|
||||
'dashboard.useUnsplashPhoto': 'ใช้รูปภาพ Unsplash โดย {photographer}',
|
||||
'dashboard.titleRequired': 'ต้องระบุชื่อ',
|
||||
'dashboard.endDateError': 'วันที่สิ้นสุดต้องอยู่หลังวันที่เริ่มต้น',
|
||||
|
||||
@@ -134,6 +134,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'เกี่ยวกับระยะเวลารวมที่วางแผนไว้',
|
||||
'tours.planner.plannedTotalManual': 'กำหนดเอง',
|
||||
'tours.planner.timeValueInvalid': 'ป้อนค่าจำนวนเต็มตั้งแต่ 0 ถึง 1440 นาที',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'เวลาเดินรวมเวลาพักเกิน 24 ชั่วโมงต่อวัน ให้แบ่งเป็นทริปหลายวันด้วย “จบช่วงที่นี่” ที่จุดแวะ หรือป้อนระยะเวลารวมที่วางแผนไว้ให้สั้นลง',
|
||||
'tours.planner.walkingTimeUnknown': 'ยังไม่ได้คำนวณ',
|
||||
'tours.planner.walkingShort': 'เดิน',
|
||||
'tours.planner.breaksShort': 'การพัก',
|
||||
|
||||
@@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour
|
||||
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
|
||||
{
|
||||
file: 'tours',
|
||||
generalKeyCount: 1350,
|
||||
generalKeyCount: 1351,
|
||||
modules: toursModules,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Bir arama terimi girin',
|
||||
'dashboard.unsplashNoResults': 'Görsel bulunamadı',
|
||||
'dashboard.coverSearchError': 'Unsplash araması başarısız oldu',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash bu sunucuyu reddetti. Görsel aramanın çalışması için bir yönetici, yönetici ayarlarına ücretsiz bir Unsplash API anahtarı ekleyebilir.',
|
||||
'dashboard.useUnsplashPhoto': '{photographer} tarafından çekilen Unsplash fotoğrafını kullan',
|
||||
'dashboard.titleRequired': 'Başlık gerekli',
|
||||
'dashboard.endDateError': 'Bitiş tarihi başlangıçtan sonra olmalı',
|
||||
|
||||
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Planlanan toplam süre hakkında',
|
||||
'tours.planner.plannedTotalManual': 'Elle değiştirilen',
|
||||
'tours.planner.timeValueInvalid': '0 ile 1440 dakika arasında tam bir değer girin.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Yürüyüş süresi ve molalar günde 24 saati aşıyor. Bir durakta “Etabı burada bitir” ile çok günlü bir tura dönüştürün ya da daha kısa bir planlanan toplam süre girin.',
|
||||
'tours.planner.walkingTimeUnknown': 'Hesaplanmadı',
|
||||
'tours.planner.walkingShort': 'Yürüyüş',
|
||||
'tours.planner.breaksShort': 'Molalar',
|
||||
|
||||
@@ -126,6 +126,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Введіть пошуковий запит',
|
||||
'dashboard.unsplashNoResults': 'Зображень не знайдено',
|
||||
'dashboard.coverSearchError': 'Не вдалося виконати пошук в Unsplash',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash відхилив цей сервер. Адміністратор може додати безкоштовний ключ Unsplash API в налаштуваннях адміністрування, щоб запрацював пошук зображень.',
|
||||
'dashboard.useUnsplashPhoto': 'Використати фото Unsplash від {photographer}',
|
||||
'dashboard.titleRequired': "Назва обов'язкова",
|
||||
'dashboard.endDateError': 'Дата закінчення повинна бути пізніше дати початку',
|
||||
|
||||
@@ -152,6 +152,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Про заплановану загальну тривалість',
|
||||
'tours.planner.plannedTotalManual': 'Ручне значення',
|
||||
'tours.planner.timeValueInvalid': 'Введіть ціле значення від 0 до 1440 хвилин.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Час у дорозі разом із перервами перевищує 24 години на день. Зробіть маршрут багатоденним через «Завершити етап тут» на зупинці або введіть коротшу заплановану загальну тривалість.',
|
||||
'tours.planner.walkingTimeUnknown': 'Не розраховано',
|
||||
'tours.planner.walkingShort': 'Ходьба',
|
||||
'tours.planner.breaksShort': 'Перерви',
|
||||
|
||||
@@ -203,6 +203,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': 'Nhập từ khóa tìm kiếm',
|
||||
'dashboard.unsplashNoResults': 'Không tìm thấy ảnh nào',
|
||||
'dashboard.coverSearchError': 'Tìm kiếm trên Unsplash không thành công',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash đã từ chối máy chủ này. Quản trị viên có thể thêm khóa API Unsplash miễn phí trong cài đặt quản trị để tìm kiếm hình ảnh hoạt động.',
|
||||
'dashboard.useUnsplashPhoto': 'Dùng ảnh Unsplash của {photographer}',
|
||||
'dashboard.tripChangeNeedsConnection':
|
||||
'Đổi ngày, số ngày, tiền tệ hoặc ảnh bìa cần có kết nối. Tên, mô tả và lời nhắc có thể đổi khi ngoại tuyến.',
|
||||
|
||||
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': 'Về tổng thời lượng dự kiến',
|
||||
'tours.planner.plannedTotalManual': 'Ghi đè thủ công',
|
||||
'tours.planner.timeValueInvalid': 'Nhập giá trị nguyên từ 0 đến 1440 phút.',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'Thời gian đi bộ cộng thời gian nghỉ vượt quá 24 giờ mỗi ngày. Hãy chia thành chuyến nhiều ngày bằng “Kết thúc chặng tại đây” ở một điểm dừng, hoặc nhập tổng thời lượng dự kiến ngắn hơn.',
|
||||
'tours.planner.walkingTimeUnknown': 'Chưa tính',
|
||||
'tours.planner.walkingShort': 'Đi bộ',
|
||||
'tours.planner.breaksShort': 'Nghỉ',
|
||||
|
||||
@@ -89,6 +89,8 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.unsplashQueryRequired': '請輸入搜尋詞',
|
||||
'dashboard.unsplashNoResults': '找不到圖片',
|
||||
'dashboard.coverSearchError': 'Unsplash 搜尋失敗',
|
||||
'dashboard.unsplashKeyNeeded':
|
||||
'Unsplash 拒絕了此伺服器。管理員可以在管理設定中新增免費的 Unsplash API 金鑰,以便使用圖片搜尋。',
|
||||
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
|
||||
'dashboard.titleRequired': '標題為必填項',
|
||||
'dashboard.endDateError': '結束日期必須晚於開始日期',
|
||||
|
||||
@@ -127,6 +127,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.plannedTotalInfoLabel': '關於預計總時長',
|
||||
'tours.planner.plannedTotalManual': '手動覆寫',
|
||||
'tours.planner.timeValueInvalid': '請輸入 0 到 1440 分鐘之間的整數。',
|
||||
'tours.planner.plannedTotalOverDay':
|
||||
'步行時間加休息每天超過 24 小時。請在某個停靠點選擇「在此結束階段」,把它分成多日行程,或輸入更短的預計總時長。',
|
||||
'tours.planner.walkingTimeUnknown': '未計算',
|
||||
'tours.planner.walkingShort': '步行',
|
||||
'tours.planner.breaksShort': '休息',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user