mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book read-only with a reload banner and saves nothing, where it used to drop the elements it could not read on the next save. - The server refuses a save, a restore or a tool edit that would write an older revision over a newer book or one newer than itself (409 book_schema_newer), on REST and through every book tool alike. - normalizeBookDocument moves to book-normalize.ts. - Print waits for photos eagerly and up to 15 s for an export of more than 40 photos, and skips images without a source.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { BOOK_GRIDS, bookGridElements, defaultBookGridBox } from './book-grids';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, applyBookTemplate, type BookTemplate } from './book-layouts';
|
||||
import { normalizeBookDocument } from './book-normalize';
|
||||
import {
|
||||
applySavedBookLayout,
|
||||
bookLayoutFromSpread,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
bookLayoutRole,
|
||||
nextBookLayoutName,
|
||||
} from './book-saved-layouts';
|
||||
import { normalizeBookDocument, type BookDocument } from './book.schema';
|
||||
import type { BookDocument } from './book.schema';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
bookElementSchema,
|
||||
bookLayoutSchema,
|
||||
type BookDocument,
|
||||
} from './book.schema';
|
||||
|
||||
/**
|
||||
* Read a stored document without ever throwing.
|
||||
*
|
||||
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
|
||||
* open — an editor that refuses to load someone's work because one field drifted
|
||||
* is worse than one that drops the field.
|
||||
*
|
||||
* ── Why there are two attempts ───────────────────────────────────────────
|
||||
*
|
||||
* Dropping *the field* was the intent; dropping the whole book was what the
|
||||
* code did. `elements` is an array of a discriminated union, so one element the
|
||||
* contract cannot read fails its spread, fails the array, fails the document —
|
||||
* and this function then handed back an empty book. It is not hypothetical: a
|
||||
* mood mark carried the journal's key in a field capped at two characters, so
|
||||
* every book with one on a page refused to save, and would have opened blank.
|
||||
* Both the client's load seam and the server's read *and* write path run
|
||||
* through here, which means the empty book was also what got written down.
|
||||
*
|
||||
* So a document that fails whole is tried again with the unreadable elements
|
||||
* removed, and only a document that fails even then is given up on. Losing one
|
||||
* decoration to a version skew is a shrug; losing the book is not.
|
||||
*/
|
||||
export function normalizeBookDocument(raw: unknown): BookDocument {
|
||||
const parsed = bookDocumentSchema.safeParse(raw);
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
|
||||
if (salvaged.success) return salvaged.data;
|
||||
|
||||
return bookDocumentSchema.parse({});
|
||||
}
|
||||
|
||||
function withoutUnreadableElements(raw: unknown): unknown {
|
||||
if (!raw || typeof raw !== 'object') return raw;
|
||||
const doc = raw as { spreads?: unknown };
|
||||
if (!Array.isArray(doc.spreads)) return raw;
|
||||
|
||||
const readable = (list: unknown) =>
|
||||
Array.isArray(list)
|
||||
? // Trimmed to the cap as well as filtered: a spread that grew past 60
|
||||
// fails the array the same way an unreadable element fails the union, and
|
||||
// losing the sixty-first decoration beats losing the book.
|
||||
list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
|
||||
: list;
|
||||
|
||||
const layouts = (doc as { layouts?: unknown }).layouts;
|
||||
return {
|
||||
...doc,
|
||||
// A saved layout is a convenience: one that no longer reads is dropped, not the book.
|
||||
...(Array.isArray(layouts)
|
||||
? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
|
||||
: {}),
|
||||
spreads: doc.spreads.map((sp) => {
|
||||
if (!sp || typeof sp !== 'object') return sp;
|
||||
const spread = sp as { elements?: unknown; parked?: unknown };
|
||||
return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { normalizeBookDocument } from './book-normalize';
|
||||
import {
|
||||
BOOK_SCHEMA_REV,
|
||||
bookSchemaRev,
|
||||
isBookNewerThanReader,
|
||||
readBookDocument,
|
||||
stampBookSchemaRev,
|
||||
} from './book-schema-rev';
|
||||
import { bookSaveRequestSchema } from './book-store.schema';
|
||||
import { bookDocumentSchema } from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// BKREV-001 to BKREV-007
|
||||
|
||||
/**
|
||||
* The reader guard: a document says which revision of the format wrote it, a
|
||||
* reader older than that opens it without saving it, and a book saved today
|
||||
* stores exactly what it stored before the field existed.
|
||||
*/
|
||||
|
||||
const frame = { x: 12, y: 18, w: 60, h: 40 };
|
||||
|
||||
const doc = (over: Record<string, unknown> = {}) => ({
|
||||
version: 1,
|
||||
title: 'Lisbon',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads: [
|
||||
{
|
||||
id: 's1',
|
||||
role: 'inner',
|
||||
background: null,
|
||||
entryId: null,
|
||||
elements: [{ id: 't1', kind: 'text', frame, text: 'Alfama' }],
|
||||
parked: [],
|
||||
},
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the revision a document was written by', () => {
|
||||
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
|
||||
expect(BOOK_SCHEMA_REV).toBe(1);
|
||||
expect(bookSchemaRev(doc())).toBe(1);
|
||||
expect(bookSchemaRev(null)).toBe(1);
|
||||
expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
|
||||
for (const odd of [0, -2, 2.5, '3', Number.NaN, null]) expect(bookSchemaRev(doc({ schemaRev: odd }))).toBe(1);
|
||||
expect(bookSchemaRev(doc({ schemaRev: 3 }))).toBe(3);
|
||||
});
|
||||
|
||||
it('BKREV-002: keeps the revision through the contract, and lets a broken one fall back without losing the book', () => {
|
||||
expect(bookDocumentSchema.parse(doc({ schemaRev: 2 })).schemaRev).toBe(2);
|
||||
const odd = bookDocumentSchema.safeParse(doc({ schemaRev: 'two' }));
|
||||
expect(odd.success).toBe(true);
|
||||
expect(odd.success && odd.data.schemaRev).toBeUndefined();
|
||||
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
|
||||
const today = normalizeBookDocument(doc());
|
||||
expect('schemaRev' in today).toBe(false);
|
||||
expect(stampBookSchemaRev(today)).toBe(today);
|
||||
expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a reader older than the document', () => {
|
||||
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
|
||||
expect(isBookNewerThanReader(doc())).toBe(false);
|
||||
// A reader of a later revision reads the same document as its own.
|
||||
expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
|
||||
const unreadable = doc({ schemaRev: 5, page: 'not a page' });
|
||||
expect(isBookNewerThanReader(unreadable)).toBe(true);
|
||||
});
|
||||
|
||||
it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
const read = readBookDocument(raw);
|
||||
expect(read.newer).toBe(true);
|
||||
expect(read.document.schemaRev).toBe(2);
|
||||
expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
|
||||
expect(readBookDocument(doc()).newer).toBe(false);
|
||||
});
|
||||
|
||||
it('BKREV-006: a writer stamps its own revision once that is above 1, and never lowers one', () => {
|
||||
const today = normalizeBookDocument(doc());
|
||||
expect(stampBookSchemaRev(today, 2).schemaRev).toBe(2);
|
||||
const later = normalizeBookDocument(doc({ schemaRev: 3 }));
|
||||
expect(stampBookSchemaRev(later, 2)).toBe(later);
|
||||
expect(stampBookSchemaRev(later, 2).schemaRev).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a save request carrying a newer book', () => {
|
||||
it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
|
||||
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
|
||||
const raw = doc({ schemaRev: 2 });
|
||||
const spread = (raw.spreads as { elements: unknown[] }[])[0];
|
||||
if (spread) spread.elements.push(sticker);
|
||||
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
|
||||
// A book of today still reads whole, revision or none.
|
||||
const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
|
||||
expect(today).toEqual(bookDocumentSchema.parse(doc()));
|
||||
expect(bookSaveRequestSchema.parse({ document: doc({ schemaRev: 1 }) }).document).toMatchObject({ schemaRev: 1 });
|
||||
expect(bookSaveRequestSchema.safeParse({ document: { schemaRev: 1, spreads: 'no' } }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { normalizeBookDocument } from './book-normalize';
|
||||
import type { BookDocument } from './book.schema';
|
||||
|
||||
/**
|
||||
* Which revision of the book format a document was written by, and what a
|
||||
* reader does with one that is newer than it knows.
|
||||
*
|
||||
* ── Why a revision beside `version` ──────────────────────────────────────
|
||||
*
|
||||
* `version` is the format itself and stays 1: every document ever saved
|
||||
* reads under it, and nothing here asks for a migration. What grows is the
|
||||
* vocabulary inside it, a new element kind, a new font, a new field the
|
||||
* renderer draws, and an older reader cannot tell any of those from noise.
|
||||
* `normalizeBookDocument` drops an element it cannot read and the contract
|
||||
* strips a field it does not know, both on purpose, so a damaged book still
|
||||
* opens. Saving what is left is the part that must not happen: an old tab, an
|
||||
* offline copy or a replayed request would write the loss down for everyone.
|
||||
*
|
||||
* So the document says which revision wrote it. A reader that meets a newer
|
||||
* one opens it read-only and asks for a reload, and the server refuses a save
|
||||
* from a writer older than the book it would replace.
|
||||
*
|
||||
* ── When to bump ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* `BOOK_SCHEMA_REV` goes up by one in the change that adds an element kind,
|
||||
* an enum value or a drawn field to book.schema.ts, never for a change an
|
||||
* older reader survives whole. A writer stamps its own revision on what it
|
||||
* saves only once that is above 1, so a book saved today stores exactly what
|
||||
* it stored before this existed.
|
||||
*/
|
||||
export const BOOK_SCHEMA_REV = 1;
|
||||
|
||||
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
|
||||
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
|
||||
|
||||
/**
|
||||
* The revision a document says it was written by, read from the raw value
|
||||
* rather than the parsed one: a document so new that it fails to parse whole
|
||||
* still says how new it is. Anything that is not a whole number from 1 up,
|
||||
* and every document from before the field, reads as 1.
|
||||
*/
|
||||
export function bookSchemaRev(raw: unknown): number {
|
||||
if (!raw || typeof raw !== 'object') return 1;
|
||||
const rev = (raw as { schemaRev?: unknown }).schemaRev;
|
||||
return typeof rev === 'number' && Number.isInteger(rev) && rev >= 1 ? rev : 1;
|
||||
}
|
||||
|
||||
/** Whether a document was written by a newer revision than `readerRev` (this build's by default). */
|
||||
export function isBookNewerThanReader(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): boolean {
|
||||
return bookSchemaRev(raw) > readerRev;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as a writer of `writerRev` saves it: stamped with that
|
||||
* revision, unless it already says as much or more. A document at revision 1
|
||||
* is handed back as it is, so nothing changes on disk until the first bump.
|
||||
*/
|
||||
export function stampBookSchemaRev<T extends BookDocument>(doc: T, writerRev: number = BOOK_SCHEMA_REV): T {
|
||||
return bookSchemaRev(doc) >= writerRev ? doc : { ...doc, schemaRev: writerRev };
|
||||
}
|
||||
|
||||
/** A stored document as a reader holds it: normalised, and whether it was written by a newer revision. */
|
||||
export interface BookRead {
|
||||
document: BookDocument;
|
||||
/** True when the document is newer than the reader: show it, never save it. */
|
||||
newer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored document the way normalizeBookDocument does, and say whether
|
||||
* it is newer than this reader. The normalised copy of a newer book has lost
|
||||
* what this build cannot read, which is why `newer` has to travel with it.
|
||||
*/
|
||||
export function readBookDocument(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): BookRead {
|
||||
return { document: normalizeBookDocument(raw), newer: isBookNewerThanReader(raw, readerRev) };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BOOK_SCHEMA_REV } from './book-schema-rev';
|
||||
import { bookDocumentSchema } from './book.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -42,9 +43,23 @@ export const bookRecordSchema = bookSummarySchema.extend({
|
||||
});
|
||||
export type BookRecord = z.infer<typeof bookRecordSchema>;
|
||||
|
||||
/**
|
||||
* A document a newer TREK wrote, as far as a save request carries it: only the
|
||||
* revision. Its new element kinds or values would fail `bookDocumentSchema` at
|
||||
* the pipe and end in a 400, so it is taken first and handed to the service
|
||||
* this bare, where the save is refused with 409 `book_schema_newer` before
|
||||
* anything is written. Nothing of the rest is needed for that refusal.
|
||||
*/
|
||||
const newerBookDocumentSchema = z.object({
|
||||
schemaRev: z
|
||||
.number()
|
||||
.int()
|
||||
.min(BOOK_SCHEMA_REV + 1),
|
||||
});
|
||||
|
||||
export const bookSaveRequestSchema = z.object({
|
||||
title: z.string().max(200).default(''),
|
||||
document: bookDocumentSchema,
|
||||
document: z.union([newerBookDocumentSchema, bookDocumentSchema]),
|
||||
/**
|
||||
* The version this edit was made against.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeBookDocument } from './book-normalize';
|
||||
import {
|
||||
MAX_BOOK_LAYOUTS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
bookSpreadSchema,
|
||||
bookStatsElementSchema,
|
||||
bookTextElementSchema,
|
||||
normalizeBookDocument,
|
||||
} from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -936,6 +936,8 @@ export type BookLayout = z.infer<typeof bookLayoutSchema>;
|
||||
export const bookDocumentSchema = z.object({
|
||||
/** In the document, not in a column: a format bump must not need a migration. */
|
||||
version: z.literal(1).catch(1).default(1),
|
||||
/** The format revision that wrote it (book-schema-rev.ts); absent, or unreadable and so dropped, reads as 1. */
|
||||
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([]),
|
||||
@@ -943,63 +945,3 @@ export const bookDocumentSchema = z.object({
|
||||
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
|
||||
});
|
||||
export type BookDocument = z.infer<typeof bookDocumentSchema>;
|
||||
|
||||
/**
|
||||
* Read a stored document without ever throwing.
|
||||
*
|
||||
* Same shape as `normalizeAppearance`: a book that cannot be parsed must still
|
||||
* open — an editor that refuses to load someone's work because one field drifted
|
||||
* is worse than one that drops the field.
|
||||
*
|
||||
* ── Why there are two attempts ───────────────────────────────────────────
|
||||
*
|
||||
* Dropping *the field* was the intent; dropping the whole book was what the
|
||||
* code did. `elements` is an array of a discriminated union, so one element the
|
||||
* contract cannot read fails its spread, fails the array, fails the document —
|
||||
* and this function then handed back an empty book. It is not hypothetical: a
|
||||
* mood mark carried the journal's key in a field capped at two characters, so
|
||||
* every book with one on a page refused to save, and would have opened blank.
|
||||
* Both the client's load seam and the server's read *and* write path run
|
||||
* through here, which means the empty book was also what got written down.
|
||||
*
|
||||
* So a document that fails whole is tried again with the unreadable elements
|
||||
* removed, and only a document that fails even then is given up on. Losing one
|
||||
* decoration to a version skew is a shrug; losing the book is not.
|
||||
*/
|
||||
export function normalizeBookDocument(raw: unknown): BookDocument {
|
||||
const parsed = bookDocumentSchema.safeParse(raw);
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
|
||||
if (salvaged.success) return salvaged.data;
|
||||
|
||||
return bookDocumentSchema.parse({});
|
||||
}
|
||||
|
||||
function withoutUnreadableElements(raw: unknown): unknown {
|
||||
if (!raw || typeof raw !== 'object') return raw;
|
||||
const doc = raw as { spreads?: unknown };
|
||||
if (!Array.isArray(doc.spreads)) return raw;
|
||||
|
||||
const readable = (list: unknown) =>
|
||||
Array.isArray(list)
|
||||
? // Trimmed to the cap as well as filtered: a spread that grew past 60
|
||||
// fails the array the same way an unreadable element fails the union, and
|
||||
// losing the sixty-first decoration beats losing the book.
|
||||
list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
|
||||
: list;
|
||||
|
||||
const layouts = (doc as { layouts?: unknown }).layouts;
|
||||
return {
|
||||
...doc,
|
||||
// A saved layout is a convenience: one that no longer reads is dropped, not the book.
|
||||
...(Array.isArray(layouts)
|
||||
? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
|
||||
: {}),
|
||||
spreads: doc.spreads.map((sp) => {
|
||||
if (!sp || typeof sp !== 'object') return sp;
|
||||
const spread = sp as { elements?: unknown; parked?: unknown };
|
||||
return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -677,6 +677,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'تدوير لليسار',
|
||||
'journey.studio.rotateRight': 'تدوير لليمين',
|
||||
'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ',
|
||||
'journey.studio.saveNewer': 'حفظ إصدار أحدث من Studio هذا الكتاب. أعد التحميل لتعديله.',
|
||||
'journey.studio.saveReload': 'إعادة التحميل',
|
||||
'journey.studio.filterPhotos': 'تصفية الصور',
|
||||
'journey.studio.filterAll': 'كل الصور',
|
||||
'journey.studio.filterLoose': 'ليست ضمن إدخال',
|
||||
|
||||
@@ -712,6 +712,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Sola fırlat',
|
||||
'journey.studio.rotateRight': 'Sağa fırlat',
|
||||
'journey.studio.saveReadOnly': 'Yalnız oxuma rejimi — yadda saxlanılmadı',
|
||||
'journey.studio.saveNewer': 'Bu kitabı Studio-nun daha yeni versiyası saxlayıb. Redaktə etmək üçün yenidən yükləyin.',
|
||||
'journey.studio.saveReload': 'Yenidən yüklə',
|
||||
|
||||
'journey.studio.filterPhotos': 'Fotoları filtrlə',
|
||||
'journey.studio.filterAll': 'Bütün fotolar',
|
||||
|
||||
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Girar à esquerda',
|
||||
'journey.studio.rotateRight': 'Girar à direita',
|
||||
'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo',
|
||||
'journey.studio.saveNewer': 'Uma versão mais recente do Studio salvou este livro. Recarregue para editar.',
|
||||
'journey.studio.saveReload': 'Recarregar',
|
||||
'journey.studio.filterPhotos': 'Filtrar fotos',
|
||||
'journey.studio.filterAll': 'Todas as fotos',
|
||||
'journey.studio.filterLoose': 'Sem entrada',
|
||||
|
||||
@@ -653,6 +653,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': "Gira cap a l'esquerra",
|
||||
'journey.studio.rotateRight': 'Gira cap a la dreta',
|
||||
'journey.studio.saveReadOnly': 'Només lectura, no es desa',
|
||||
'journey.studio.saveNewer': 'Una versió més recent de Studio ha desat aquest llibre. Recarrega per editar-lo.',
|
||||
'journey.studio.saveReload': 'Recarrega',
|
||||
'journey.studio.filterPhotos': 'Filtra les fotos',
|
||||
'journey.studio.filterAll': 'Totes les fotos',
|
||||
'journey.studio.filterLoose': 'Sense entrada',
|
||||
|
||||
@@ -655,6 +655,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Otočit doleva',
|
||||
'journey.studio.rotateRight': 'Otočit doprava',
|
||||
'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se',
|
||||
'journey.studio.saveNewer': 'Tuto knihu uložila novější verze aplikace Studio. Pro úpravy ji načtěte znovu.',
|
||||
'journey.studio.saveReload': 'Načíst znovu',
|
||||
'journey.studio.filterPhotos': 'Filtrovat fotografie',
|
||||
'journey.studio.filterAll': 'Všechny fotografie',
|
||||
'journey.studio.filterLoose': 'Bez záznamu',
|
||||
|
||||
@@ -654,6 +654,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Nach links drehen',
|
||||
'journey.studio.rotateRight': 'Nach rechts drehen',
|
||||
'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert',
|
||||
'journey.studio.saveNewer': 'Eine neuere Studio-Version hat dieses Buch gespeichert. Zum Bearbeiten neu laden.',
|
||||
'journey.studio.saveReload': 'Neu laden',
|
||||
'journey.studio.filterPhotos': 'Fotos filtern',
|
||||
'journey.studio.filterAll': 'Alle Fotos',
|
||||
'journey.studio.filterLoose': 'Keinem Eintrag zugeordnet',
|
||||
|
||||
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Rotate left',
|
||||
'journey.studio.rotateRight': 'Rotate right',
|
||||
'journey.studio.saveReadOnly': 'Read-only, not saved',
|
||||
'journey.studio.saveNewer': 'A newer Studio saved this book. Reload to edit it.',
|
||||
'journey.studio.saveReload': 'Reload',
|
||||
'journey.studio.filterPhotos': 'Filter photos',
|
||||
'journey.studio.filterAll': 'All photos',
|
||||
'journey.studio.filterLoose': 'Not in an entry',
|
||||
|
||||
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Girar a la izquierda',
|
||||
'journey.studio.rotateRight': 'Girar a la derecha',
|
||||
'journey.studio.saveReadOnly': 'Solo lectura, no se guarda',
|
||||
'journey.studio.saveNewer': 'Una versión más reciente de Studio guardó este libro. Recarga para editarlo.',
|
||||
'journey.studio.saveReload': 'Recargar',
|
||||
'journey.studio.filterPhotos': 'Filtrar fotos',
|
||||
'journey.studio.filterAll': 'Todas las fotos',
|
||||
'journey.studio.filterLoose': 'Sin entrada',
|
||||
|
||||
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Pööra vasakule',
|
||||
'journey.studio.rotateRight': 'Pööra paremale',
|
||||
'journey.studio.saveReadOnly': 'Ainult lugemiseks, salvestamata',
|
||||
'journey.studio.saveNewer': 'Selle raamatu salvestas Studio uuem versioon. Muutmiseks laadi leht uuesti.',
|
||||
'journey.studio.saveReload': 'Laadi uuesti',
|
||||
'journey.studio.filterPhotos': 'Filtreeri fotosid',
|
||||
'journey.studio.filterAll': 'Kõik fotod',
|
||||
'journey.studio.filterLoose': 'Sissekandega sidumata',
|
||||
|
||||
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Pivoter à gauche',
|
||||
'journey.studio.rotateRight': 'Pivoter à droite',
|
||||
'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré",
|
||||
'journey.studio.saveNewer': 'Une version plus récente de Studio a enregistré ce livre. Rechargez pour le modifier.',
|
||||
'journey.studio.saveReload': 'Recharger',
|
||||
'journey.studio.filterPhotos': 'Filtrer les photos',
|
||||
'journey.studio.filterAll': 'Toutes les photos',
|
||||
'journey.studio.filterLoose': 'Sans entrée',
|
||||
|
||||
@@ -657,6 +657,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Περιστροφή αριστερά',
|
||||
'journey.studio.rotateRight': 'Περιστροφή δεξιά',
|
||||
'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται',
|
||||
'journey.studio.saveNewer': 'Μια νεότερη έκδοση του Studio αποθήκευσε αυτό το βιβλίο. Φορτώστε ξανά για επεξεργασία.',
|
||||
'journey.studio.saveReload': 'Επαναφόρτωση',
|
||||
'journey.studio.filterPhotos': 'Φιλτράρισμα φωτογραφιών',
|
||||
'journey.studio.filterAll': 'Όλες οι φωτογραφίες',
|
||||
'journey.studio.filterLoose': 'Χωρίς καταχώρηση',
|
||||
|
||||
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Forgatás balra',
|
||||
'journey.studio.rotateRight': 'Forgatás jobbra',
|
||||
'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik',
|
||||
'journey.studio.saveNewer': 'Ezt a könyvet a Studio egy újabb verziója mentette. Töltsd újra a szerkesztéshez.',
|
||||
'journey.studio.saveReload': 'Újratöltés',
|
||||
'journey.studio.filterPhotos': 'Fotók szűrése',
|
||||
'journey.studio.filterAll': 'Összes fotó',
|
||||
'journey.studio.filterLoose': 'Nincs bejegyzésben',
|
||||
|
||||
@@ -641,6 +641,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Putar ke kiri',
|
||||
'journey.studio.rotateRight': 'Putar ke kanan',
|
||||
'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan',
|
||||
'journey.studio.saveNewer': 'Buku ini disimpan oleh versi Studio yang lebih baru. Muat ulang untuk mengeditnya.',
|
||||
'journey.studio.saveReload': 'Muat ulang',
|
||||
'journey.studio.filterPhotos': 'Filter foto',
|
||||
'journey.studio.filterAll': 'Semua foto',
|
||||
'journey.studio.filterLoose': 'Tidak dalam entri',
|
||||
|
||||
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Ruota a sinistra',
|
||||
'journey.studio.rotateRight': 'Ruota a destra',
|
||||
'journey.studio.saveReadOnly': 'Sola lettura, non si salva',
|
||||
'journey.studio.saveNewer': 'Una versione più recente di Studio ha salvato questo libro. Ricarica per modificarlo.',
|
||||
'journey.studio.saveReload': 'Ricarica',
|
||||
'journey.studio.filterPhotos': 'Filtra foto',
|
||||
'journey.studio.filterAll': 'Tutte le foto',
|
||||
'journey.studio.filterLoose': 'Senza voce',
|
||||
|
||||
@@ -639,6 +639,9 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': '左に回転',
|
||||
'journey.studio.rotateRight': '右に回転',
|
||||
'journey.studio.saveReadOnly': '閲覧のみ・保存されません',
|
||||
'journey.studio.saveNewer':
|
||||
'この本は新しいバージョンの Studio で保存されました。編集するには再読み込みしてください。',
|
||||
'journey.studio.saveReload': '再読み込み',
|
||||
'journey.studio.filterPhotos': '写真を絞り込む',
|
||||
'journey.studio.filterAll': 'すべての写真',
|
||||
'journey.studio.filterLoose': 'エントリーに属さない',
|
||||
|
||||
@@ -636,6 +636,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': '왼쪽으로 회전',
|
||||
'journey.studio.rotateRight': '오른쪽으로 회전',
|
||||
'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음',
|
||||
'journey.studio.saveNewer': '이 책은 더 새로운 버전의 Studio에서 저장되었습니다. 편집하려면 새로고침하세요.',
|
||||
'journey.studio.saveReload': '새로고침',
|
||||
'journey.studio.filterPhotos': '사진 필터',
|
||||
'journey.studio.filterAll': '모든 사진',
|
||||
'journey.studio.filterLoose': '항목에 없음',
|
||||
|
||||
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Naar links draaien',
|
||||
'journey.studio.rotateRight': 'Naar rechts draaien',
|
||||
'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen',
|
||||
'journey.studio.saveNewer': 'Een nieuwere versie van Studio heeft dit boek opgeslagen. Herlaad om het te bewerken.',
|
||||
'journey.studio.saveReload': 'Herladen',
|
||||
'journey.studio.filterPhotos': "Foto's filteren",
|
||||
'journey.studio.filterAll': "Alle foto's",
|
||||
'journey.studio.filterLoose': 'Niet in een vermelding',
|
||||
|
||||
@@ -668,6 +668,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Obróć w lewo',
|
||||
'journey.studio.rotateRight': 'Obróć w prawo',
|
||||
'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się',
|
||||
'journey.studio.saveNewer': 'Nowsza wersja Studio zapisała tę książkę. Odśwież, aby ją edytować.',
|
||||
'journey.studio.saveReload': 'Odśwież',
|
||||
'journey.studio.filterPhotos': 'Filtruj zdjęcia',
|
||||
'journey.studio.filterAll': 'Wszystkie zdjęcia',
|
||||
'journey.studio.filterLoose': 'Bez wpisu',
|
||||
|
||||
@@ -667,6 +667,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Повернуть влево',
|
||||
'journey.studio.rotateRight': 'Повернуть вправо',
|
||||
'journey.studio.saveReadOnly': 'Только чтение, не сохраняется',
|
||||
'journey.studio.saveNewer': 'Эту книгу сохранила более новая версия Studio. Перезагрузите, чтобы редактировать.',
|
||||
'journey.studio.saveReload': 'Перезагрузить',
|
||||
'journey.studio.filterPhotos': 'Фильтр фото',
|
||||
'journey.studio.filterAll': 'Все фото',
|
||||
'journey.studio.filterLoose': 'Без записи',
|
||||
|
||||
@@ -660,6 +660,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Otočiť doľava',
|
||||
'journey.studio.rotateRight': 'Otočiť doprava',
|
||||
'journey.studio.saveReadOnly': 'Iba na čítanie, neukladá sa',
|
||||
'journey.studio.saveNewer': 'Túto knihu uložila novšia verzia aplikácie Studio. Na úpravy ju načítajte znova.',
|
||||
'journey.studio.saveReload': 'Načítať znova',
|
||||
'journey.studio.filterPhotos': 'Filtrovať fotografie',
|
||||
'journey.studio.filterAll': 'Všetky fotografie',
|
||||
'journey.studio.filterLoose': 'Bez záznamu',
|
||||
|
||||
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Rotera åt vänster',
|
||||
'journey.studio.rotateRight': 'Rotera åt höger',
|
||||
'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte',
|
||||
'journey.studio.saveNewer': 'En nyare version av Studio har sparat den här boken. Ladda om för att redigera.',
|
||||
'journey.studio.saveReload': 'Ladda om',
|
||||
'journey.studio.filterPhotos': 'Filtrera foton',
|
||||
'journey.studio.filterAll': 'Alla foton',
|
||||
'journey.studio.filterLoose': 'Inte i något inlägg',
|
||||
|
||||
@@ -633,6 +633,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'หมุนไปทางซ้าย',
|
||||
'journey.studio.rotateRight': 'หมุนไปทางขวา',
|
||||
'journey.studio.saveReadOnly': 'อ่านอย่างเดียว ไม่ได้บันทึก',
|
||||
'journey.studio.saveNewer': 'หนังสือเล่มนี้ถูกบันทึกโดย Studio เวอร์ชันที่ใหม่กว่า โปรดโหลดใหม่เพื่อแก้ไข',
|
||||
'journey.studio.saveReload': 'โหลดใหม่',
|
||||
'journey.studio.filterPhotos': 'กรองรูปภาพ',
|
||||
'journey.studio.filterAll': 'รูปภาพทั้งหมด',
|
||||
'journey.studio.filterLoose': 'ไม่อยู่ในรายการ',
|
||||
|
||||
@@ -650,6 +650,9 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Sola döndür',
|
||||
'journey.studio.rotateRight': 'Sağa döndür',
|
||||
'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor',
|
||||
'journey.studio.saveNewer':
|
||||
'Bu kitap Studio’nun daha yeni bir sürümüyle kaydedildi. Düzenlemek için yeniden yükleyin.',
|
||||
'journey.studio.saveReload': 'Yeniden yükle',
|
||||
'journey.studio.filterPhotos': 'Fotoğrafları filtrele',
|
||||
'journey.studio.filterAll': 'Tüm fotoğraflar',
|
||||
'journey.studio.filterLoose': 'Bir kayıtta değil',
|
||||
|
||||
@@ -671,6 +671,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Повернути ліворуч',
|
||||
'journey.studio.rotateRight': 'Повернути праворуч',
|
||||
'journey.studio.saveReadOnly': 'Лише для читання, не зберігається',
|
||||
'journey.studio.saveNewer': 'Цю книгу збережено новішою версією Studio. Перезавантажте, щоб редагувати.',
|
||||
'journey.studio.saveReload': 'Перезавантажити',
|
||||
'journey.studio.filterPhotos': 'Фільтр фото',
|
||||
'journey.studio.filterAll': 'Усі фото',
|
||||
'journey.studio.filterLoose': 'Без запису',
|
||||
|
||||
@@ -640,6 +640,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': 'Xoay trái',
|
||||
'journey.studio.rotateRight': 'Xoay phải',
|
||||
'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu',
|
||||
'journey.studio.saveNewer': 'Cuốn sách này đã được lưu bằng phiên bản Studio mới hơn. Tải lại để chỉnh sửa.',
|
||||
'journey.studio.saveReload': 'Tải lại',
|
||||
'journey.studio.filterPhotos': 'Lọc ảnh',
|
||||
'journey.studio.filterAll': 'Tất cả ảnh',
|
||||
'journey.studio.filterLoose': 'Không thuộc mục nào',
|
||||
|
||||
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': '向左旋轉',
|
||||
'journey.studio.rotateRight': '向右旋轉',
|
||||
'journey.studio.saveReadOnly': '唯讀,不會儲存',
|
||||
'journey.studio.saveNewer': '此書由較新版本的 Studio 儲存。請重新載入後再編輯。',
|
||||
'journey.studio.saveReload': '重新載入',
|
||||
'journey.studio.filterPhotos': '篩選照片',
|
||||
'journey.studio.filterAll': '全部照片',
|
||||
'journey.studio.filterLoose': '不屬於任何條目',
|
||||
|
||||
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.rotateLeft': '向左旋转',
|
||||
'journey.studio.rotateRight': '向右旋转',
|
||||
'journey.studio.saveReadOnly': '只读,不会保存',
|
||||
'journey.studio.saveNewer': '此书由更新版本的 Studio 保存。请重新加载后再编辑。',
|
||||
'journey.studio.saveReload': '重新加载',
|
||||
'journey.studio.filterPhotos': '筛选照片',
|
||||
'journey.studio.filterAll': '全部照片',
|
||||
'journey.studio.filterLoose': '不属于任何条目',
|
||||
|
||||
@@ -116,6 +116,8 @@ export * from './journey/journey-recording-prep.schema';
|
||||
export * from './journey/journey-map.schema';
|
||||
export * from './journey/trip-journal-link.schema';
|
||||
export * from './book/book.schema';
|
||||
export * from './book/book-normalize';
|
||||
export * from './book/book-schema-rev';
|
||||
export * from './book/journey-stats.schema';
|
||||
export * from './book/book-store.schema';
|
||||
export * from './book/book-ids';
|
||||
|
||||
Reference in New Issue
Block a user