diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs index ebf9048bd3..ba9de7cd68 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs @@ -123,6 +123,42 @@ async Task IMiniLcmWriteApi.UpdateExampleSentence(Guid entryId, return result; } + async Task IMiniLcmWriteApi.CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position) + { + await using var _ = BeginTrackingChanges(); + var result = await _api.CreatePicture(entryId, senseId, picture, position); + NotifyEntryChanged(entryId); + return result; + } + + async Task IMiniLcmWriteApi.UpdatePicture(Guid entryId, Guid senseId, Guid pictureId, UpdateObjectInput update) + { + await using var _ = BeginTrackingChanges(); + var result = await _api.UpdatePicture(entryId, senseId, pictureId, update); + NotifyEntryChanged(entryId); + return result; + } + + async Task IMiniLcmWriteApi.UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after, IMiniLcmApi? api) + { + await using var _ = BeginTrackingChanges(); + var result = await _api.UpdatePicture(entryId, senseId, before, after, api ?? this); + NotifyEntryChanged(entryId); + return result; + } + + async Task IMiniLcmWriteApi.MovePicture(Guid entryId, Guid senseId, Guid pictureId, BetweenPosition position) + { + await _api.MovePicture(entryId, senseId, pictureId, position); + NotifyEntryChanged(entryId); + } + + async Task IMiniLcmWriteApi.DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + { + await _api.DeletePicture(entryId, senseId, pictureId); + NotifyEntryChanged(entryId); + } + async Task IMiniLcmWriteApi.CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position) { var result = await _api.CreateComplexFormComponent(complexFormComponent, position); diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index a56aa4817e..fd23950b7d 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -382,6 +382,37 @@ public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid example OnDataChanged(); } + [JSInvokable] + public async Task CreatePicture(Guid entryId, Guid senseId, Picture picture) + { + var createdPicture = await _wrappedApi.CreatePicture(entryId, senseId, picture); + OnDataChanged(); + return createdPicture; + } + + [JSInvokable] + public async Task UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after) + { + var updatedPicture = await _wrappedApi.UpdatePicture(entryId, senseId, before, after); + OnDataChanged(); + return updatedPicture; + } + + [JSInvokable] + // previousPictureId and nextPictureId represent the target position, where the picture should end up after being moved + public async Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, Guid? previousPictureId = null, Guid? nextPictureId = null) + { + await _wrappedApi.MovePicture(entryId, senseId, pictureId, new MiniLcm.SyncHelpers.BetweenPosition(previousPictureId, nextPictureId)); + OnDataChanged(); + } + + [JSInvokable] + public async Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + { + await _wrappedApi.DeletePicture(entryId, senseId, pictureId); + OnDataChanged(); + } + [JSInvokable] public async Task GetFileStream(string mediaUri) { diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index d4cec3d0e0..1566db6bc0 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Net; using Microsoft.Extensions.Options; using MiniLcm.Project; using Refit; @@ -48,19 +50,46 @@ public async Task DeleteResource(Guid fileId) /// media file Id /// /// + // Coalesces concurrent downloads of the same file into one shared task. Without this, two + // requests for a not-yet-cached file (e.g. the UI loading a picture more than once at first + // render) both see no local resource and both call DownloadResource, whose AddLocalResource + // inserts a LocalResource keyed by the file id — the second insert then fails with + // "UNIQUE constraint failed: LocalResource.Id". The first caller starts the task; others + // await the same one. Keyed by file id (a unique Guid), so different files still download in + // parallel. + private static readonly ConcurrentDictionary> DownloadTasks = new(); + public async Task GetFileStream(Guid fileId) { var localResource = await resourceService.GetLocalResource(fileId); if (localResource is null) { - var connectionStatus = await httpClientProvider.ConnectionStatus(); - if (connectionStatus == ConnectionStatus.Online) + var downloadTask = DownloadTasks.GetOrAdd(fileId, async _ => + { + // Check again if we have it locally in case another thread completed before the GetOrAdd factory ran + return await resourceService.GetLocalResource(fileId) ?? await resourceService.DownloadResource(fileId, this); + }); + try { - localResource = await resourceService.DownloadResource(fileId, this); + localResource = await downloadTask; } - else + catch { - return new ReadFileResponse(ReadFileResult.Offline); + // Did the download fail because we're offline? Return an explicit "we're offline" result + // so that the frontend can display some appropriate UI. If we're online and yet the download + // failed, then that's a real error that should be rethrown. + if (await httpClientProvider.ConnectionStatus() == ConnectionStatus.Offline) + { + return new ReadFileResponse(ReadFileResult.Offline); + } + throw; + } + finally + { + // Once the download completes, we need to remove the task from the ConcurrentDictionary + // so that a new download can be attempted later by a different thread. That way if the local + // file is ever deleted (say, by being replaced with a different picture) a new download can start. + DownloadTasks.TryRemove(new KeyValuePair>(fileId, downloadTask)); } } //todo, consider trying to download the file again, maybe the cache was cleared @@ -69,16 +98,19 @@ public async Task GetFileStream(Guid fileId) return new(File.OpenRead(localResource.LocalPath), Path.GetFileName(localResource.LocalPath)); } - private async Task<(Stream? stream, string? filename)> RequestMediaFile(Guid fileId) - { - var mediaClient = await MediaServerClient(); - var response = await mediaClient.DownloadFile(fileId); - if (!response.IsSuccessStatusCode) - { - throw new Exception($"Failed to download file {fileId}: {response.StatusCode} {response.ReasonPhrase}"); - } - return (await response.Content.ReadAsStreamAsync(), response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", "")); - } + // Media files are proxied (lexbox -> FwHeadless). A cold request — e.g. the first one after the + // backend starts, before its code paths are JIT-warmed — can exceed the proxy's timeout and come + // back as 504 (or 502/503) even though the backend keeps working and warms up. Retrying a + // transient failure a few times usually hits the now-warm backend and succeeds. A failed fetch + // never reaches AddLocalResource, so retrying cannot reintroduce the duplicate-key insert. + private const int MaxDownloadAttempts = 3; + private static readonly HashSet TransientDownloadStatusCodes = + [ + HttpStatusCode.RequestTimeout, // 408 + HttpStatusCode.BadGateway, // 502 + HttpStatusCode.ServiceUnavailable, // 503 + HttpStatusCode.GatewayTimeout, // 504 + ]; private async Task MediaServerClient() { @@ -91,16 +123,32 @@ async Task IRemoteResourceService.DownloadResource(string remote { var projectResourceCachePath = ProjectResourceCachePath; Directory.CreateDirectory(projectResourceCachePath); - var (stream, filename) = await RequestMediaFile(new Guid(remoteId)); - if (stream is null) throw new FileNotFoundException(remoteId); - await using (stream) + var mediaClient = await MediaServerClient(); + var fileId = new Guid(remoteId); + for (var attempt = 1; ; attempt++) { - filename = Path.GetFileName(filename); - var localPath = Path.Combine(projectResourceCachePath, filename ?? remoteId); - localPath = EnsureUnique(localPath); - await using var localFile = File.Create(localPath); - await stream.CopyToAsync(localFile); - return new DownloadResult(localPath); + using var response = await mediaClient.DownloadFile(fileId); + if (response.IsSuccessStatusCode) + { + await using var stream = await response.Content.ReadAsStreamAsync(); + if (stream is null) throw new FileNotFoundException(remoteId); + var filename = response.Content.Headers.ContentDisposition?.FileName?.Replace("\"", ""); + filename = Path.GetFileName(filename); + var localPath = Path.Combine(projectResourceCachePath, filename ?? remoteId); + localPath = EnsureUnique(localPath); + await using var localFile = File.Create(localPath); + await stream.CopyToAsync(localFile); + return new DownloadResult(localPath); + } + + var statusCode = response.StatusCode; + var reasonPhrase = response.ReasonPhrase; + if (attempt >= MaxDownloadAttempts || !TransientDownloadStatusCodes.Contains(statusCode)) + { + throw new Exception($"Failed to download file {fileId}: {statusCode} {reasonPhrase}"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 160f6180f4..7b3ca922d8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -516,9 +516,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - embla-carousel-svelte: - specifier: ^8.6.0 - version: 8.6.0(svelte@5.56.0(@typescript-eslint/types@8.56.1)) eslint: specifier: 'catalog:' version: 9.39.3(jiti@2.6.1) @@ -4513,19 +4510,6 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - embla-carousel-reactive-utils@8.6.0: - resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} - peerDependencies: - embla-carousel: 8.6.0 - - embla-carousel-svelte@8.6.0: - resolution: {integrity: sha512-ZDsKk8Sdv+AUTygMYcwZjfRd1DTh+JSUzxkOo8b9iKAkYjg+39mzbY/lwHsE3jXSpKxdKWS69hPSNuzlOGtR2Q==} - peerDependencies: - svelte: ^3.49.0 || ^4.0.0 || ^5.0.0 - - embla-carousel@8.6.0: - resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -12292,18 +12276,6 @@ snapshots: electron-to-chromium@1.5.302: {} - embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): - dependencies: - embla-carousel: 8.6.0 - - embla-carousel-svelte@8.6.0(svelte@5.56.0(@typescript-eslint/types@8.56.1)): - dependencies: - embla-carousel: 8.6.0 - embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - svelte: 5.56.0(@typescript-eslint/types@8.56.1) - - embla-carousel@8.6.0: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 645a52abe2..380725d7f7 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -67,7 +67,6 @@ "@vitest/ui": "catalog:", "bits-ui": "2.16.4", "clsx": "^2.1.1", - "embla-carousel-svelte": "^8.6.0", "eslint": "catalog:", "eslint-config-prettier": "^10.1.8", "eslint-output": "catalog:", diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 5fedeb1bfc..7a01a4828f 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -21,6 +21,7 @@ import type {ICreateEntryOptions} from '../../MiniLcm/ICreateEntryOptions'; import type {IComplexFormComponent} from '../../MiniLcm/Models/IComplexFormComponent'; import type {ISense} from '../../MiniLcm/Models/ISense'; import type {IExampleSentence} from '../../MiniLcm/Models/IExampleSentence'; +import type {IPicture} from '../../MiniLcm/Models/IPicture'; import type {IReadFileResponseJs} from './IReadFileResponseJs'; import type {IUploadFileResponse} from '../../MiniLcm/Media/IUploadFileResponse'; import type {ILcmFileMetadata} from '../../MiniLcm/Media/ILcmFileMetadata'; @@ -75,6 +76,10 @@ export interface IMiniLcmJsInvokable createExampleSentence(entryId: string, senseId: string, exampleSentence: IExampleSentence) : Promise; updateExampleSentence(entryId: string, senseId: string, before: IExampleSentence, after: IExampleSentence) : Promise; deleteExampleSentence(entryId: string, senseId: string, exampleSentenceId: string) : Promise; + createPicture(entryId: string, senseId: string, picture: IPicture) : Promise; + updatePicture(entryId: string, senseId: string, before: IPicture, after: IPicture) : Promise; + movePicture(entryId: string, senseId: string, pictureId: string, previousPictureId?: string, nextPictureId?: string) : Promise; + deletePicture(entryId: string, senseId: string, pictureId: string) : Promise; getFileStream(mediaUri: string) : Promise; saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata) : Promise; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts index ec6024469b..2e9069b827 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts @@ -11,6 +11,5 @@ export interface IPicture order: number; mediaUri: string; caption: IRichMultiString; - deletedAt?: string; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/index.ts b/frontend/viewer/src/lib/dotnet-types/index.ts index 811b6979d1..1e5c6cfbe3 100644 --- a/frontend/viewer/src/lib/dotnet-types/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/index.ts @@ -16,6 +16,7 @@ export * from './generated-types/MiniLcm/Models/ITranslation'; export * from './generated-types/MiniLcm/Models/IMorphType'; export * from './generated-types/MiniLcm/Models/IObjectWithId'; export * from './generated-types/MiniLcm/Models/IPartOfSpeech'; +export * from './generated-types/MiniLcm/Models/IPicture'; export * from './generated-types/MiniLcm/Models/IProjectIdentifier'; export * from './generated-types/MiniLcm/Models/IPublication'; export * from './generated-types/MiniLcm/Models/IRichSpan'; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte new file mode 100644 index 0000000000..1583d6bbe9 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -0,0 +1,110 @@ + + + + e.preventDefault()}> + + {$t`Edit Picture`} + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte new file mode 100644 index 0000000000..35f4cdc05f --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -0,0 +1,122 @@ + + +{#snippet imageContent()} + {#if state.status === 'loaded'} + + {caption + {:else if state.status === 'loading'} +
+ +
+ {:else} +
+ + {state.message} +
+ {/if} +{/snippet} + +
+ +
+ {#if editable} + + {:else} + {@render imageContent()} + {/if} +
+ {#if showCaption && caption} + +
+ {caption} +
+ {/if} +
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte new file mode 100644 index 0000000000..4eb9cd3094 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -0,0 +1,186 @@ + + +
+ {#if pictures.length > 0} + +
+ {#each pictures as picture (picture.id)} + openEditor(picture)} + /> + {/each} +
+ {:else if readonly} +
+ {$t`No pictures`} +
+ {/if} + + {#if !readonly} + +
+ +
+ + + {/if} +
+ +{#if editingPicture} + uploadReplacement(file)} + onSubmit={(after) => void submitEdits(after)} + onDelete={() => void deleteEditingPicture()} + /> +{/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-formats.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-formats.ts new file mode 100644 index 0000000000..32af991f8a --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-formats.ts @@ -0,0 +1,11 @@ +// Single source of truth for the picture upload formats, shared by the add file picker +// (PicturesEditor) and the replace file picker (EditPictureDialog). + +// Formats the browser accepts and that the server supports for pictures. +export const ACCEPTED_PICTURE_TYPES = 'image/jpeg,image/png,image/tiff,image/bmp'; + +// The server rejects files above its size limit. JPEGs can usually be shrunk by lowering the +// export quality, whereas lossless formats (PNG, BMP, TIFF) need a smaller resolution instead. +export function isLosslessImage(file: File): boolean { + return /^image\/(png|bmp|tiff)$/.test(file.type) || /\.(png|bmp|tiff?)$/i.test(file.name); +} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte index 10e1fdc0b8..0eee7bb04a 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte @@ -12,6 +12,7 @@ import type {Snippet} from 'svelte'; import {entityConfig, type SenseFieldId} from '../../views/entity-config'; import {tvt} from '$lib/views/view-text'; + import PicturesEditor from '../field-editors/PicturesEditor.svelte'; interface Props extends Omit { sense: ISense; @@ -97,4 +98,11 @@ {@render semanticDomainsDescription?.()} + + + + + + + diff --git a/frontend/viewer/src/lib/views/entity-config.ts b/frontend/viewer/src/lib/views/entity-config.ts index 311dfc8635..d3e8ec4cba 100644 --- a/frontend/viewer/src/lib/views/entity-config.ts +++ b/frontend/viewer/src/lib/views/entity-config.ts @@ -65,6 +65,10 @@ export const entityConfig = { label: msg`Semantic domains`, helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/semantic_domains_field.htm', }, + pictures: { + label: msg`Pictures`, + helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/Picture_field.htm', + }, }, example: { $label: msg`Example`, diff --git a/frontend/viewer/src/lib/views/view-data.ts b/frontend/viewer/src/lib/views/view-data.ts index 094571043f..12be458a82 100644 --- a/frontend/viewer/src/lib/views/view-data.ts +++ b/frontend/viewer/src/lib/views/view-data.ts @@ -61,6 +61,7 @@ export const FW_LITE_VIEW: RootView = { definition: {show: true, order: 2}, partOfSpeechId: {show: true, order: 3}, semanticDomains: {show: true, order: 4}, + pictures: {show: true, order: 5}, }, example: { sentence: {show: true, order: 1}, diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index c31ad2ee21..328274cc5e 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -336,10 +336,15 @@ msgstr "Browse view failed" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Cancel" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "Caption" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -567,6 +572,10 @@ msgstr "Delete {0}" msgid "Delete Entry" msgstr "Delete Entry" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "Delete Picture" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -681,6 +690,11 @@ msgstr "Downloading..." msgid "Edit Custom View" msgstr "Edit Custom View" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "Edit Picture" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1034,6 +1048,11 @@ msgstr "I don't see my project" msgid "I understand that this can't be undone" msgstr "I understand that this can't be undone" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "Image not found" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1369,6 +1388,11 @@ msgstr "No items found" msgid "No new data" msgstr "No new data" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "No pictures" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1448,6 +1472,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, unable to download" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "Offline, unable to download image" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1540,6 +1569,18 @@ msgstr "Pending" msgid "Pick a Part of Speech" msgstr "Pick a Part of Speech" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "Picture" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "Pictures" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1649,6 +1690,10 @@ msgstr "Reopen" msgid "Replace audio" msgstr "Replace audio" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "Replace Picture" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1842,6 +1887,7 @@ msgstr "Subject does not have suitable object of type: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Submit" @@ -1985,6 +2031,14 @@ msgstr "This {0} was deleted" msgid "This date # and this emoji # are snippets" msgstr "This date # and this emoji # are snippets" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "This picture is too large to upload. Try reducing the image resolution and uploading again." + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2053,11 +2107,21 @@ msgstr "Type an example sentence" msgid "Type:" msgstr "Type:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "Unable to load image" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Unable to open in FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "Unable to upload the picture" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2153,6 +2217,10 @@ msgstr "Update was disallowed by permission settings." msgid "Updates" msgstr "Updates" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "Uploading pictures is not supported here" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 20bb46208c..1291065ff6 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Cancelar" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Borrar {0}" msgid "Delete Entry" msgstr "Borrar entrada" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Descargando..." msgid "Edit Custom View" msgstr "Editar vista personalizada" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "No veo mi proyecto" msgid "I understand that this can't be undone" msgstr "Entiendo que esto no se puede deshacer" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "No se han encontrado artículos" msgid "No new data" msgstr "No hay nuevos datos" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Fuera de línea" msgid "Offline, unable to download" msgstr "Desconectado, no se puede descargar" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "Pendiente" msgid "Pick a Part of Speech" msgstr "Escoge una parte de la voz" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Vuelva a abrir" msgid "Replace audio" msgstr "Sustituir audio" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "El asunto no tiene un objeto de tipo adecuado: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Enviar" @@ -1990,6 +2036,14 @@ msgstr "Este {0} ha sido eliminado" msgid "This date # and this emoji # are snippets" msgstr "Esta fecha # y este emoji # son fragmentos" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Escriba una frase de ejemplo" msgid "Type:" msgstr "Tipo:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "No se puede abrir en FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "La actualización no fue permitida por la configuración de permisos." msgid "Updates" msgstr "Actualizaciones" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 205ebf064e..6920f6a0f2 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Annuler" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Supprimer {0}" msgid "Delete Entry" msgstr "Supprimer l'entrée" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Téléchargement en cours..." msgid "Edit Custom View" msgstr "Modifier la vue personnalisée" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "Je ne vois pas mon projet" msgid "I understand that this can't be undone" msgstr "Je comprends que cela ne peut pas être annulé" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "Aucun élément trouvé" msgid "No new data" msgstr "Aucune nouvelle donnée" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Déconnecté" msgid "Offline, unable to download" msgstr "Déconnecté, impossible de télécharger" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "Déconnecté, impossible de télécharger l'image" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "En attente" msgid "Pick a Part of Speech" msgstr "Choisir une partie du discours" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Rouvrir" msgid "Replace audio" msgstr "Remplacer l'audio" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "Le sujet n'a pas d'objet de type approprié : {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Soumettre" @@ -1990,6 +2036,14 @@ msgstr "Cet {0} a été supprimé" msgid "This date # and this emoji # are snippets" msgstr "Cette date # et cet emoji # sont des extraits." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Saisir une phrase exemplaire" msgid "Type:" msgstr "Type :" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Impossible d'ouvrir FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "La mise à jour a été désactivée par les paramètres d'autorisation. msgid "Updates" msgstr "Mises à jour" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 7e3085eb37..67fcef2d4b 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Batal" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Hapus {0}" msgid "Delete Entry" msgstr "Hapus Entri" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Mengunduh..." msgid "Edit Custom View" msgstr "Edit Tampilan Khusus" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "Saya tidak melihat proyek saya" msgid "I understand that this can't be undone" msgstr "Saya memahami bahwa hal ini tidak dapat dibatalkan" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "Tidak ada barang yang ditemukan" msgid "No new data" msgstr "Tidak ada data baru" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, tidak dapat mengunduh" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bagian dari Pidato" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Buka kembali" msgid "Replace audio" msgstr "Mengganti audio" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "Subjek tidak memiliki jenis objek yang sesuai: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Kirim" @@ -1990,6 +2036,14 @@ msgstr "{0} ini telah dihapus" msgid "This date # and this emoji # are snippets" msgstr "Tanggal ini # dan emoji ini # adalah cuplikan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Ketik contoh kalimat" msgid "Type:" msgstr "Ketik:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Tidak dapat dibuka di FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "Pembaruan ditolak oleh pengaturan izin." msgid "Updates" msgstr "Pembaruan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 6b92e1006a..7528bdc664 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "취소" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "삭제 {0}" msgid "Delete Entry" msgstr "항목 삭제" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "다운로드 중..." msgid "Edit Custom View" msgstr "사용자 지정 보기 편집" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "내 프로젝트가 보이지 않습니다." msgid "I understand that this can't be undone" msgstr "이 작업은 되돌릴 수 없다는 것을 이해합니다." +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "항목을 찾을 수 없습니다." msgid "No new data" msgstr "새 데이터 없음" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "오프라인" msgid "Offline, unable to download" msgstr "오프라인 상태, 다운로드할 수 없음" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "보류 중" msgid "Pick a Part of Speech" msgstr "품사 선택" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "다시 열기" msgid "Replace audio" msgstr "오디오 교체" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "제목에 적합한 객체 유형이 없습니다: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "제출하기" @@ -1990,6 +2036,14 @@ msgstr "이 {0} 삭제됨" msgid "This date # and this emoji # are snippets" msgstr "이 날짜 #와 이 이모티콘 #은 스니펫입니다." +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "예제 문장을 입력합니다." msgid "Type:" msgstr "유형:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "FieldWorks에서 열 수 없음" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "권한 설정에 의해 업데이트가 허용되지 않았습니다." msgid "Updates" msgstr "업데이트" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 41e2547ffd..f87682e3e9 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Batal" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Hapus {0}" msgid "Delete Entry" msgstr "Padam Entri" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Memuat turun..." msgid "Edit Custom View" msgstr "Sunting Tinjauan Tersuai" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "Saya tidak nampak projek saya" msgid "I understand that this can't be undone" msgstr "Saya faham bahawa ini tidak boleh dibuat asal" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "Tiada item ditemui" msgid "No new data" msgstr "Tiada data baru" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Luar talian" msgid "Offline, unable to download" msgstr "Luar talian, tidak dapat memuat turun" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bahagian Pertuturan" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Buka semula" msgid "Replace audio" msgstr "Ganti audio" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "Subjek tidak mempunyai objek sesuai jenis: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Hantar" @@ -1990,6 +2036,14 @@ msgstr "Item {0} telah dipadam" msgid "This date # and this emoji # are snippets" msgstr "Tarikh ini # dan emoji ini # adalah coretan" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Taip ayat contoh" msgid "Type:" msgstr "Jenis:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Tidak dapat membuka dalam FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "Kemas kini tidak dibenarkan oleh tetapan kebenaran." msgid "Updates" msgstr "Kemas kini" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 2bf6258491..0d53cb65d3 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Ghairi" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Futa {0}" msgid "Delete Entry" msgstr "Futa ingizo" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Inapakua..." msgid "Edit Custom View" msgstr "Hariri Muonekano Binafsi" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "Siioni mradi wangu" msgid "I understand that this can't be undone" msgstr "Ninaelewa kuwa hii haiwezi kurudishwa nyuma" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "Hakuna kitu kilichopatikana" msgid "No new data" msgstr "Hakuna data mpya" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Nyuma ya mtandao" msgid "Offline, unable to download" msgstr "Nyuma ya mtandao, haiwezi kupakua" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "Inasubiri" msgid "Pick a Part of Speech" msgstr "Chagua Sehemu ya Mazungumzo" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Fungua Upya" msgid "Replace audio" msgstr "Badili sauti" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "Kigezo hakina kitu kinachofaa cha aina: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Wasilisha" @@ -1990,6 +2036,14 @@ msgstr "Hili {0} limefutwa" msgid "This date # and this emoji # are snippets" msgstr "Tarehe hii # na emoji hii # ni vigezo" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Andika sentensi ya mfano" msgid "Type:" msgstr "Aina:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Haiwezi kufungua katika FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "Sasisho halikuruhusiwa na mipangilio ya ruhusa." msgid "Updates" msgstr "Masasisho" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index fe3b137341..8eae116ad3 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -341,10 +341,15 @@ msgstr "" #: src/lib/components/field-editors/select.svelte #: src/lib/entry-editor/EditEntryDialog.svelte #: src/lib/entry-editor/NewEntryDialog.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte #: src/lib/views/custom/CustomViewForm.svelte msgid "Cancel" msgstr "Hủy" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Caption" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Changes can also be the result of fields being added to new versions of FieldWorks Lite." @@ -572,6 +577,10 @@ msgstr "Xóa {0}" msgid "Delete Entry" msgstr "Xóa mục" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Delete Picture" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "Delete Entry" #. Menu option: opens confirmation dialog to permanently remove word @@ -686,6 +695,11 @@ msgstr "Đang tải xuống..." msgid "Edit Custom View" msgstr "Chỉnh sửa chế độ xem tùy chỉnh" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Edit Picture" +msgstr "" + #. Role selector option #: src/lib/admin-dialogs/GetProjectByCodeDialog.svelte msgid "Editor" @@ -1039,6 +1053,11 @@ msgstr "Tôi không thấy dự án của mình" msgid "I understand that this can't be undone" msgstr "Tôi hiểu rằng điều này không thể hoàn tác" +#. Error displayed in place of a picture when the media file is missing from local storage +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Image not found" +msgstr "" + #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1374,6 +1393,11 @@ msgstr "Không tìm thấy mục nào" msgid "No new data" msgstr "Không có dữ liệu mới" +#. Empty state shown read-only in the Pictures field when a sense has no pictures +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "No pictures" +msgstr "" + #. Sync status label shown in the sync panel when no server is associated with this project. #: src/project/sync/SyncStatusPrimitive.svelte msgid "No server" @@ -1453,6 +1477,11 @@ msgstr "Ngoại tuyến" msgid "Offline, unable to download" msgstr "Ngoại tuyến, không thể tải xuống" +#. Error when offline and trying to download image +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Offline, unable to download image" +msgstr "" + #. Sort option in activity view — earliest commit first. #: src/lib/activity/ActivityFilter.svelte msgid "Oldest first" @@ -1545,6 +1574,18 @@ msgstr "Đang chờ xử lý" msgid "Pick a Part of Speech" msgstr "Chọn một Loại từ" +#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Picture" +msgstr "" + +#. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) +#: src/lib/views/entity-config.ts +msgid "Pictures" +msgstr "" + #. View option #. Pin the dictionary preview panel above the entry editor form #. Allows quick reference while editing without scrolling @@ -1654,6 +1695,10 @@ msgstr "Mở lại" msgid "Replace audio" msgstr "Thay thế âm thanh" +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +msgid "Replace Picture" +msgstr "" + #. Button text #: src/lib/about/FeedbackDialog.svelte msgid "Report a technical problem" @@ -1847,6 +1892,7 @@ msgstr "Chủ đề không có đối tượng phù hợp loại: {0}" #: src/lib/components/SubmitOrCancel.svelte #: src/lib/components/field-editors/multi-select.svelte #: src/lib/components/field-editors/multi-select.svelte +#: src/lib/entry-editor/field-editors/EditPictureDialog.svelte msgid "Submit" msgstr "Gửi" @@ -1990,6 +2036,14 @@ msgstr "Đường dẫn này {0} đã bị xóa." msgid "This date # and this emoji # are snippets" msgstr "Ngày này # và biểu tượng cảm xúc này # là đoạn trích" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgstr "" + +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." +msgstr "" + #. Help message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." @@ -2058,11 +2112,21 @@ msgstr "Gõ một câu ví dụ" msgid "Type:" msgstr "Loại:" +#. Generic error displayed in place of a picture when loading fails for an unspecified reason +#: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Unable to load image" +msgstr "" + #. Error message #: src/lib/components/OpenInFieldWorksButton.svelte msgid "Unable to open in FieldWorks" msgstr "Không thể mở trong FieldWorks" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Unable to upload the picture" +msgstr "" + #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). #: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityFilter.svelte @@ -2158,6 +2222,10 @@ msgstr "Cập nhật đã bị từ chối do cài đặt quyền truy cập." msgid "Updates" msgstr "Cập nhật" +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte +msgid "Uploading pictures is not supported here" +msgstr "" + #. Field label #: src/lib/views/entity-config.ts msgid "Uses components as" diff --git a/frontend/viewer/src/project/demo/demo-entry-data.ts b/frontend/viewer/src/project/demo/demo-entry-data.ts index 787da7cb05..f64c9c9907 100644 --- a/frontend/viewer/src/project/demo/demo-entry-data.ts +++ b/frontend/viewer/src/project/demo/demo-entry-data.ts @@ -2,6 +2,36 @@ import {type IEntry, type IMorphType, type IWritingSystems, MorphTypeKind, Writi export const projectName = 'Sena 3'; +/** Media URIs for the demo pictures, mapped to inline SVG markup in {@link demoPictureSvgs}. */ +export const demoPictureMediaUris = { + house1: 'demo://picture/house-1', + house2: 'demo://picture/house-2', +} as const; + +/** + * Inline SVG bodies served by the demo api's getFileStream, so the picture carousel has + * something to display in the in-browser demo project (real projects load actual media files). + */ +export const demoPictureSvgs: Record = { + [demoPictureMediaUris.house1]: + '' + + '' + + '' + + '' + + '' + + 'Demo picture 1' + + '', + [demoPictureMediaUris.house2]: + '' + + '' + + '' + + '' + + '' + + '' + + 'Demo picture 2' + + '', +}; + export const partsOfSpeech = [ {id: '86ff66f6-0774-407a-a0dc-3eeaf873daf7', name: {en: 'Verb'}, predefined: true}, {id: 'a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5', name: {en: 'Noun'}, predefined: true}, @@ -301,7 +331,26 @@ export const allWsEntry: IEntry = { partOfSpeech: partsOfSpeech[1], partOfSpeechId: partsOfSpeech[1].id, semanticDomains: [], - pictures: [], + pictures: [ + { + id: 'ce4a3b1e-6d2f-4f1a-8c0d-1f2e3a4b5c6d', + order: 1, + mediaUri: demoPictureMediaUris.house1, + caption: { + en: { spans: [{ text: 'A traditional house', ws: 'en' }] }, + pt: { spans: [{ text: 'Uma casa tradicional', ws: 'pt' }] }, + }, + }, + { + id: 'a1b2c3d4-7e8f-4a0b-9c1d-2e3f4a5b6c7d', + order: 2, + mediaUri: demoPictureMediaUris.house2, + caption: { + en: { spans: [{ text: 'A modern house', ws: 'en' }] }, + pt: { spans: [{ text: 'Uma casa moderna', ws: 'pt' }] }, + }, + }, + ], exampleSentences: [{ id: '00000000-0000-0000-0000-000000000003', senseId: '00000000-0000-0000-0000-000000000002', diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 68f85cce75..90bef54b67 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -13,6 +13,7 @@ import { type IMiniLcmJsInvokable, type IMorphType, type IPartOfSpeech, + type IPicture, type IProjectModel, type IPublication, type IQueryOptions, @@ -26,9 +27,10 @@ import { ViewBase, MorphTypeKind, } from '$lib/dotnet-types'; -import {entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; +import {demoPictureSvgs, entries, morphTypes, partsOfSpeech, projectName, writingSystems} from './demo-entry-data'; import {WritingSystemService} from '../data/writing-system-service.svelte'; +import {randomId} from '$lib/utils'; import {FwLitePlatform} from '$lib/dotnet-types/generated-types/FwLiteShared/FwLitePlatform'; import {delay} from '$lib/utils/time'; import {initProjectContext, type ProjectContext} from '$project/project-context.svelte'; @@ -50,6 +52,9 @@ function pickWs(ws: string, defaultWs: string): string { return ws === 'default' ? defaultWs : ws; } +/** The demo plays the role of the server; it mirrors the backend's 10 MB upload limit. */ +const DEMO_FILE_SIZE_LIMIT = 10 * 1024 * 1024; + const complexFormTypes = entries .flatMap(entry => entry.complexFormTypes) .filter((value, index, all) => all.findIndex(v2 => v2.id === value.id) === index); @@ -380,6 +385,58 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { return Promise.resolve(); } + createPicture(entryGuid: string, senseGuid: string, picture: IPicture): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + // Generated demo senses may omit `pictures` entirely, so default it rather than spread undefined. + sense.pictures = [...(sense.pictures ?? []), picture]; + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(picture); + } + + updatePicture(entryGuid: string, senseGuid: string, _before: IPicture, after: IPicture): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + const index = (sense.pictures ?? []).findIndex(p => p.id === after.id); + if (index === -1) throw new Error(`Picture ${after.id} not found`); + sense.pictures.splice(index, 1, after); + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(after); + } + + deletePicture(entryGuid: string, senseGuid: string, pictureGuid: string): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + sense.pictures = (sense.pictures ?? []).filter(p => p.id !== pictureGuid); + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(); + } + + movePicture(entryGuid: string, senseGuid: string, pictureGuid: string, previousPictureGuid: string, nextPictureGuid: string): Promise { + const entry = this._entries.find(e => e.id === entryGuid); + if (!entry) throw new Error(`Entry ${entryGuid} not found`); + const sense = entry.senses.find(s => s.id === senseGuid); + if (!sense) throw new Error(`Sense ${senseGuid} not found`); + const pictures = [...(sense.pictures ?? [])]; + const from = pictures.findIndex(p => p.id === pictureGuid); + if (from === -1) throw new Error(`Picture ${pictureGuid} not found`); + const [moved] = pictures.splice(from, 1); + // Insert after the previous picture if given, else before the next, else at the end. + const prevIdx = previousPictureGuid ? pictures.findIndex(p => p.id === previousPictureGuid) : -1; + const nextIdx = nextPictureGuid ? pictures.findIndex(p => p.id === nextPictureGuid) : -1; + const insertAt = prevIdx !== -1 ? prevIdx + 1 : nextIdx !== -1 ? nextIdx : pictures.length; + pictures.splice(insertAt, 0, moved); + sense.pictures = pictures; + this.#projectEventBus.notifyEntryUpdated(entry); + return Promise.resolve(); + } + createWritingSystem(_type: WritingSystemType, _writingSystem: IWritingSystem): Promise { throw new Error('Method not implemented.'); } @@ -520,11 +577,56 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { throw new Error('Method not implemented.'); } - getFileStream(_mediaUri: string): Promise { - return Promise.resolve({result: ReadFileResult.NotSupported}); + // Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the + // mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend. + #uploadedFiles = new Map(); + // Maps an already-uploaded filename to its mediaUri, mirroring how the real server dedups by + // filename (LcmMediaService keys by the file name within the project's resource cache). + #uploadedFilenames = new Map(); + + getFileStream(mediaUri: string): Promise { + const uploaded = this.#uploadedFiles.get(mediaUri); + if (uploaded) { + return Promise.resolve({ + result: ReadFileResult.Success, + fileName: mediaUri.split('/').pop() ?? 'demo-upload', + stream: { + stream: () => Promise.resolve(uploaded.stream()), + arrayBuffer: () => uploaded.arrayBuffer(), + }, + }); + } + const svg = demoPictureSvgs[mediaUri]; + if (!svg) return Promise.resolve({result: ReadFileResult.NotFound}); + const blob = new Blob([svg], {type: 'image/svg+xml'}); + return Promise.resolve({ + result: ReadFileResult.Success, + fileName: 'demo-picture.svg', + stream: { + stream: () => Promise.resolve(blob.stream()), + arrayBuffer: () => blob.arrayBuffer(), + }, + }); } - saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise { - return Promise.resolve({result: UploadFileResult.NotSupported}); + saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise { + const blob = streamReference instanceof Blob + ? streamReference + : new Blob([streamReference as BlobPart], {type: metadata.mimeType}); + // The demo stands in for the server, so it enforces the same 10 MB limit the real + // backend does — exercising the client's TooBig handling without a backend. + if (blob.size > DEMO_FILE_SIZE_LIMIT) { + return Promise.resolve({result: UploadFileResult.TooBig}); + } + // Same filename as an earlier upload: report AlreadyExists and hand back the existing + // mediaUri (as the real server does) so the caller can point a new Picture at that file. + const existing = this.#uploadedFilenames.get(metadata.filename); + if (existing) { + return Promise.resolve({result: UploadFileResult.AlreadyExists, mediaUri: existing}); + } + const mediaUri = `demo-upload/${randomId()}`; + this.#uploadedFiles.set(mediaUri, blob); + this.#uploadedFilenames.set(metadata.filename, mediaUri); + return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri}); } } diff --git a/frontend/viewer/tests/sense-pictures.test.ts b/frontend/viewer/tests/sense-pictures.test.ts new file mode 100644 index 0000000000..f7689495c5 --- /dev/null +++ b/frontend/viewer/tests/sense-pictures.test.ts @@ -0,0 +1,216 @@ +import {expect, test, type Page} from '@playwright/test'; +import {BrowsePage} from './browse-page'; + +// A valid 96x96 PNG, used to exercise the upload flow without a real image file. It has real +// dimensions (not 1x1) so the rendered image — and the trash button anchored to its corner — +// occupy a realistic, clickable area. +const TEST_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAIAAABt+uBvAAAAjklEQVR42u3QMQ0AAAgDsKlDGJoQiANOriZV0FQPhygQJEiQIEGCBAlCkCBBggQJEiQIQYIECRIkSJAgBAkSJEiQIEGCBCFIkCBBggQJEoQgQYIECRIkSBCCBAkSJEiQIEGCECRIkCBBggQJQpAgQYIECRIkCEGCBAkSJEiQIEEIEiRIkCBBggQhSJCgPwuoEXMcuO2DAAAAAABJRU5ErkJggg==', + 'base64', +); + +/** + * Verifies the Sense "Pictures" field: + * - a sense with pictures renders the image (loaded via getFileStream into a blob url) + caption + * - a sense without pictures shows the enabled "+ Picture" add button, and uploading a file + * through it adds a picture that then renders + * + * The demo "nyumba" entry (allWsEntry) has two demo pictures on its sense; other demo + * entries (e.g. "ambuka") have none. + */ +test.describe('Sense pictures', () => { + test('displays a picture (and its caption) for a sense that has pictures', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + await browsePage.selectEntryByFilter('nyumba'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + + const image = picturesField.locator('img').first(); + await expect(image).toBeVisible({timeout: 5000}); + // A blob: src proves the full pipeline ran: getFileStream returned a stream that we + // turned into a Blob and an object url. A broken/missing image would not have this. + await expect(image).toHaveAttribute('src', /^blob:/); + + // Caption is rendered from the best analysis alternative. + await expect(picturesField.getByText(/A traditional house|Uma casa tradicional/)).toBeVisible(); + }); + + test('shows an enabled "+ Picture" button for a sense with no pictures', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + // "ambuka" has no pictures, so its Pictures field shows the add button. + await browsePage.selectEntryByFilter('ambuka'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + + const addButton = picturesField.getByRole('button', {name: 'Picture'}); + await expect(addButton).toBeVisible(); + await expect(addButton).toBeEnabled(); + // The add button is the empty state, so no image should be present yet. + await expect(picturesField.locator('img')).toHaveCount(0); + }); + + test('uploading a picture through the "+ Picture" button adds and renders it', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + + await browsePage.selectEntryByFilter('ambuka'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + await expect(picturesField.locator('img')).toHaveCount(0); + + // Clicking the button opens the OS file picker; drive the hidden input directly instead. + await picturesField.locator('input[type="file"]').setInputFiles({ + name: 'photo.png', + mimeType: 'image/png', + buffer: TEST_PNG, + }); + + // The uploaded picture is created and re-loaded via getFileStream into a blob url. + const image = picturesField.locator('img').first(); + await expect(image).toBeVisible({timeout: 5000}); + await expect(image).toHaveAttribute('src', /^blob:/); + }); + + test('re-uploading an existing file adds a second picture that reuses it', async ({page}) => { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + await browsePage.selectEntryByFilter('ambuka'); + + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + const fileInput = picturesField.locator('input[type="file"]'); + + // First upload creates a picture. + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + + // Uploading the same filename again -> server reports AlreadyExists with the existing + // mediaUri; that's not an error here, so a second Picture pointing at the same file is added. + await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); + + // Two pictures now exist (each renders its own image in the flex layout). + await expect(picturesField.locator('img')).toHaveCount(2, {timeout: 5000}); + }); + + /** Uploads one picture to "ambuka" (which starts empty) and returns the pictures-field locator. */ + async function addOnePicture(page: Page) { + const browsePage = new BrowsePage(page); + await browsePage.goto(); + await browsePage.selectEntryByFilter('ambuka'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField).toBeVisible({timeout: 5000}); + await picturesField.locator('input[type="file"]').setInputFiles({ + name: 'photo.png', mimeType: 'image/png', buffer: TEST_PNG, + }); + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + return picturesField; + } + + /** Adds a picture, clicks it to open the edit dialog, and returns [picturesField, dialog]. */ + async function openEditor(page: Page) { + const picturesField = await addOnePicture(page); + await picturesField.getByRole('button', {name: 'Edit Picture'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({timeout: 5000}); + return {picturesField, dialog}; + } + + test('"+ Picture" stays available; clicking a picture opens the edit dialog', async ({page}) => { + const picturesField = await addOnePicture(page); + + // The add button is still present even though a picture now exists... + await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); + // ...and the picture itself is a button that opens the editor (no field-level action buttons). + const editButton = picturesField.getByRole('button', {name: 'Edit Picture'}); + await expect(editButton).toBeVisible(); + await expect(picturesField.getByRole('button', {name: 'Replace Picture'})).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Delete Picture'})).toHaveCount(0); + + // Opening it reveals the caption editor and the Replace/Delete actions inside the dialog. + await editButton.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Caption')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'Replace Picture'})).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'Delete Picture'})).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'Submit'})).toBeVisible(); + + // Submit dismisses the dialog (leaving the picture in place). + await dialog.getByRole('button', {name: 'Submit'}).click(); + await expect(dialog).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Edit Picture'})).toBeVisible(); + }); + + test('Delete Picture (in the dialog) removes the picture after confirmation', async ({page}) => { + const {picturesField, dialog} = await openEditor(page); + + await dialog.getByRole('button', {name: 'Delete Picture'}).click(); + // Confirm in the delete alert dialog (its confirm button is also labelled "Delete Picture"). + await page.getByRole('alertdialog').getByRole('button', {name: 'Delete Picture', exact: true}).click(); + + // Picture is gone and the edit dialog closes; the add button remains. + await expect(picturesField.locator('img')).toHaveCount(0, {timeout: 5000}); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); + }); + + test('Replace, buffered until Submit, swaps the picture in place', async ({page}) => { + const {picturesField, dialog} = await openEditor(page); + const fieldImage = picturesField.locator('img').first(); + const originalSrc = await fieldImage.getAttribute('src'); + + await dialog.getByRole('button', {name: 'Replace Picture'}).click(); + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'replacement.png', mimeType: 'image/png', buffer: TEST_PNG, + }); + + // The dialog previews the replacement, but the field picture is unchanged until Submit. + await expect(dialog.locator('img')).toHaveAttribute('src', /^blob:/); + await expect(fieldImage).toHaveAttribute('src', originalSrc ?? ''); + + await dialog.getByRole('button', {name: 'Submit'}).click(); + + // Now committed: still one picture (replaced, not added), re-loaded into a fresh blob url. + await expect(picturesField.locator('img')).toHaveCount(1); + await expect(fieldImage).toHaveAttribute('src', /^blob:/); + await expect(fieldImage).not.toHaveAttribute('src', originalSrc ?? '', {timeout: 5000}); + }); + + test('editing the caption and pressing Submit shows it under the picture', async ({page}) => { + const {picturesField, dialog} = await openEditor(page); + + // Type into the first writing system's caption editor and commit the field value (blur). + const captionEditor = dialog.locator('[contenteditable="true"]').first(); + await captionEditor.click(); + await captionEditor.pressSequentially('Riverbank'); + await captionEditor.evaluate((el) => (el as HTMLElement).blur()); + + // Buffered: nothing shows under the field picture until Submit. + await expect(picturesField.getByText('Riverbank')).toHaveCount(0); + + await dialog.getByRole('button', {name: 'Submit'}).click(); + await expect(picturesField.getByText('Riverbank')).toBeVisible({timeout: 5000}); + }); + + test('Cancel discards caption edits', async ({page}) => { + const {picturesField, dialog} = await openEditor(page); + + const captionEditor = dialog.locator('[contenteditable="true"]').first(); + await captionEditor.click(); + await captionEditor.pressSequentially('Discarded'); + await captionEditor.evaluate((el) => (el as HTMLElement).blur()); + + await dialog.getByRole('button', {name: 'Cancel'}).click(); + await expect(dialog).toHaveCount(0); + + // The edit never reached the model, so it isn't shown under the picture. + await expect(picturesField.getByText('Discarded')).toHaveCount(0); + }); +});