mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
feat(assistant): photo changes of a journal get their own card and ways back
The four new photo link tools join the journal's tools: the journey chat offers them to editors, the trip chat loads them with the journey group and the home chat keeps none. A journeyPhotos card shows the photos as thumbnails with the entries they leave and reach, the first one marked as the cover in a new order, and a caption old to new; a library add shows the photos once they landed. A linked photo comes off again, an unlinked one goes back, moved photos go back and a caption is written back, each while nothing changed since. A new order of an entry's photos and a library add have no way back yet. The journal prompt names the photo tools.
This commit is contained in:
@@ -108,7 +108,10 @@ function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||
return (
|
||||
<>
|
||||
{(look.tags ?? []).map((tag, index) => (
|
||||
<SoftPill key={`${tag}-${index}`} icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}>
|
||||
<SoftPill
|
||||
key={`${tag}-${index}`}
|
||||
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
|
||||
>
|
||||
<bdi className="truncate">{tag}</bdi>
|
||||
</SoftPill>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
|
||||
*
|
||||
* A change of a journal's photos in the chat: the entries the photos leave
|
||||
* and reach, the photos as thumbnails, a caption old to new, what stays
|
||||
* where, and the receipt that opens the entry.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const MOVE: JourneyPhotosCard = {
|
||||
kind: 'journeyPhotos',
|
||||
journeyId: 3,
|
||||
mode: 'move',
|
||||
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
|
||||
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||
photos: [
|
||||
{ photoId: 101, video: false, caption: 'Black sand' },
|
||||
{ photoId: 102, video: true, caption: null },
|
||||
],
|
||||
more: 4,
|
||||
kept: false,
|
||||
caption: null,
|
||||
};
|
||||
|
||||
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'p1',
|
||||
verbKey,
|
||||
subject: 'Reynisfjara',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const receipt: AssistantApplied = {
|
||||
toolCallId: 'p1',
|
||||
entity: 'journey_entry',
|
||||
ids: [2],
|
||||
dayId: null,
|
||||
undoUntil: null,
|
||||
};
|
||||
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
|
||||
}
|
||||
|
||||
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal photos', () => {
|
||||
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
|
||||
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vik')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
|
||||
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
|
||||
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
|
||||
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
|
||||
expect(screen.getByText('Entry cover')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
|
||||
const caption = {
|
||||
...MOVE,
|
||||
mode: 'caption' as const,
|
||||
from: null,
|
||||
to: null,
|
||||
more: 0,
|
||||
photos: [{ photoId: 101, video: false, caption: 'Old' }],
|
||||
};
|
||||
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Old')).toHaveClass('line-through');
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
|
||||
const onOpen = vi.fn();
|
||||
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
|
||||
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
|
||||
/**
|
||||
* What a change row of a journal's photos adds under the row: the entries
|
||||
* the photos leave and reach, the photos themselves as small thumbnails (the
|
||||
* first marked as the entry's cover in a new order), a caption old to new,
|
||||
* and what the change leaves where. A change that failed, was discarded or
|
||||
* ran out shows the row alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
|
||||
|
||||
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
|
||||
const { locale } = useTranslation();
|
||||
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
|
||||
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
|
||||
}
|
||||
|
||||
/** The entries the photos leave and reach, the way between them marked. */
|
||||
function Route({ card }: { card: JourneyPhotosCard }) {
|
||||
if (!card.from && !card.to) return null;
|
||||
return (
|
||||
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
|
||||
{card.from && <EntryName entry={card.from} />}
|
||||
{card.from && card.to && (
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
)}
|
||||
{card.to && <EntryName entry={card.to} />}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.photos.length === 0 && card.more === 0) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.photos.map((photo, index) => (
|
||||
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
|
||||
<img
|
||||
src={`/api/photos/${photo.photoId}/thumbnail`}
|
||||
alt={photo.caption ?? ''}
|
||||
loading="lazy"
|
||||
className="size-full rounded-lg bg-surface-secondary object-cover"
|
||||
/>
|
||||
{photo.video && (
|
||||
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
|
||||
<span className="sr-only">{t('assistant.card.journey.video')}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
{card.mode === 'order' && index === 0 && (
|
||||
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.common.more', { count: card.more })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
|
||||
function Notes({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
|
||||
if (card.mode === 'unlink' || card.mode === 'move') {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
|
||||
}
|
||||
if (card.mode === 'order' && card.photos.length > 0) {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
|
||||
}
|
||||
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
<Route card={card} />
|
||||
<Thumbs card={card} />
|
||||
{card.caption && (
|
||||
<span className="mt-1.5 flex">
|
||||
<FactChange
|
||||
icon={MessageSquareText}
|
||||
before={card.caption.before}
|
||||
after={card.caption.after ?? t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Notes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowRightLeft,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
BookPlus,
|
||||
BookX,
|
||||
Eye,
|
||||
ImageMinus,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Link,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Type,
|
||||
@@ -48,6 +53,16 @@ export const JOURNEY_LOOK = {
|
||||
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
|
||||
},
|
||||
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
|
||||
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
|
||||
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
|
||||
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
|
||||
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
|
||||
captionJournalPhoto: {
|
||||
Icon: MessageSquareText,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.captionJournalPhoto',
|
||||
},
|
||||
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
|
||||
},
|
||||
tools: {
|
||||
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
||||
@@ -66,16 +81,19 @@ export const JOURNEY_LOOK = {
|
||||
journey_entry: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journey.open',
|
||||
open: (applied, card) =>
|
||||
card?.kind === 'journeyEntries'
|
||||
open: (applied, card) => {
|
||||
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
|
||||
return card?.kind === 'journeyEntries'
|
||||
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
||||
: null,
|
||||
: null;
|
||||
},
|
||||
},
|
||||
journey: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journeyHeader.open',
|
||||
open: (applied, card) => {
|
||||
const journeyId = applied.ids[0] ?? (card?.kind === 'journeyHeader' ? card.journeyId : null);
|
||||
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
|
||||
const journeyId = applied.ids[0] ?? fromCard;
|
||||
return journeyId ? journalTarget(journeyId, null, false) : null;
|
||||
},
|
||||
},
|
||||
@@ -101,5 +119,8 @@ export const JOURNEY_LOOK = {
|
||||
journeyHeader: {
|
||||
entity: () => 'journey',
|
||||
},
|
||||
journeyPhotos: {
|
||||
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
|
||||
},
|
||||
},
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader'>;
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CardView } from '../cardTypes';
|
||||
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
||||
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
||||
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
|
||||
import { JourneyPhotosBody } from './JourneyPhotosSlots';
|
||||
|
||||
/**
|
||||
* How the kinds of the journeys draw in the chat: the entries a read found
|
||||
@@ -24,4 +25,7 @@ export const JOURNEY_VIEWS = {
|
||||
Facts: JourneyHeaderFacts,
|
||||
Body: JourneyHeaderBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader']: CardView<K> };
|
||||
journeyPhotos: {
|
||||
Body: JourneyPhotosBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface JourneyOffer {
|
||||
/** The controllers of the journal: the journey and its entries, its map and tracks, its recorded days and imports. */
|
||||
const JOURNAL_CONTROLLERS: ReadonlySet<string> = new Set([
|
||||
'JourneyMcp',
|
||||
'JourneyPhotoLinksMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyRecordingsMcp',
|
||||
|
||||
@@ -227,6 +227,7 @@ const TRIP_OR_JOURNEY_ARGS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
const JOURNEY_WRITE_CONTROLLERS: ReadonlySet<string> = new Set([
|
||||
'JourneyMcp',
|
||||
'JourneyPhotoLinksMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyRecordingsMcp',
|
||||
@@ -334,6 +335,7 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'location history and the recorded days.',
|
||||
controllers: [
|
||||
'JourneyMcp',
|
||||
'JourneyPhotoLinksMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyBookMcp',
|
||||
|
||||
@@ -110,7 +110,7 @@ How you work:
|
||||
- A new entry needs its day (entry_date). When it is about a place, first find the place with search_place by its name and town, then pass its name as location_name and its coordinates as location_lat and location_lng: an entry without coordinates never appears on the journey's map.
|
||||
- A new entry stays a draft (is_draft true) unless the user asks to publish it.
|
||||
- Write titles, stories, moods, weather, tags and pros and cons from what the user told you. Make texts up only when the user asks you to, and then say in one sentence that you did.
|
||||
- An entry's photos and their ids come from list_journey_entries or get_journey_entry; photos from the user's photo library are found with search_provider_photos or the album tools and added with add_journey_provider_photos.
|
||||
- An entry's photos and their ids come from list_journey_entries or get_journey_entry, the whole gallery's from get_journey. Put a gallery photo on an entry with link_journey_photo, take one off with unlink_journey_photo (it stays in the gallery), move photos between entries with move_journey_photos, order an entry's photos with reorder_journey_entry_photos (the first is its cover) and caption a photo with set_journey_photo_caption. Photos from the user's photo library are found with search_provider_photos or the album tools and added with add_journey_provider_photos.
|
||||
- Only the owner changes the journal itself (its name, subtitle, status and display settings) and which trips it holds. Deleting the journal, sharing it and who may see it are not done here: say so in one sentence and point to the journal's settings.
|
||||
- The photo book of the journey is laid out in its Studio, which has an assistant of its own; its tools are not offered here.
|
||||
- If the journal card says the user may not change the journal, propose no changes; explain what they could ask the journal's owner for instead.
|
||||
|
||||
@@ -31,6 +31,20 @@ import {
|
||||
type JournalOrderLoaded,
|
||||
type StayLoaded,
|
||||
} from './journey-order';
|
||||
import {
|
||||
describeJournalPhotos,
|
||||
journalPhotosApplied,
|
||||
journalPhotosCard,
|
||||
loadJournalPhotos,
|
||||
type JournalPhotosLoaded,
|
||||
type JournalPhotosTool,
|
||||
} from './journey-photos';
|
||||
import {
|
||||
MOVE_PHOTOS_BACK,
|
||||
RELINK_UNLINKED_PHOTO,
|
||||
RESTORE_PHOTO_CAPTION,
|
||||
UNLINK_LINKED_PHOTO,
|
||||
} from './journey-photos-undo';
|
||||
import { DELETE_CREATED_ENTRY, RESTORE_CHANGED_ENTRY, RESTORE_ENTRY_ORDER } from './journey-undo';
|
||||
import type { AssistantChangeVerbKey, JourneyEntriesCard } from '@trek/shared';
|
||||
|
||||
@@ -124,6 +138,29 @@ const stayCards: ToolCards<StayLoaded> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The entry a photo change touched, else the journal (a caption). */
|
||||
function photoIds(args: ToolArgs, result: unknown): number[] {
|
||||
const id = argId(args, 'toEntryId') ?? argId(args, 'entryId') ?? argId(recordOf(result), 'toEntryId');
|
||||
return id === null ? [] : [id];
|
||||
}
|
||||
|
||||
/** A change of a journal's photos: its verb, its words, its lookups, its card while held and once applied. */
|
||||
function journalPhotos(
|
||||
tool: JournalPhotosTool,
|
||||
verbKey: AssistantChangeVerbKey,
|
||||
undo?: NonNullable<ToolCards['undo']>,
|
||||
): ToolCards<JournalPhotosLoaded> {
|
||||
return {
|
||||
write: { verbKey, entity: tool === 'set_journey_photo_caption' ? 'journey' : 'journey_entry' },
|
||||
load: (args, read) => loadJournalPhotos(args, read),
|
||||
describe: (ctx, args, loaded) => describeJournalPhotos(tool, ctx, args, loaded),
|
||||
change: (_ctx, args, loaded) => journalPhotosCard(tool, args, loaded),
|
||||
applied: ({ result, preview }) => journalPhotosApplied(tool, result, preview),
|
||||
appliedIds: tool === 'set_journey_photo_caption' ? journalIds : photoIds,
|
||||
...(undo ? { undo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const JOURNEY_CARDS: DomainCards = {
|
||||
id: 'journey',
|
||||
tools: {
|
||||
@@ -156,6 +193,12 @@ export const JOURNEY_CARDS: DomainCards = {
|
||||
...journalWrite('delete_journey_entry'),
|
||||
},
|
||||
reorder_journey_entries: reorderCards,
|
||||
link_journey_photo: journalPhotos('link_journey_photo', 'linkJournalPhoto', UNLINK_LINKED_PHOTO),
|
||||
unlink_journey_photo: journalPhotos('unlink_journey_photo', 'unlinkJournalPhoto', RELINK_UNLINKED_PHOTO),
|
||||
move_journey_photos: journalPhotos('move_journey_photos', 'moveJournalPhotos', MOVE_PHOTOS_BACK),
|
||||
reorder_journey_entry_photos: journalPhotos('reorder_journey_entry_photos', 'orderJournalPhotos'),
|
||||
set_journey_photo_caption: journalPhotos('set_journey_photo_caption', 'captionJournalPhoto', RESTORE_PHOTO_CAPTION),
|
||||
add_journey_provider_photos: journalPhotos('add_journey_provider_photos', 'addJournalPhotos'),
|
||||
accept_dawarich_suggestion_as_journal_entry: stayCards,
|
||||
create_journey: journalHeader('create_journey', 'createJournal', DELETE_CREATED_JOURNEY),
|
||||
update_journey: journalHeader('update_journey', 'updateJournalSettings', RESTORE_JOURNEY_SETTINGS),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { argId, idIn, readKey, recordOf } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
|
||||
/**
|
||||
* The ways back from a change of a journal's photos, each checked against
|
||||
* the entry as `get_journey_entry` reads it now:
|
||||
* - a photo put on an entry comes off again while it is still there;
|
||||
* - a photo taken off goes back while it is still off;
|
||||
* - moved photos go back while the target holds them all and, unless they
|
||||
* were kept, the source none of them;
|
||||
* - a caption is written back while the photo still carries the new one.
|
||||
*/
|
||||
|
||||
type Undo = NonNullable<ToolCards['undo']>;
|
||||
|
||||
function entryRead(entryId: number | null) {
|
||||
return { tool: 'get_journey_entry', args: { entryId } };
|
||||
}
|
||||
|
||||
/** The journey photo ids an entry holds now, as the snapshot read it; null when the read failed. */
|
||||
function heldBy(snapshot: UndoSnapshot, entryId: number | null): Set<number> | null {
|
||||
const read = entryRead(entryId);
|
||||
const entry = recordOf(recordOf(snapshot.reads.get(readKey(read.tool, read.args))).entry);
|
||||
if (entry.id !== entryId) return null;
|
||||
return new Set(rowsOf(entry.photos).map((photo) => photo.id as number));
|
||||
}
|
||||
|
||||
function linkArgs(args: ToolArgs): { entryId: number; journey_photo_id: number } | null {
|
||||
const entryId = argId(args, 'entryId');
|
||||
const photoId = argId(args, 'journey_photo_id');
|
||||
return entryId === null || photoId === null ? null : { entryId, journey_photo_id: photoId };
|
||||
}
|
||||
|
||||
export const UNLINK_LINKED_PHOTO: Undo = {
|
||||
tool: 'unlink_journey_photo',
|
||||
args: (args, _result, preview) => (preview?.undoable === false ? null : linkArgs(args)),
|
||||
reads: (args) => [entryRead(argId(args, 'entryId'))],
|
||||
unchanged: (_result, snapshot, call) => {
|
||||
const link = linkArgs(call?.args ?? {});
|
||||
return !!link && (heldBy(snapshot, link.entryId)?.has(link.journey_photo_id) ?? false);
|
||||
},
|
||||
};
|
||||
|
||||
export const RELINK_UNLINKED_PHOTO: Undo = {
|
||||
tool: 'link_journey_photo',
|
||||
args: (args, result) => (recordOf(result).success === true ? linkArgs(args) : null),
|
||||
reads: (args) => [entryRead(argId(args, 'entryId'))],
|
||||
unchanged: (_result, snapshot, call) => {
|
||||
const link = linkArgs(call?.args ?? {});
|
||||
const held = link ? heldBy(snapshot, link.entryId) : null;
|
||||
return !!link && !!held && !held.has(link.journey_photo_id);
|
||||
},
|
||||
};
|
||||
|
||||
/** What a move answered it moved, between which entries. */
|
||||
function movedBy(result: unknown) {
|
||||
const answer = recordOf(result);
|
||||
const moved = Array.isArray(answer.moved) ? answer.moved.filter((id): id is number => Number.isInteger(id)) : [];
|
||||
return { moved, from: argId(answer, 'fromEntryId'), to: argId(answer, 'toEntryId'), kept: answer.kept === true };
|
||||
}
|
||||
|
||||
export const MOVE_PHOTOS_BACK: Undo = {
|
||||
tool: 'move_journey_photos',
|
||||
args: (_args, result, preview) => {
|
||||
const { moved, from, to } = movedBy(result);
|
||||
if (preview?.undoable === false || moved.length === 0 || from === null || to === null) return null;
|
||||
// Back to the source and off the target; a kept photo is on the source already, so only the target loses it.
|
||||
return { fromEntryId: to, toEntryId: from, photoIds: moved };
|
||||
},
|
||||
reads: (_args, result) => {
|
||||
const { from, to } = movedBy(result);
|
||||
return [entryRead(to), entryRead(from)];
|
||||
},
|
||||
unchanged: (result, snapshot) => {
|
||||
const { moved, from, to, kept } = movedBy(result);
|
||||
const onTarget = heldBy(snapshot, to);
|
||||
const onSource = heldBy(snapshot, from);
|
||||
if (!onTarget || !onSource || moved.length === 0) return false;
|
||||
return moved.every((id) => onTarget.has(id) && (kept || !onSource.has(id)));
|
||||
},
|
||||
};
|
||||
|
||||
/** The caption a caption change wrote, and the one it replaced. */
|
||||
function captionsOf(result: unknown) {
|
||||
const answer = recordOf(result);
|
||||
return { photo: recordOf(answer.photo), previous: recordOf(answer.previous) };
|
||||
}
|
||||
|
||||
export const RESTORE_PHOTO_CAPTION: Undo = {
|
||||
tool: 'set_journey_photo_caption',
|
||||
args: (args, result) => {
|
||||
const { photo, previous } = captionsOf(result);
|
||||
const photoId = idIn(result, 'photo');
|
||||
if (photoId === null || !('caption' in previous)) return null;
|
||||
const journeyId = argId(args, 'journeyId') ?? argId(photo, 'journey_id');
|
||||
return {
|
||||
...(journeyId === null ? {} : { journeyId }),
|
||||
journey_photo_id: photoId,
|
||||
caption: previous.caption ?? null,
|
||||
};
|
||||
},
|
||||
reads: (args) => [
|
||||
{ tool: 'get_journey', args: argId(args, 'journeyId') === null ? {} : { journeyId: args.journeyId } },
|
||||
],
|
||||
unchanged: (result, snapshot, call) => {
|
||||
const args = call?.args ?? {};
|
||||
const read = { tool: 'get_journey', args: argId(args, 'journeyId') === null ? {} : { journeyId: args.journeyId } };
|
||||
const journey = recordOf(recordOf(snapshot.reads.get(readKey(read.tool, read.args))).journey);
|
||||
const { photo } = captionsOf(result);
|
||||
const current = rowsOf(journey.gallery).find((row) => row.id === photo.id);
|
||||
return !!current && (current.caption ?? null) === (photo.caption ?? null);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, recordOf, type Row } from '../card-values';
|
||||
import { formatDate, Rows, text } from '../preview-format';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import {
|
||||
JOURNEY_PHOTOS_LIMITS,
|
||||
type AssistantChangePreview,
|
||||
type JourneyPhotoLook,
|
||||
type JourneyPhotosCard,
|
||||
type JourneyPhotosEntry,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The change cards of a journal's photos: one put on an entry or taken off
|
||||
* it, photos moved between entries, an entry's photos in a new order, a
|
||||
* caption, photos added from a library. Each shows the photos themselves (by
|
||||
* their TREK photo id, for the thumbnail) and the entries they leave and
|
||||
* reach, read with the user's rights from `get_journey`.
|
||||
*/
|
||||
|
||||
/** What a held photo change looked up: the journal as `get_journey` answers it. */
|
||||
export interface JournalPhotosLoaded {
|
||||
journey: Row;
|
||||
}
|
||||
|
||||
/** The tools these cards are for, each with what its card shows. */
|
||||
const MODES = {
|
||||
link_journey_photo: 'link',
|
||||
unlink_journey_photo: 'unlink',
|
||||
move_journey_photos: 'move',
|
||||
reorder_journey_entry_photos: 'order',
|
||||
set_journey_photo_caption: 'caption',
|
||||
add_journey_provider_photos: 'add',
|
||||
} as const satisfies Record<string, JourneyPhotosCard['mode']>;
|
||||
export type JournalPhotosTool = keyof typeof MODES;
|
||||
|
||||
/** The entry a call is about: the one it changes, or the one the photos leave. */
|
||||
function entryArg(args: ToolArgs): number | null {
|
||||
return argId(args, 'entryId') ?? argId(args, 'fromEntryId');
|
||||
}
|
||||
|
||||
/**
|
||||
* At most two reads: the journal the call names, or, where it names only an
|
||||
* entry, the entry first for its journal. In a journey's own conversation the
|
||||
* session pins the journal, so the entry read and the journal read both reach it.
|
||||
*/
|
||||
export async function loadJournalPhotos(args: ToolArgs, read: CardReader): Promise<JournalPhotosLoaded | null> {
|
||||
let journeyId = argId(args, 'journeyId');
|
||||
const entryId = entryArg(args);
|
||||
if (journeyId === null && entryId !== null) {
|
||||
journeyId = argId(recordOf(recordOf(await read('get_journey_entry', { entryId })).journey), 'id');
|
||||
}
|
||||
const journey = recordOf(recordOf(await read('get_journey', journeyId === null ? {} : { journeyId })).journey);
|
||||
return argId(journey, 'id') === null ? null : { journey };
|
||||
}
|
||||
|
||||
function entriesOf(loaded: JournalPhotosLoaded | null): Row[] {
|
||||
return rowsOf(loaded?.journey.entries);
|
||||
}
|
||||
|
||||
function entryOf(loaded: JournalPhotosLoaded | null, entryId: number | null): Row | undefined {
|
||||
return entryId === null ? undefined : entriesOf(loaded).find((entry) => entry.id === entryId);
|
||||
}
|
||||
|
||||
/** The photos of an entry, by their journey photo id, in its order. */
|
||||
function entryPhotos(entry: Row | undefined): Row[] {
|
||||
return rowsOf(entry?.photos);
|
||||
}
|
||||
|
||||
function galleryPhoto(loaded: JournalPhotosLoaded | null, journeyPhotoId: number | null): Row | undefined {
|
||||
return rowsOf(loaded?.journey.gallery).find((photo) => photo.id === journeyPhotoId);
|
||||
}
|
||||
|
||||
function idsOf(value: unknown): number[] {
|
||||
return Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id) && id > 0) : [];
|
||||
}
|
||||
|
||||
/** A photo as a card shows it; null for a row without a TREK photo. */
|
||||
function photoLook(row: Row | undefined): JourneyPhotoLook | null {
|
||||
const photoId = argId(row ?? {}, 'photo_id');
|
||||
if (!row || photoId === null) return null;
|
||||
return {
|
||||
photoId,
|
||||
video: row.media_type === 'video',
|
||||
caption: clipped(row.caption, JOURNEY_PHOTOS_LIMITS.caption),
|
||||
};
|
||||
}
|
||||
|
||||
function entryRef(entry: Row | undefined, entryId: number | null): JourneyPhotosEntry | null {
|
||||
if (entryId === null) return null;
|
||||
const date =
|
||||
typeof entry?.entry_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entry.entry_date) ? entry.entry_date : null;
|
||||
const title =
|
||||
clipped(entry?.title, JOURNEY_PHOTOS_LIMITS.title) ?? clipped(entry?.location_name, JOURNEY_PHOTOS_LIMITS.title);
|
||||
return { entryId, title, date };
|
||||
}
|
||||
|
||||
/** The photos a call concerns, as the card draws them, in the order the change leaves them. */
|
||||
function photosOf(tool: JournalPhotosTool, args: ToolArgs, loaded: JournalPhotosLoaded | null): Row[] {
|
||||
const source = entryPhotos(entryOf(loaded, entryArg(args)));
|
||||
const byId = (rows: Row[], ids: number[]) => ids.flatMap((id) => rows.filter((row) => row.id === id));
|
||||
if (tool === 'link_journey_photo' || tool === 'set_journey_photo_caption') {
|
||||
const photo = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
|
||||
return photo ? [photo] : [];
|
||||
}
|
||||
if (tool === 'unlink_journey_photo') return byId(source, [argId(args, 'journey_photo_id') ?? 0]);
|
||||
if (tool === 'move_journey_photos') return byId(source, idsOf(args.photoIds));
|
||||
if (tool === 'reorder_journey_entry_photos') return byId(source, idsOf(args.orderedIds));
|
||||
return [];
|
||||
}
|
||||
|
||||
/** How many photos the call names: its ids, or for a library add the assets it brings. */
|
||||
function namedCount(tool: JournalPhotosTool, args: ToolArgs): number {
|
||||
if (tool === 'move_journey_photos') return idsOf(args.photoIds).length;
|
||||
if (tool === 'reorder_journey_entry_photos') return idsOf(args.orderedIds).length;
|
||||
if (tool === 'add_journey_provider_photos') return Array.isArray(args.asset_ids) ? args.asset_ids.length : 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** The card under a held photo change; null without a journal to name. */
|
||||
export function journalPhotosCard(
|
||||
tool: JournalPhotosTool,
|
||||
args: ToolArgs,
|
||||
loaded: JournalPhotosLoaded | null,
|
||||
): JourneyPhotosCard | null {
|
||||
const journeyId = argId(loaded?.journey ?? {}, 'id');
|
||||
if (journeyId === null) return null;
|
||||
const mode = MODES[tool];
|
||||
const looks = photosOf(tool, args, loaded).flatMap((row) => {
|
||||
const look = photoLook(row);
|
||||
return look ? [look] : [];
|
||||
});
|
||||
const shown = looks.slice(0, JOURNEY_PHOTOS_LIMITS.photos);
|
||||
const entryId = entryArg(args);
|
||||
const leaves = mode === 'move' || mode === 'unlink';
|
||||
const toId = mode === 'move' ? argId(args, 'toEntryId') : entryId;
|
||||
const captioned = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
|
||||
return {
|
||||
kind: 'journeyPhotos',
|
||||
journeyId,
|
||||
mode,
|
||||
from: leaves ? entryRef(entryOf(loaded, entryId), entryId) : null,
|
||||
to: mode === 'unlink' || mode === 'caption' ? null : entryRef(entryOf(loaded, toId), toId),
|
||||
photos: shown,
|
||||
more: Math.max(0, namedCount(tool, args) - shown.length),
|
||||
kept: mode === 'move' && args.keep_in_source === true,
|
||||
caption:
|
||||
mode === 'caption'
|
||||
? {
|
||||
before: clipped(captioned?.caption, JOURNEY_PHOTOS_LIMITS.caption),
|
||||
after: clipped(args.caption, JOURNEY_PHOTOS_LIMITS.caption),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** What an entry is called on a row: its title, else its place, else its day. */
|
||||
function entryName(ctx: PreviewContext, entry: Row | undefined): string {
|
||||
const day = typeof entry?.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
|
||||
return text(entry?.title) || text(entry?.location_name) || day;
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of a photo change: the entry it is about, the number of photos,
|
||||
* a caption old to new. A link or a move is undoable only where none of its
|
||||
* photos was on the entry they reach already: the way back would take off a
|
||||
* photo the change never put there.
|
||||
*/
|
||||
export function describeJournalPhotos(
|
||||
tool: JournalPhotosTool,
|
||||
ctx: PreviewContext,
|
||||
args: ToolArgs,
|
||||
loaded: JournalPhotosLoaded | null,
|
||||
): Described {
|
||||
const rows = new Rows();
|
||||
const entry = entryOf(loaded, entryArg(args));
|
||||
const count = namedCount(tool, args);
|
||||
const base = { dayId: null, placeIds: [], domain: 'journey' };
|
||||
if (tool === 'set_journey_photo_caption') {
|
||||
const photo = galleryPhoto(loaded, argId(args, 'journey_photo_id'));
|
||||
rows.change('text', text(photo?.caption), text(args.caption));
|
||||
return { subject: text(loaded?.journey.title), rows, ...base };
|
||||
}
|
||||
rows.rows.push({ op: 'add', labelKey: 'field', field: 'photos', before: null, after: String(count) });
|
||||
if (tool === 'move_journey_photos') {
|
||||
const target = entryOf(loaded, argId(args, 'toEntryId'));
|
||||
const from = entryName(ctx, entry);
|
||||
rows.rows.push({
|
||||
op: 'move',
|
||||
labelKey: 'field',
|
||||
field: 'entry',
|
||||
before: from || null,
|
||||
after: entryName(ctx, target),
|
||||
});
|
||||
const already = new Set(entryPhotos(target).map((photo) => photo.id));
|
||||
const undoable = !idsOf(args.photoIds).some((id) => already.has(id));
|
||||
return { subject: entryName(ctx, target) || entryName(ctx, entry), rows, ...base, undoable };
|
||||
}
|
||||
if (tool === 'link_journey_photo') {
|
||||
const already = entryPhotos(entry).some((photo) => photo.id === argId(args, 'journey_photo_id'));
|
||||
return { subject: entryName(ctx, entry), rows, ...base, undoable: !already };
|
||||
}
|
||||
return { subject: entryName(ctx, entry) || text(loaded?.journey.title), rows, ...base };
|
||||
}
|
||||
|
||||
/**
|
||||
* The card of an applied photo change: photos added from a library as they
|
||||
* landed (their TREK photo ids are known only now); every other change keeps
|
||||
* the card it was held with.
|
||||
*/
|
||||
export function journalPhotosApplied(
|
||||
tool: JournalPhotosTool,
|
||||
result: unknown,
|
||||
preview: AssistantChangePreview | null,
|
||||
): JourneyPhotosCard | null {
|
||||
const held = preview?.card?.kind === 'journeyPhotos' ? preview.card : null;
|
||||
if (tool !== 'add_journey_provider_photos' || !held) return null;
|
||||
const looks = rowsOf(recordOf(result).photos).flatMap((row) => {
|
||||
const look = photoLook(row);
|
||||
return look ? [look] : [];
|
||||
});
|
||||
const photos = looks.slice(0, JOURNEY_PHOTOS_LIMITS.photos);
|
||||
return { ...held, photos, more: Math.max(0, looks.length - photos.length) };
|
||||
}
|
||||
@@ -95,6 +95,8 @@ describe('a home tool session', () => {
|
||||
'delete_journey_entry',
|
||||
'reorder_journey_entry_photos',
|
||||
'accept_dawarich_suggestion_as_journal_entry',
|
||||
'link_journey_photo',
|
||||
'move_journey_photos',
|
||||
];
|
||||
for (const name of journeyWrites) {
|
||||
expect(names, name).not.toContain(name);
|
||||
|
||||
@@ -160,6 +160,13 @@ describe('a journal tool session, on the journey page', () => {
|
||||
expect.arrayContaining([...ENTRY_WRITES, 'update_journey', 'remove_journey_trip', 'add_journey_trip']),
|
||||
);
|
||||
expect(names).toEqual(expect.arrayContaining(['get_journey', 'get_journey_entry', 'search_place']));
|
||||
const photoTools = [
|
||||
'link_journey_photo',
|
||||
'unlink_journey_photo',
|
||||
'move_journey_photos',
|
||||
'set_journey_photo_caption',
|
||||
];
|
||||
expect(names).toEqual(expect.arrayContaining(photoTools));
|
||||
const left = ['delete_journey', 'list_journeys', 'create_journey', 'add_journey_contributor', 'get_book'];
|
||||
for (const name of [...left, ...BOOK_WRITES, 'get_journey_share_link', 'use_addon_tools']) {
|
||||
expect(names, name).not.toContain(name);
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* ASTCARD-JPH-001 to ASTCARD-JPH-008
|
||||
*
|
||||
* A change of a journal's photos in the assistant chat: what it reads first,
|
||||
* its row and its card (the photos and the entries they leave and reach), a
|
||||
* library add's receipt with the photos as they landed, and the ways back
|
||||
* with their checks. Every card is checked against the shared union through
|
||||
* the registry's guard.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import type { CardReader, PreviewContext, UndoSnapshot } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { JOURNEY_CARDS } from '../../../../../../src/nest/assistant/cards/journey';
|
||||
import { loadJournalPhotos } from '../../../../../../src/nest/assistant/cards/journey/journey-photos';
|
||||
import {
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { journeyPhotosCardSchema, type AssistantChangePreview, type JourneyPhotosCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
const ctx: PreviewContext = {
|
||||
card: buildTripCard(1, 7, { trip: { title: 'Berlin' } }, null, null),
|
||||
categories: new Map(),
|
||||
locale: 'en',
|
||||
today: '2026-10-24',
|
||||
airport: () => null,
|
||||
};
|
||||
|
||||
const JOURNEY = {
|
||||
id: 3,
|
||||
title: 'Iceland',
|
||||
gallery: [
|
||||
{ id: 11, photo_id: 101, caption: 'Old', media_type: 'image' },
|
||||
{ id: 12, photo_id: 102, caption: null, media_type: 'video' },
|
||||
],
|
||||
entries: [
|
||||
{ id: 1, title: 'Vik', entry_date: '2026-07-01', photos: [{ id: 11, photo_id: 101, caption: 'Old' }] },
|
||||
{ id: 2, title: 'Reynisfjara', entry_date: '2026-07-02', photos: [] },
|
||||
],
|
||||
};
|
||||
const LOADED = { journey: JOURNEY };
|
||||
|
||||
function cardOf(value: unknown): JourneyPhotosCard {
|
||||
return journeyPhotosCardSchema.parse(value);
|
||||
}
|
||||
|
||||
function undoOf(tool: string) {
|
||||
const undo = JOURNEY_CARDS.tools[tool]?.undo;
|
||||
if (!undo) throw new Error(`no undo for ${tool}`);
|
||||
return undo;
|
||||
}
|
||||
|
||||
function entriesRead(entries: Array<{ id: number; photos: number[] }>): UndoSnapshot {
|
||||
return {
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map(
|
||||
entries.map((entry) => [
|
||||
readKey('get_journey_entry', { entryId: entry.id }),
|
||||
{ entry: { id: entry.id, photos: entry.photos.map((id) => ({ id })) } },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('the cards of a photo change', () => {
|
||||
it('ASTCARD-JPH-001: reads the entry for its journal where the call names none, then the journal', async () => {
|
||||
const asked: string[] = [];
|
||||
const read: CardReader = (tool, args = {}) => {
|
||||
asked.push(readKey(tool, args));
|
||||
if (tool === 'get_journey_entry') return Promise.resolve({ journey: { id: 3 } });
|
||||
return Promise.resolve(tool === 'get_journey' ? { journey: JOURNEY } : null);
|
||||
};
|
||||
expect(await loadJournalPhotos({ entryId: 1, journey_photo_id: 12 }, read)).toEqual(LOADED);
|
||||
expect(asked).toEqual([readKey('get_journey_entry', { entryId: 1 }), readKey('get_journey', { journeyId: 3 })]);
|
||||
expect(await loadJournalPhotos({ journeyId: 9 }, () => Promise.resolve(null))).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-002: a link shows the gallery photo on the entry it reaches, undoable unless it is there already', () => {
|
||||
const preview = describeToolCall('link_journey_photo', 'c1', { entryId: 2, journey_photo_id: 12 }, ctx, LOADED);
|
||||
expect(preview).toMatchObject({ verbKey: 'linkJournalPhoto', subject: 'Reynisfjara', undoable: true });
|
||||
expect(cardOf(preview.card)).toMatchObject({
|
||||
mode: 'link',
|
||||
from: null,
|
||||
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||
photos: [{ photoId: 102, video: true, caption: null }],
|
||||
});
|
||||
const already = describeToolCall('link_journey_photo', 'c2', { entryId: 1, journey_photo_id: 11 }, ctx, LOADED);
|
||||
expect(already.undoable).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-003: a move names both entries and shows the photos that go', () => {
|
||||
const args = { fromEntryId: 1, toEntryId: 2, photoIds: [11], keep_in_source: true };
|
||||
const preview = describeToolCall('move_journey_photos', 'c1', args, ctx, LOADED);
|
||||
expect(preview).toMatchObject({ verbKey: 'moveJournalPhotos', subject: 'Reynisfjara', undoable: true });
|
||||
expect(preview.rows).toContainEqual({
|
||||
op: 'move',
|
||||
labelKey: 'field',
|
||||
field: 'entry',
|
||||
before: 'Vik',
|
||||
after: 'Reynisfjara',
|
||||
});
|
||||
expect(cardOf(preview.card)).toMatchObject({
|
||||
mode: 'move',
|
||||
from: { entryId: 1, title: 'Vik' },
|
||||
to: { entryId: 2 },
|
||||
kept: true,
|
||||
photos: [{ photoId: 101 }],
|
||||
});
|
||||
const back = describeToolCall(
|
||||
'move_journey_photos',
|
||||
'c2',
|
||||
{ fromEntryId: 2, toEntryId: 1, photoIds: [11] },
|
||||
ctx,
|
||||
LOADED,
|
||||
);
|
||||
expect(back.undoable).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-004: an unlink, a new order and a caption draw their own cards', () => {
|
||||
const unlink = cardOf(
|
||||
describeToolCall('unlink_journey_photo', 'c1', { entryId: 1, journey_photo_id: 11 }, ctx, LOADED).card,
|
||||
);
|
||||
expect(unlink).toMatchObject({ mode: 'unlink', from: { entryId: 1 }, to: null, photos: [{ photoId: 101 }] });
|
||||
const order = describeToolCall('reorder_journey_entry_photos', 'c2', { entryId: 1, orderedIds: [11] }, ctx, LOADED);
|
||||
expect(order).toMatchObject({ verbKey: 'orderJournalPhotos', undoable: false });
|
||||
expect(cardOf(order.card)).toMatchObject({ mode: 'order', to: { entryId: 1 } });
|
||||
const caption = describeToolCall(
|
||||
'set_journey_photo_caption',
|
||||
'c3',
|
||||
{ journey_photo_id: 11, caption: 'New' },
|
||||
ctx,
|
||||
LOADED,
|
||||
);
|
||||
expect(caption).toMatchObject({ verbKey: 'captionJournalPhoto', subject: 'Iceland', undoable: true });
|
||||
expect(caption.rows).toEqual([{ op: 'change', labelKey: 'text', before: 'Old', after: 'New' }]);
|
||||
expect(cardOf(caption.card).caption).toEqual({ before: 'Old', after: 'New' });
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-005: a library add counts its assets while held and shows the photos once they landed', () => {
|
||||
const args = { journeyId: 3, entryId: 2, provider: 'immich', asset_ids: ['a', 'b'] };
|
||||
const held = describeToolCall('add_journey_provider_photos', 'c1', args, ctx, LOADED);
|
||||
expect(cardOf(held.card)).toMatchObject({ mode: 'add', photos: [], more: 2, to: { entryId: 2 } });
|
||||
const result = { photos: [{ photo_id: 201 }, { photo_id: 202, media_type: 'video' }], added: 2 };
|
||||
const preview = held as unknown as AssistantChangePreview;
|
||||
expect(cardOf(appliedCardOf('add_journey_provider_photos', { args, result, preview }))).toMatchObject({
|
||||
photos: [
|
||||
{ photoId: 201, video: false },
|
||||
{ photoId: 202, video: true },
|
||||
],
|
||||
more: 0,
|
||||
});
|
||||
expect(ASSISTANT_UNDO_MAP.add_journey_provider_photos).toBeUndefined();
|
||||
expect(ASSISTANT_WRITE_TOOLS.add_journey_provider_photos).toEqual({
|
||||
verbKey: 'addJournalPhotos',
|
||||
entity: 'journey_entry',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ways back from a photo change', () => {
|
||||
it('ASTCARD-JPH-006: a linked photo comes off while it is still on the entry, an unlinked one goes back while off', () => {
|
||||
const args = { entryId: 2, journey_photo_id: 12 };
|
||||
const unlink = undoOf('link_journey_photo');
|
||||
expect(unlink.args(args, { photo: { id: 12 } }, null)).toEqual(args);
|
||||
expect(unlink.args(args, {}, { undoable: false } as unknown as AssistantChangePreview)).toBeNull();
|
||||
const call = { args, preview: null };
|
||||
expect(unlink.unchanged({}, entriesRead([{ id: 2, photos: [12] }]), call)).toBe(true);
|
||||
expect(unlink.unchanged({}, entriesRead([{ id: 2, photos: [] }]), call)).toBe(false);
|
||||
const relink = undoOf('unlink_journey_photo');
|
||||
expect(relink.args(args, { success: true }, null)).toEqual(args);
|
||||
expect(relink.args(args, { success: false }, null)).toBeNull();
|
||||
expect(relink.unchanged({}, entriesRead([{ id: 2, photos: [] }]), call)).toBe(true);
|
||||
expect(relink.unchanged({}, entriesRead([{ id: 2, photos: [12] }]), call)).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-007: moved photos go back while the target holds them all and the source none', () => {
|
||||
const undo = undoOf('move_journey_photos');
|
||||
const result = { moved: [11], fromEntryId: 1, toEntryId: 2, kept: false };
|
||||
expect(undo.args({}, result, null)).toEqual({ fromEntryId: 2, toEntryId: 1, photoIds: [11] });
|
||||
expect(undo.args({}, { ...result, moved: [] }, null)).toBeNull();
|
||||
expect(
|
||||
undo.unchanged(
|
||||
result,
|
||||
entriesRead([
|
||||
{ id: 2, photos: [11] },
|
||||
{ id: 1, photos: [] },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
undo.unchanged(
|
||||
result,
|
||||
entriesRead([
|
||||
{ id: 2, photos: [] },
|
||||
{ id: 1, photos: [] },
|
||||
]),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
undo.unchanged(
|
||||
result,
|
||||
entriesRead([
|
||||
{ id: 2, photos: [11] },
|
||||
{ id: 1, photos: [11] },
|
||||
]),
|
||||
),
|
||||
).toBe(false);
|
||||
// Kept on the source too, the photos may stay there.
|
||||
const kept = { ...result, kept: true };
|
||||
expect(
|
||||
undo.unchanged(
|
||||
kept,
|
||||
entriesRead([
|
||||
{ id: 2, photos: [11] },
|
||||
{ id: 1, photos: [11] },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ASTCARD-JPH-008: a caption is written back while the photo still carries the new one', () => {
|
||||
const undo = undoOf('set_journey_photo_caption');
|
||||
const result = { photo: { id: 11, caption: 'New' }, previous: { caption: 'Old' } };
|
||||
expect(undo.args({ journeyId: 3 }, result, null)).toEqual({ journeyId: 3, journey_photo_id: 11, caption: 'Old' });
|
||||
expect(undo.args({}, { photo: { id: 11 }, previous: {} }, null)).toBeNull();
|
||||
const snapshot = (caption: string | null): UndoSnapshot => ({
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('get_journey', { journeyId: 3 }), { journey: { gallery: [{ id: 11, caption }] } }]]),
|
||||
});
|
||||
const call = { args: { journeyId: 3 }, preview: null };
|
||||
expect(undo.unchanged(result, snapshot('New'), call)).toBe(true);
|
||||
expect(undo.unchanged(result, snapshot('Other'), call)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardDateSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Photos of a journal as the assistant chat shows a change of them: one put
|
||||
* on an entry or taken off it, photos moved from one entry to another, an
|
||||
* entry's photos in a new order (the first is its cover), a caption changed,
|
||||
* photos added from a library. A photo travels as its TREK photo id, which
|
||||
* the client turns into TREK's own thumbnail address.
|
||||
*/
|
||||
|
||||
/** How much a card carries; the server clips to the same numbers. */
|
||||
export const JOURNEY_PHOTOS_LIMITS = { photos: 8, caption: 160, title: 100 } as const;
|
||||
|
||||
export const JOURNEY_PHOTOS_MODES = ['link', 'unlink', 'move', 'order', 'caption', 'add'] as const;
|
||||
|
||||
/** An entry the photos leave or reach. */
|
||||
export const journeyPhotosEntrySchema = z.object({
|
||||
entryId: idSchema.nullable(),
|
||||
title: cardText(JOURNEY_PHOTOS_LIMITS.title).nullable(),
|
||||
date: cardDateSchema.nullable(),
|
||||
});
|
||||
export type JourneyPhotosEntry = z.infer<typeof journeyPhotosEntrySchema>;
|
||||
|
||||
export const journeyPhotoLookSchema = z.object({
|
||||
/** The TREK photo, for its thumbnail. */
|
||||
photoId: idSchema,
|
||||
video: z.boolean(),
|
||||
caption: cardText(JOURNEY_PHOTOS_LIMITS.caption).nullable(),
|
||||
});
|
||||
export type JourneyPhotoLook = z.infer<typeof journeyPhotoLookSchema>;
|
||||
|
||||
export const journeyPhotosCardSchema = z.object({
|
||||
kind: z.literal('journeyPhotos'),
|
||||
journeyId: idSchema,
|
||||
mode: z.enum(JOURNEY_PHOTOS_MODES),
|
||||
/** The entry the photos leave (a move, an unlink). */
|
||||
from: journeyPhotosEntrySchema.nullable(),
|
||||
/** The entry the photos reach or whose photos change (a link, a move, a new order, an add). */
|
||||
to: journeyPhotosEntrySchema.nullable(),
|
||||
/** In the order the change leaves them; for a new order, the first is the entry's cover. */
|
||||
photos: z.array(journeyPhotoLookSchema).max(JOURNEY_PHOTOS_LIMITS.photos),
|
||||
/** Photos the change concerns that the card does not carry. */
|
||||
more: cardCountSchema,
|
||||
/** A move that leaves the photos on the first entry as well. */
|
||||
kept: z.boolean(),
|
||||
/** A caption change, old to new; null on every other change. */
|
||||
caption: z
|
||||
.object({
|
||||
before: cardText(JOURNEY_PHOTOS_LIMITS.caption).nullable(),
|
||||
after: cardText(JOURNEY_PHOTOS_LIMITS.caption).nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type JourneyPhotosCard = z.infer<typeof journeyPhotosCardSchema>;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { journeyEntriesCardSchema } from './journey-entries.card';
|
||||
import { journeyHeaderCardSchema } from './journey-header.card';
|
||||
import { journeyPhotosCardSchema } from './journey-photos.card';
|
||||
|
||||
export * from './journey-entries.card';
|
||||
export * from './journey-header.card';
|
||||
export * from './journey-photos.card';
|
||||
|
||||
/**
|
||||
* What the travel journal brings to the assistant chat: the kinds of card it draws,
|
||||
@@ -11,11 +13,16 @@ export * from './journey-header.card';
|
||||
* spread these into the card union and the shared enums, so a new kind, verb
|
||||
* or entity is added here and nowhere else.
|
||||
*/
|
||||
export const JOURNEY_CARD_SCHEMAS = [journeyEntriesCardSchema, journeyHeaderCardSchema] as const;
|
||||
export const JOURNEY_CARD_SCHEMAS = [
|
||||
journeyEntriesCardSchema,
|
||||
journeyHeaderCardSchema,
|
||||
journeyPhotosCardSchema,
|
||||
] as const;
|
||||
/**
|
||||
* An entry written, changed or deleted, the entries put in a new order; a journal started, renamed, given
|
||||
* another status or other settings, deleted; a trip linked or unlinked; its
|
||||
* suggestions brought back; the user's own view of it changed.
|
||||
* suggestions brought back; the user's own view of it changed; a photo put on
|
||||
* an entry, taken off, moved, put in order, captioned or added.
|
||||
*/
|
||||
export const JOURNEY_VERB_KEYS = [
|
||||
'addJournalEntry',
|
||||
@@ -31,6 +38,12 @@ export const JOURNEY_VERB_KEYS = [
|
||||
'unlinkJournalTrip',
|
||||
'restoreJournalSuggestions',
|
||||
'setJournalPreferences',
|
||||
'linkJournalPhoto',
|
||||
'unlinkJournalPhoto',
|
||||
'moveJournalPhotos',
|
||||
'orderJournalPhotos',
|
||||
'captionJournalPhoto',
|
||||
'addJournalPhotos',
|
||||
] as const;
|
||||
/** An entry of a journal, or the journal itself; the receipt opens it there. */
|
||||
export const JOURNEY_APPLIED_ENTITIES = ['journey_entry', 'journey'] as const;
|
||||
|
||||
@@ -90,5 +90,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons.two': '{count} نقطتان سلبيتان',
|
||||
'assistant.card.journey.cons.few': '{count} نقاط سلبية',
|
||||
'assistant.card.journey.cons.many': '{count} نقطة سلبية',
|
||||
'assistant.change.verb.linkJournalPhoto': 'إضافة صورة إلى المدخل',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'إزالة صورة من المدخل',
|
||||
'assistant.change.verb.moveJournalPhotos': 'نقل الصور إلى مدخل آخر',
|
||||
'assistant.change.verb.orderJournalPhotos': 'إعادة ترتيب صور المدخل',
|
||||
'assistant.change.verb.captionJournalPhoto': 'تغيير تعليق صورة',
|
||||
'assistant.change.verb.addJournalPhotos': 'إضافة صور من المكتبة',
|
||||
'assistant.card.journeyPhotos.cover': 'غلاف المدخل',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'تصبح الصورة الأولى غلاف المدخل.',
|
||||
'assistant.card.journeyPhotos.kept': 'تبقى أيضًا في المدخل الأول.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'تبقى الصور في معرض اليوميات.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'من مكتبة صورك؛ تحتفظ اليوميات بمرجع إليها فقط.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -60,5 +60,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} müsbət cəhət',
|
||||
'assistant.card.journey.cons': '{count} mənfi cəhət',
|
||||
'assistant.card.journey.cons.one': '{count} mənfi cəhət',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Şəkli qeydə əlavə et',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Şəkli qeyddən götür',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Şəkilləri başqa qeydə köçür',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Qeydin şəkillərini yenidən sırala',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Şəklin başlığını dəyiş',
|
||||
'assistant.change.verb.addJournalPhotos': 'Kitabxanadan şəkil əlavə et',
|
||||
'assistant.card.journeyPhotos.cover': 'Qeydin üz şəkli',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'İlk şəkil qeydin üz şəkli olur.',
|
||||
'assistant.card.journeyPhotos.kept': 'Onlar birinci qeyddə də qalır.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Şəkillər gündəliyin qalereyasında qalır.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Şəkil kitabxanandan; gündəlik yalnız istinadı saxlayır.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -58,5 +58,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} ponto positivo',
|
||||
'assistant.card.journey.cons': '{count} pontos negativos',
|
||||
'assistant.card.journey.cons.one': '{count} ponto negativo',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Colocar uma foto na entrada',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Tirar uma foto da entrada',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Mover fotos para outra entrada',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Reordenar as fotos da entrada',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Mudar a legenda de uma foto',
|
||||
'assistant.change.verb.addJournalPhotos': 'Adicionar fotos da biblioteca',
|
||||
'assistant.card.journeyPhotos.cover': 'Capa da entrada',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'A primeira foto vira a capa da entrada.',
|
||||
'assistant.card.journeyPhotos.kept': 'Elas também ficam na primeira entrada.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'As fotos continuam na galeria do diário.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Da sua biblioteca de fotos; o diário guarda só a referência.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -58,5 +58,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} punt fort',
|
||||
'assistant.card.journey.cons': '{count} punts febles',
|
||||
'assistant.card.journey.cons.one': '{count} punt feble',
|
||||
'assistant.change.verb.linkJournalPhoto': "Posar una foto a l'entrada",
|
||||
'assistant.change.verb.unlinkJournalPhoto': "Treure una foto de l'entrada",
|
||||
'assistant.change.verb.moveJournalPhotos': 'Moure fotos a una altra entrada',
|
||||
'assistant.change.verb.orderJournalPhotos': "Reordenar les fotos de l'entrada",
|
||||
'assistant.change.verb.captionJournalPhoto': "Canviar el peu d'una foto",
|
||||
'assistant.change.verb.addJournalPhotos': 'Afegir fotos de la biblioteca',
|
||||
'assistant.card.journeyPhotos.cover': "Portada de l'entrada",
|
||||
'assistant.card.journeyPhotos.firstIsCover': "La primera foto passa a ser la portada de l'entrada.",
|
||||
'assistant.card.journeyPhotos.kept': 'També es queden a la primera entrada.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Les fotos es queden a la galeria del diari.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'De la teva biblioteca de fotos; el diari només en desa la referència.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -66,5 +66,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons': '{count} minusů',
|
||||
'assistant.card.journey.cons.one': '{count} minus',
|
||||
'assistant.card.journey.cons.few': '{count} minusy',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Přidat fotku k záznamu',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Odebrat fotku ze záznamu',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Přesunout fotky do jiného záznamu',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Přeuspořádat fotky záznamu',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Změnit popisek fotky',
|
||||
'assistant.change.verb.addJournalPhotos': 'Přidat fotky z knihovny',
|
||||
'assistant.card.journeyPhotos.cover': 'Titulní fotka záznamu',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'První fotka se stane titulní fotkou záznamu.',
|
||||
'assistant.card.journeyPhotos.kept': 'Zůstanou i u prvního záznamu.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Fotky zůstanou v galerii deníku.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Z tvé knihovny fotek; deník si uloží jen odkaz.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -60,5 +60,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} Pluspunkt',
|
||||
'assistant.card.journey.cons': '{count} Minuspunkte',
|
||||
'assistant.card.journey.cons.one': '{count} Minuspunkt',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Foto zum Eintrag legen',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Foto vom Eintrag nehmen',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Fotos in einen anderen Eintrag verschieben',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Fotos des Eintrags neu ordnen',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Bildunterschrift ändern',
|
||||
'assistant.change.verb.addJournalPhotos': 'Fotos aus der Bibliothek hinzufügen',
|
||||
'assistant.card.journeyPhotos.cover': 'Titelbild des Eintrags',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Das erste Foto wird zum Titelbild des Eintrags.',
|
||||
'assistant.card.journeyPhotos.kept': 'Sie bleiben auch beim ersten Eintrag.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Die Fotos bleiben in der Galerie des Journals.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Aus deiner Fotobibliothek; das Journal merkt sich nur den Verweis.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -58,5 +58,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} good point',
|
||||
'assistant.card.journey.cons': '{count} weak points',
|
||||
'assistant.card.journey.cons.one': '{count} weak point',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Put a photo on the entry',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Take a photo off the entry',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Move photos to another entry',
|
||||
'assistant.change.verb.orderJournalPhotos': "Put the entry's photos in a new order",
|
||||
'assistant.change.verb.captionJournalPhoto': "Change a photo's caption",
|
||||
'assistant.change.verb.addJournalPhotos': 'Add photos from the library',
|
||||
'assistant.card.journeyPhotos.cover': 'Entry cover',
|
||||
'assistant.card.journeyPhotos.firstIsCover': "The first photo becomes the entry's cover.",
|
||||
'assistant.card.journeyPhotos.kept': 'They also stay on the first entry.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': "The photos stay in the journal's gallery.",
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'From your photo library; the journal keeps a reference.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} punto a favor',
|
||||
'assistant.card.journey.cons': '{count} puntos en contra',
|
||||
'assistant.card.journey.cons.one': '{count} punto en contra',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Poner una foto en la entrada',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Quitar una foto de la entrada',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Mover fotos a otra entrada',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Reordenar las fotos de la entrada',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Cambiar el pie de una foto',
|
||||
'assistant.change.verb.addJournalPhotos': 'Añadir fotos de la biblioteca',
|
||||
'assistant.card.journeyPhotos.cover': 'Portada de la entrada',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'La primera foto pasa a ser la portada de la entrada.',
|
||||
'assistant.card.journeyPhotos.kept': 'También se quedan en la primera entrada.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Las fotos se quedan en la galería del diario.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'De tu biblioteca de fotos; el diario solo guarda la referencia.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} pluss',
|
||||
'assistant.card.journey.cons': '{count} miinust',
|
||||
'assistant.card.journey.cons.one': '{count} miinus',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Lisa foto sissekandele',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Eemalda foto sissekandelt',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Tõsta fotod teisele sissekandele',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Järjesta sissekande fotod ümber',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Muuda foto pealdist',
|
||||
'assistant.change.verb.addJournalPhotos': 'Lisa fotod kogust',
|
||||
'assistant.card.journeyPhotos.cover': 'Sissekande kaanepilt',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Esimesest fotost saab sissekande kaanepilt.',
|
||||
'assistant.card.journeyPhotos.kept': 'Need jäävad ka esimesele sissekandele.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Fotod jäävad päeviku galeriisse.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Sinu fotokogust; päevik hoiab ainult viidet.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -60,5 +60,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} point fort',
|
||||
'assistant.card.journey.cons': '{count} points faibles',
|
||||
'assistant.card.journey.cons.one': '{count} point faible',
|
||||
'assistant.change.verb.linkJournalPhoto': "Ajouter une photo à l'entrée",
|
||||
'assistant.change.verb.unlinkJournalPhoto': "Retirer une photo de l'entrée",
|
||||
'assistant.change.verb.moveJournalPhotos': 'Déplacer des photos vers une autre entrée',
|
||||
'assistant.change.verb.orderJournalPhotos': "Réordonner les photos de l'entrée",
|
||||
'assistant.change.verb.captionJournalPhoto': "Modifier la légende d'une photo",
|
||||
'assistant.change.verb.addJournalPhotos': 'Ajouter des photos de la photothèque',
|
||||
'assistant.card.journeyPhotos.cover': "Couverture de l'entrée",
|
||||
'assistant.card.journeyPhotos.firstIsCover': "La première photo devient la couverture de l'entrée.",
|
||||
'assistant.card.journeyPhotos.kept': 'Elles restent aussi sur la première entrée.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Les photos restent dans la galerie du journal.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': "Depuis votre photothèque ; le journal ne garde qu'une référence.",
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -60,5 +60,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} θετικό',
|
||||
'assistant.card.journey.cons': '{count} αρνητικά',
|
||||
'assistant.card.journey.cons.one': '{count} αρνητικό',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Προσθήκη φωτογραφίας στην καταχώριση',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Αφαίρεση φωτογραφίας από την καταχώριση',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Μετακίνηση φωτογραφιών σε άλλη καταχώριση',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Αναδιάταξη φωτογραφιών της καταχώρισης',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Αλλαγή λεζάντας φωτογραφίας',
|
||||
'assistant.change.verb.addJournalPhotos': 'Προσθήκη φωτογραφιών από τη βιβλιοθήκη',
|
||||
'assistant.card.journeyPhotos.cover': 'Εξώφυλλο καταχώρισης',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Η πρώτη φωτογραφία γίνεται εξώφυλλο της καταχώρισης.',
|
||||
'assistant.card.journeyPhotos.kept': 'Μένουν και στην πρώτη καταχώριση.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Οι φωτογραφίες μένουν στη συλλογή του ημερολογίου.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Από τη βιβλιοθήκη φωτογραφιών σου· το ημερολόγιο κρατά μόνο αναφορά.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -60,5 +60,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} előny',
|
||||
'assistant.card.journey.cons': '{count} hátrány',
|
||||
'assistant.card.journey.cons.one': '{count} hátrány',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Fotó hozzáadása a bejegyzéshez',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Fotó levétele a bejegyzésről',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Fotók áthelyezése másik bejegyzéshez',
|
||||
'assistant.change.verb.orderJournalPhotos': 'A bejegyzés fotóinak átrendezése',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Fotó feliratának módosítása',
|
||||
'assistant.change.verb.addJournalPhotos': 'Fotók hozzáadása a könyvtárból',
|
||||
'assistant.card.journeyPhotos.cover': 'A bejegyzés borítóképe',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Az első fotó lesz a bejegyzés borítóképe.',
|
||||
'assistant.card.journeyPhotos.kept': 'Az első bejegyzésen is megmaradnak.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'A fotók a napló galériájában maradnak.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'A fotókönyvtáradból; a napló csak hivatkozást tárol.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -51,5 +51,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': 'Urutkan ulang entri',
|
||||
'assistant.card.journey.pros': '{count} hal positif',
|
||||
'assistant.card.journey.cons': '{count} hal negatif',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Taruh foto di entri',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Lepas foto dari entri',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Pindahkan foto ke entri lain',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Urutkan ulang foto entri',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Ubah keterangan foto',
|
||||
'assistant.change.verb.addJournalPhotos': 'Tambahkan foto dari pustaka',
|
||||
'assistant.card.journeyPhotos.cover': 'Sampul entri',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Foto pertama menjadi sampul entri.',
|
||||
'assistant.card.journeyPhotos.kept': 'Foto juga tetap di entri pertama.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Foto tetap ada di galeri jurnal.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Dari pustaka fotomu; jurnal hanya menyimpan rujukannya.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} punto a favore',
|
||||
'assistant.card.journey.cons': '{count} punti a sfavore',
|
||||
'assistant.card.journey.cons.one': '{count} punto a sfavore',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Aggiungi una foto alla voce',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Togli una foto dalla voce',
|
||||
'assistant.change.verb.moveJournalPhotos': "Sposta le foto in un'altra voce",
|
||||
'assistant.change.verb.orderJournalPhotos': 'Riordina le foto della voce',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Cambia la didascalia di una foto',
|
||||
'assistant.change.verb.addJournalPhotos': 'Aggiungi foto dalla libreria',
|
||||
'assistant.card.journeyPhotos.cover': 'Copertina della voce',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'La prima foto diventa la copertina della voce.',
|
||||
'assistant.card.journeyPhotos.kept': 'Restano anche nella prima voce.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Le foto restano nella galleria del diario.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Dalla tua libreria di foto; il diario ne tiene solo il riferimento.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -50,5 +50,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': 'エントリーを並べ替える',
|
||||
'assistant.card.journey.pros': '良い点 {count} 件',
|
||||
'assistant.card.journey.cons': '残念な点 {count} 件',
|
||||
'assistant.change.verb.linkJournalPhoto': 'エントリーに写真を追加',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'エントリーから写真を外す',
|
||||
'assistant.change.verb.moveJournalPhotos': '写真を別のエントリーへ移動',
|
||||
'assistant.change.verb.orderJournalPhotos': 'エントリーの写真を並べ替える',
|
||||
'assistant.change.verb.captionJournalPhoto': '写真のキャプションを変更',
|
||||
'assistant.change.verb.addJournalPhotos': 'ライブラリから写真を追加',
|
||||
'assistant.card.journeyPhotos.cover': 'エントリーのカバー',
|
||||
'assistant.card.journeyPhotos.firstIsCover': '最初の写真がエントリーのカバーになります。',
|
||||
'assistant.card.journeyPhotos.kept': '最初のエントリーにも残ります。',
|
||||
'assistant.card.journeyPhotos.staysInGallery': '写真はジャーナルのギャラリーに残ります。',
|
||||
'assistant.card.journeyPhotos.fromLibrary': '写真ライブラリから。ジャーナルには参照だけが保存されます。',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -50,5 +50,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': '항목 순서 바꾸기',
|
||||
'assistant.card.journey.pros': '좋은 점 {count}개',
|
||||
'assistant.card.journey.cons': '아쉬운 점 {count}개',
|
||||
'assistant.change.verb.linkJournalPhoto': '항목에 사진 넣기',
|
||||
'assistant.change.verb.unlinkJournalPhoto': '항목에서 사진 빼기',
|
||||
'assistant.change.verb.moveJournalPhotos': '사진을 다른 항목으로 옮기기',
|
||||
'assistant.change.verb.orderJournalPhotos': '항목 사진 순서 바꾸기',
|
||||
'assistant.change.verb.captionJournalPhoto': '사진 설명 바꾸기',
|
||||
'assistant.change.verb.addJournalPhotos': '라이브러리에서 사진 추가',
|
||||
'assistant.card.journeyPhotos.cover': '항목 표지',
|
||||
'assistant.card.journeyPhotos.firstIsCover': '첫 번째 사진이 항목 표지가 돼요.',
|
||||
'assistant.card.journeyPhotos.kept': '첫 번째 항목에도 남아요.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': '사진은 저널 갤러리에 남아요.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': '사진 라이브러리에서 가져와요. 저널에는 참조만 저장돼요.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} pluspunt',
|
||||
'assistant.card.journey.cons': '{count} minpunten',
|
||||
'assistant.card.journey.cons.one': '{count} minpunt',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Foto bij het bericht zetten',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Foto van het bericht halen',
|
||||
'assistant.change.verb.moveJournalPhotos': "Foto's naar een ander bericht verplaatsen",
|
||||
'assistant.change.verb.orderJournalPhotos': "Foto's van het bericht opnieuw ordenen",
|
||||
'assistant.change.verb.captionJournalPhoto': 'Bijschrift van een foto wijzigen',
|
||||
'assistant.change.verb.addJournalPhotos': "Foto's uit de bibliotheek toevoegen",
|
||||
'assistant.card.journeyPhotos.cover': 'Omslag van het bericht',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'De eerste foto wordt de omslag van het bericht.',
|
||||
'assistant.card.journeyPhotos.kept': 'Ze blijven ook bij het eerste bericht.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': "De foto's blijven in de galerij van het dagboek.",
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Uit je fotobibliotheek; het dagboek bewaart alleen een verwijzing.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -75,5 +75,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons.one': '{count} minus',
|
||||
'assistant.card.journey.cons.few': '{count} minusy',
|
||||
'assistant.card.journey.cons.many': '{count} minusów',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Dodaj zdjęcie do wpisu',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Usuń zdjęcie z wpisu',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Przenieś zdjęcia do innego wpisu',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Zmień kolejność zdjęć wpisu',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Zmień podpis zdjęcia',
|
||||
'assistant.change.verb.addJournalPhotos': 'Dodaj zdjęcia z biblioteki',
|
||||
'assistant.card.journeyPhotos.cover': 'Okładka wpisu',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Pierwsze zdjęcie staje się okładką wpisu.',
|
||||
'assistant.card.journeyPhotos.kept': 'Zostają też przy pierwszym wpisie.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Zdjęcia zostają w galerii dziennika.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Z twojej biblioteki zdjęć; dziennik zapisuje tylko odnośnik.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -75,5 +75,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons.one': '{count} минус',
|
||||
'assistant.card.journey.cons.few': '{count} минуса',
|
||||
'assistant.card.journey.cons.many': '{count} минусов',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Добавить фото к записи',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Убрать фото из записи',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Перенести фото в другую запись',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Изменить порядок фото записи',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Изменить подпись к фото',
|
||||
'assistant.change.verb.addJournalPhotos': 'Добавить фото из библиотеки',
|
||||
'assistant.card.journeyPhotos.cover': 'Обложка записи',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Первое фото станет обложкой записи.',
|
||||
'assistant.card.journeyPhotos.kept': 'Они останутся и в первой записи.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Фото останутся в галерее дневника.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Из твоей фотобиблиотеки; дневник хранит только ссылку.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -66,5 +66,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons': '{count} mínusov',
|
||||
'assistant.card.journey.cons.one': '{count} mínus',
|
||||
'assistant.card.journey.cons.few': '{count} mínusy',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Pridať fotku k záznamu',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Odobrať fotku zo záznamu',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Presunúť fotky do iného záznamu',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Preusporiadať fotky záznamu',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Zmeniť popis fotky',
|
||||
'assistant.change.verb.addJournalPhotos': 'Pridať fotky z knižnice',
|
||||
'assistant.card.journeyPhotos.cover': 'Titulná fotka záznamu',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Prvá fotka sa stane titulnou fotkou záznamu.',
|
||||
'assistant.card.journeyPhotos.kept': 'Zostanú aj pri prvom zázname.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Fotky zostanú v galérii denníka.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Z tvojej knižnice fotiek; denník si uloží len odkaz.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -58,5 +58,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} fördel',
|
||||
'assistant.card.journey.cons': '{count} nackdelar',
|
||||
'assistant.card.journey.cons.one': '{count} nackdel',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Lägg ett foto på inlägget',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Ta bort ett foto från inlägget',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Flytta foton till ett annat inlägg',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Ordna om inläggets foton',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Ändra ett fotos bildtext',
|
||||
'assistant.change.verb.addJournalPhotos': 'Lägg till foton från biblioteket',
|
||||
'assistant.card.journeyPhotos.cover': 'Inläggets omslag',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Det första fotot blir inläggets omslag.',
|
||||
'assistant.card.journeyPhotos.kept': 'De finns kvar på det första inlägget också.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Fotona finns kvar i dagbokens galleri.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Från ditt fotobibliotek; dagboken sparar bara en hänvisning.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -50,5 +50,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': 'จัดลำดับรายการใหม่',
|
||||
'assistant.card.journey.pros': 'ข้อดี {count} ข้อ',
|
||||
'assistant.card.journey.cons': 'ข้อเสีย {count} ข้อ',
|
||||
'assistant.change.verb.linkJournalPhoto': 'ใส่รูปในรายการ',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'เอารูปออกจากรายการ',
|
||||
'assistant.change.verb.moveJournalPhotos': 'ย้ายรูปไปยังรายการอื่น',
|
||||
'assistant.change.verb.orderJournalPhotos': 'จัดลำดับรูปของรายการใหม่',
|
||||
'assistant.change.verb.captionJournalPhoto': 'เปลี่ยนคำบรรยายรูป',
|
||||
'assistant.change.verb.addJournalPhotos': 'เพิ่มรูปจากคลังภาพ',
|
||||
'assistant.card.journeyPhotos.cover': 'ภาพปกของรายการ',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'รูปแรกจะกลายเป็นภาพปกของรายการ',
|
||||
'assistant.card.journeyPhotos.kept': 'รูปยังคงอยู่ในรายการแรกด้วย',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'รูปยังอยู่ในแกลเลอรีของบันทึก',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'จากคลังภาพของคุณ บันทึกเก็บไว้เพียงลิงก์อ้างอิง',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -59,5 +59,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.pros.one': '{count} artı',
|
||||
'assistant.card.journey.cons': '{count} eksi',
|
||||
'assistant.card.journey.cons.one': '{count} eksi',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Kayda fotoğraf ekle',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Fotoğrafı kayıttan kaldır',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Fotoğrafları başka kayda taşı',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Kaydın fotoğraflarını yeniden sırala',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Fotoğraf açıklamasını değiştir',
|
||||
'assistant.change.verb.addJournalPhotos': 'Kitaplıktan fotoğraf ekle',
|
||||
'assistant.card.journeyPhotos.cover': 'Kayıt kapağı',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'İlk fotoğraf kaydın kapağı olur.',
|
||||
'assistant.card.journeyPhotos.kept': 'İlk kayıtta da kalırlar.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Fotoğraflar günlüğün galerisinde kalır.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Fotoğraf kitaplığından; günlük yalnızca bağlantıyı saklar.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -75,5 +75,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.card.journey.cons.one': '{count} мінус',
|
||||
'assistant.card.journey.cons.few': '{count} мінуси',
|
||||
'assistant.card.journey.cons.many': '{count} мінусів',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Додати фото до запису',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Прибрати фото із запису',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Перенести фото до іншого запису',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Змінити порядок фото запису',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Змінити підпис до фото',
|
||||
'assistant.change.verb.addJournalPhotos': 'Додати фото з бібліотеки',
|
||||
'assistant.card.journeyPhotos.cover': 'Обкладинка запису',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Перше фото стане обкладинкою запису.',
|
||||
'assistant.card.journeyPhotos.kept': 'Вони залишаться і в першому записі.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Фото залишаться в галереї щоденника.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'З твоєї фотобібліотеки; щоденник зберігає лише посилання.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -51,5 +51,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': 'Sắp xếp lại các mục',
|
||||
'assistant.card.journey.pros': '{count} điểm cộng',
|
||||
'assistant.card.journey.cons': '{count} điểm trừ',
|
||||
'assistant.change.verb.linkJournalPhoto': 'Thêm ảnh vào mục',
|
||||
'assistant.change.verb.unlinkJournalPhoto': 'Bỏ ảnh khỏi mục',
|
||||
'assistant.change.verb.moveJournalPhotos': 'Chuyển ảnh sang mục khác',
|
||||
'assistant.change.verb.orderJournalPhotos': 'Sắp xếp lại ảnh của mục',
|
||||
'assistant.change.verb.captionJournalPhoto': 'Đổi chú thích ảnh',
|
||||
'assistant.change.verb.addJournalPhotos': 'Thêm ảnh từ thư viện',
|
||||
'assistant.card.journeyPhotos.cover': 'Ảnh bìa của mục',
|
||||
'assistant.card.journeyPhotos.firstIsCover': 'Ảnh đầu tiên sẽ thành ảnh bìa của mục.',
|
||||
'assistant.card.journeyPhotos.kept': 'Ảnh vẫn được giữ ở mục đầu tiên.',
|
||||
'assistant.card.journeyPhotos.staysInGallery': 'Ảnh vẫn ở trong thư viện ảnh của nhật ký.',
|
||||
'assistant.card.journeyPhotos.fromLibrary': 'Từ thư viện ảnh của bạn; nhật ký chỉ lưu liên kết tham chiếu.',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -50,5 +50,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': '重新排列條目',
|
||||
'assistant.card.journey.pros': '{count} 個優點',
|
||||
'assistant.card.journey.cons': '{count} 個不足',
|
||||
'assistant.change.verb.linkJournalPhoto': '將照片放到條目上',
|
||||
'assistant.change.verb.unlinkJournalPhoto': '從條目上移除照片',
|
||||
'assistant.change.verb.moveJournalPhotos': '將照片移到另一個條目',
|
||||
'assistant.change.verb.orderJournalPhotos': '重新排列條目的照片',
|
||||
'assistant.change.verb.captionJournalPhoto': '變更照片說明',
|
||||
'assistant.change.verb.addJournalPhotos': '從圖庫加入照片',
|
||||
'assistant.card.journeyPhotos.cover': '條目封面',
|
||||
'assistant.card.journeyPhotos.firstIsCover': '第一張照片會成為條目封面。',
|
||||
'assistant.card.journeyPhotos.kept': '它們也會保留在第一個條目上。',
|
||||
'assistant.card.journeyPhotos.staysInGallery': '照片會保留在日誌圖庫中。',
|
||||
'assistant.card.journeyPhotos.fromLibrary': '來自你的照片圖庫,日誌只保存參照。',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
@@ -50,5 +50,16 @@ const assistantJourney: TranslationStrings = {
|
||||
'assistant.change.verb.reorderJournalEntries': '重新排列条目',
|
||||
'assistant.card.journey.pros': '{count} 个优点',
|
||||
'assistant.card.journey.cons': '{count} 个不足',
|
||||
'assistant.change.verb.linkJournalPhoto': '将照片放到条目上',
|
||||
'assistant.change.verb.unlinkJournalPhoto': '从条目上移除照片',
|
||||
'assistant.change.verb.moveJournalPhotos': '将照片移到另一个条目',
|
||||
'assistant.change.verb.orderJournalPhotos': '重新排列条目的照片',
|
||||
'assistant.change.verb.captionJournalPhoto': '更改照片说明',
|
||||
'assistant.change.verb.addJournalPhotos': '从图库添加照片',
|
||||
'assistant.card.journeyPhotos.cover': '条目封面',
|
||||
'assistant.card.journeyPhotos.firstIsCover': '第一张照片会成为条目封面。',
|
||||
'assistant.card.journeyPhotos.kept': '它们也会保留在第一个条目上。',
|
||||
'assistant.card.journeyPhotos.staysInGallery': '照片会保留在日志图库中。',
|
||||
'assistant.card.journeyPhotos.fromLibrary': '来自你的照片图库,日志只保存引用。',
|
||||
};
|
||||
export default assistantJourney;
|
||||
|
||||
Reference in New Issue
Block a user