diff --git a/.changeset/solid-keys-matter.md b/.changeset/solid-keys-matter.md new file mode 100644 index 000000000..e9e4db062 --- /dev/null +++ b/.changeset/solid-keys-matter.md @@ -0,0 +1,6 @@ +--- +'@getodk/xforms-engine': minor +'@getodk/web-forms': minor +--- + +Adds translation support. diff --git a/.tx/config b/.tx/config new file mode 100755 index 000000000..4b61b8edd --- /dev/null +++ b/.tx/config @@ -0,0 +1,9 @@ +[main] +host = https://app.transifex.com + +[o:getodk:p:web_forms:r:strings] +file_filter = packages/web-forms/locales/strings_.json +source_file = packages/web-forms/locales/strings_en.json +source_lang = en +type = STRUCTURED_JSON +minimum_perc = 0 diff --git a/README.md b/README.md index 860754759..be5019175 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,12 @@ We will be adding color and more styling soon. We intend to expose a way to do b Thank you for contributing! Follow these guidelines for smooth collaboration. +### Translations + +Translations are managed on Transifex. Translators can contribute at: https://app.transifex.com/getodk/web_forms + +For developers, see [TRANSLATIONS.md](./packages/web-forms/TRANSLATIONS.md) for details on how UI strings are managed and how to add or update translations. + ### Requirements - [Volta](https://volta.sh/) to ensure consistent `node` and `yarn` versions. @@ -510,9 +516,10 @@ If you'd like to try the functionality available on `main`, see the preview [on 1. Run `yarn changeset version` to generate changelog files and version bumps from the changeset files. 2. Run `yarn install` to update `yarn.lock` with the new versions. -3. Verify that the changelogs look good, commit changes, open a PR, and merge the PR. -4. Push tags for each package in the format `@getodk/@x.x.x`. A GitHub action will publish the packages on NPM. -5. Update dependencies to kick off the new release cycle. +3. Update translations by running `yarn translations:pull` in the root directory. +4. Verify that the changelogs look good, commit changes, open a PR, and merge the PR. +5. Push tags for each package in the format `@getodk/@x.x.x`. A GitHub action will publish the packages on NPM. +6. Update dependencies to kick off the new release cycle. ### Patch release process diff --git a/eslint.config.js b/eslint.config.js index 69c5ebdd5..b7b234360 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,6 +59,7 @@ export default defineConfig( 'packages/tree-sitter-xpath/bindings/**/*', 'packages/tree-sitter-xpath/types/**/*', 'packages/web-forms/dist-demo/**/*', + 'packages/web-forms/scripts/**/*', 'packages/xforms-engine/api-docs/**/*', '**/vendor', ], diff --git a/package.json b/package.json index a2e20fc67..0ec80b1a6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "format:readme-only": "prettier -w README.md", "format:checkonly": "prettier -c \"**/*\" --ignore-unknown", "lint": "eslint . --report-unused-disable-directives --cache", - "feature-matrix": "node scripts/feature-matrix/render.js" + "feature-matrix": "node scripts/feature-matrix/render.js", + "translations:pull": "tx pull -a -f --mode translator" }, "dependenciesMeta": { "tree-sitter": { @@ -81,5 +82,8 @@ "resolutions": { "tree-sitter": "0.22.1", "@asgerf/dts-tree-sitter/tree-sitter": "0.22.1" + }, + "devDependencies": { + "@transifex/cli": "^7.1.5" } } diff --git a/packages/common/src/fixtures/date-and-time/date-and-time.xml b/packages/common/src/fixtures/date-and-time/date-and-time.xml index aa9509975..86546e650 100644 --- a/packages/common/src/fixtures/date-and-time/date-and-time.xml +++ b/packages/common/src/fixtures/date-and-time/date-and-time.xml @@ -41,6 +41,23 @@ À quelle heure prends-tu ton premier repas ? + + + ¿Cuando está completando este formulario? + + + ¿Cuál es su fecha de nacimiento? + + + ¿Cuando fué la ultima vez que comió frutas? + + + ¿Cuando fué la ultima vez que comió verduras? + + + ¿A que hora es su primera comida? + + diff --git a/packages/scenario/src/assertion/extensions/answers.ts b/packages/scenario/src/assertion/extensions/answers.ts index 15a13c9dc..569371e83 100644 --- a/packages/scenario/src/assertion/extensions/answers.ts +++ b/packages/scenario/src/assertion/extensions/answers.ts @@ -1,6 +1,6 @@ import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts'; -import { constants, type ValidationCondition } from '@getodk/xforms-engine'; +import { type ValidationCondition } from '@getodk/xforms-engine'; import { assert, expect } from 'vitest'; import { ComparableAnswer } from '../../answer/ComparableAnswer.ts'; import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts'; @@ -40,24 +40,16 @@ const assertAnswerResult: AssertAnswerResult = (value) => { }; const matchDefaultMessage = (condition: ValidationCondition) => { - const expectedMessage = constants.VALIDATION_TEXT[`${condition}Msg`]; - return { node: { validationState: { [condition]: { valid: false, - message: { - origin: 'engine', - asString: expectedMessage, - }, + message: null, }, violation: { condition, - message: { - origin: 'engine', - asString: expectedMessage, - }, + message: null, }, }, }, diff --git a/packages/web-forms/TRANSLATIONS.md b/packages/web-forms/TRANSLATIONS.md new file mode 100644 index 000000000..cd613649a --- /dev/null +++ b/packages/web-forms/TRANSLATIONS.md @@ -0,0 +1,58 @@ +# Translations + +## How it works + +Translation strings are defined in `.i18n.json` files co-located with their components. At build time, these files are merged into `locales/strings_en.json`, which serves as the English baseline and is eagerly loaded at runtime. Translations for other locales are loaded lazily when the user switches language. + +Transifex automatically pulls the source strings from `locales/strings_en.json` daily. Translation files are synced back into the project just before each release. + +## Key naming convention + +Keys follow a 3-part dot-separated pattern: + +``` +component.feature.type +``` + +- **component**: camelCase name of the Vue component that owns the string (e.g. `odk_web_forms`) +- **feature**: the feature or section within that component (e.g. `submit`, `validation`) +- **type**: what kind of string it is. Use one of: + - `label`: button or field label + - `title`: heading or title + - `placeholder`: input placeholder + - `message`: informational message + - `error`: error message + +An easy way to remember: **who → where → kind** (which component? which feature? what type of string?). + +### Examples + +| Key (snake_case) | Description | +| -------------------------------- | ----------------------- | +| `odk_web_forms.submit.label` | Submit button label | +| `odk_web_forms.validation.error` | Validation error banner | + +## Adding a string + +1. Create or open the `.i18n.json` file next to the component. +2. Add an entry using the `component.feature.type` key convention (snake_case): + +```json +{ + "my_component.some_feature.label": { + "string": "English text here", + "developer_comment": "Context for translators: when and where this string appears." + } +} +``` + +3. Run `build:translations` to regenerate `locales/strings_en.json`. This also runs automatically as part of the build. +4. Use the string in the component via `t('my_component.some_feature.label')`. + +## Developer comments + +The `developer_comment` optional field is for translators. Explain: + +- Where the string appears in the UI +- Any placeholders (e.g. `{count}` is the number of violations) +- Any constraints (e.g. keep it short, it appears on a button) diff --git a/packages/web-forms/locales/README.md b/packages/web-forms/locales/README.md new file mode 100644 index 000000000..9258f30a5 --- /dev/null +++ b/packages/web-forms/locales/README.md @@ -0,0 +1,4 @@ +# Locales + +**Do not edit these files directly.** The `strings_en.json` file is auto-generated by `scripts/merge-translations.js`, and the other `strings_[locale].json` files are pulled from the Transifex project. +The source strings are defined in `.i18n.json` files. diff --git a/packages/web-forms/locales/strings_en.json b/packages/web-forms/locales/strings_en.json new file mode 100644 index 000000000..3f4c31ad6 --- /dev/null +++ b/packages/web-forms/locales/strings_en.json @@ -0,0 +1,422 @@ +{ + "form_load_failure_dialog.header.title": { + "string": "An error occurred while loading this form", + "developer_comment": "The title text shown in the header of the dialog." + }, + "form_load_failure_dialog.details.label": { + "string": "Technical error details", + "developer_comment": "The label for the clickable summary that reveals technical error information." + }, + "odk_web_forms.submit.label": { + "string": "Send", + "developer_comment": "Label for the primary form submission button." + }, + "odk_web_forms.validation.error": { + "string": "{count, plural, one {# question with error} other {# questions with errors}}", + "developer_comment": "Error banner message. {count} is the number of validation violations." + }, + "odk_web_forms.geolocation.error": { + "string": "Location unavailable. Enable GPS and browser permissions, then restart the form to try again.", + "developer_comment": "Error message shown when background geolocation fails." + }, + "odk_web_forms.delete.label": { + "string": "Delete", + "developer_comment": "Generic label for a delete action button." + }, + "odk_web_forms.cancel.label": { + "string": "Cancel", + "developer_comment": "Generic label for a cancel action button." + }, + "odk_web_forms.remove.label": { + "string": "Remove", + "developer_comment": "Generic label for a remove action button." + }, + "odk_web_forms.save.label": { + "string": "Save", + "developer_comment": "Generic label for a save action button." + }, + "geolocation_formatted_value.accuracy.label": { + "string": "Accuracy: {accuracy} m,", + "developer_comment": "Displays the GPS accuracy in metres. {accuracy} is the numeric accuracy value." + }, + "geolocation_formatted_value.latitude.label": { + "string": "Latitude: {latitude},", + "developer_comment": "Displays the GPS latitude coordinate. {latitude} is the numeric latitude value." + }, + "geolocation_formatted_value.longitude.label": { + "string": "Longitude: {longitude}.", + "developer_comment": "Displays the GPS longitude coordinate. {longitude} is the numeric longitude value." + }, + "validation_message.required.error": { + "string": "This field is required.", + "developer_comment": "Default error shown when a required field has no value and the form designer did not specify a custom message." + }, + "validation_message.constraint.error": { + "string": "This value doesn't meet the constraint.", + "developer_comment": "Default error shown when a field's value fails a constraint and the form designer did not specify a custom message." + }, + "map_async.load_error.message": { + "string": "Unable to load map", + "developer_comment": "Error message shown when the map component fails to load." + }, + "map_block.graphics_error.title": { + "string": "Graphics issue detected", + "developer_comment": "Title of the error shown when the browser cannot render the map due to WebGL being unavailable." + }, + "map_block.graphics_error.message": { + "string": "Your browser cannot display the map now. Enable graphics acceleration settings.", + "developer_comment": "Description of the WebGL error, instructing the user to enable hardware acceleration in their browser." + }, + "map_block.location_error.title": { + "string": "Cannot access location", + "developer_comment": "Title of the error shown in the map when the browser denies geolocation permission." + }, + "map_block.location_error.message": { + "string": "Grant location permission in the browser settings and make sure location is turned on.", + "developer_comment": "Description of what the user should do to resolve a geolocation access error in the map." + }, + "map_block.get_location.label": { + "string": "Get location", + "developer_comment": "Label for the button in the map overlay that starts watching the device's GPS location." + }, + "map_block.drag_vertex_instruction.placed": { + "string": "Press and drag to move a point", + "developer_comment": "Instruction shown on the map when a point has been placed and the user can drag points and vertices." + }, + "map_block.drag_vertex_instruction.default": { + "string": "Tap to place a point", + "developer_comment": "Instruction shown on the map before a point is placed, when the user can drag points and vertices." + }, + "map_block.drag_instruction.placed": { + "string": "Tap to move point", + "developer_comment": "Instruction shown on the map when a point has been placed and the user can drag the feature." + }, + "map_block.drag_instruction.default": { + "string": "Use the location button to center on your current location", + "developer_comment": "Instruction shown on the map before a point is placed, when the user can drag the feature." + }, + "map_confirm_dialog.delete_shape.header": { + "string": "Delete entire shape?", + "developer_comment": "Title of the confirmation dialog shown before deleting a shape." + }, + "map_confirm_dialog.delete_trace.header": { + "string": "Delete entire trace?", + "developer_comment": "Title of the confirmation dialog shown before deleting a trace." + }, + "map_confirm_dialog.delete_shape.body": { + "string": "Are you sure you want to delete this entire shape and start over?", + "developer_comment": "Body text of the confirmation dialog shown before deleting a shape." + }, + "map_confirm_dialog.delete_trace.body": { + "string": "Are you sure you want to delete this entire trace and start over?", + "developer_comment": "Body text of the confirmation dialog shown before deleting a trace." + }, + "map_advanced_panel.longitude.label": { + "string": "Longitude", + "developer_comment": "Label for the longitude input field in the advanced panel." + }, + "map_advanced_panel.longitude.error": { + "string": "Longitude is invalid", + "developer_comment": "Validation error shown when the longitude value entered is out of range." + }, + "map_advanced_panel.latitude.label": { + "string": "Latitude", + "developer_comment": "Label for the latitude input field in the advanced panel." + }, + "map_advanced_panel.latitude.error": { + "string": "Latitude is invalid", + "developer_comment": "Validation error shown when the latitude value entered is out of range." + }, + "map_advanced_panel.altitude.label": { + "string": "Altitude", + "developer_comment": "Label for the altitude input field in the advanced panel." + }, + "map_advanced_panel.accuracy.label": { + "string": "Accuracy", + "developer_comment": "Label for the accuracy input field in the advanced panel." + }, + "map_advanced_panel.import_data.label": { + "string": "Import data to replace location", + "developer_comment": "Label for the link that opens the import/paste dialog from the advanced panel." + }, + "map_controls.zoom_fit_all.description": { + "string": "Show all features on the map", + "developer_comment": "Aria-label for the map control button that zooms to fit all features in view." + }, + "map_controls.current_location.description": { + "string": "Find your location", + "developer_comment": "Aria-label for the map control button that centers the map on the device's current GPS location." + }, + "map_controls.undo.description": { + "string": "Undo last action", + "developer_comment": "Aria-label for the map control button that undoes the last map edit." + }, + "map_controls.delete.description": { + "string": "Delete one vertex or all vertices", + "developer_comment": "Aria-label for the map control button that deletes the selected vertex or all vertices." + }, + "map_controls.open_advanced.description": { + "string": "Advanced manual edits", + "developer_comment": "Aria-label for the map control button that opens the advanced coordinate editing panel." + }, + "map_controls.open_full_screen.description": { + "string": "Expand to full screen", + "developer_comment": "Aria-label for the map control button that expands the map to full screen." + }, + "map_controls.close_full_screen.description": { + "string": "Exit full screen", + "developer_comment": "Aria-label for the map control button that exits full-screen mode." + }, + "map_controls.open_info.description": { + "string": "Show information of map actions", + "developer_comment": "Aria-label for the map control button that opens the info dialog listing available map actions." + }, + "map_info_dialog.header.title": { + "string": "How to use the map?", + "developer_comment": "Title shown in the header of the dialog that explains available map actions." + }, + "map_properties.remove_selection.label": { + "string": "Remove selection", + "developer_comment": "Label for the button that removes the currently saved feature selection." + }, + "map_properties.save_selected.label": { + "string": "Save selected", + "developer_comment": "Label for the button that saves the currently selected map feature." + }, + "map_status_bar.no_trace_saved.label": { + "string": "No trace saved", + "developer_comment": "Status bar message shown when no trace (linestring) has been saved yet." + }, + "map_status_bar.no_shape_saved.label": { + "string": "No shape saved", + "developer_comment": "Status bar message shown when no shape (polygon) has been saved yet." + }, + "map_status_bar.no_point_saved.label": { + "string": "No point saved", + "developer_comment": "Status bar message shown when no point has been saved yet." + }, + "map_status_bar.point_saved.label": { + "string": "Point saved", + "developer_comment": "Status bar message shown when a point has been saved." + }, + "map_status_bar.trace_saved.label": { + "string": "Trace saved", + "developer_comment": "Status bar message shown when a trace (linestring) has been saved." + }, + "map_status_bar.shape_saved.label": { + "string": "Shape saved", + "developer_comment": "Status bar message shown when a shape (polygon) has been saved." + }, + "map_status_bar.feature_saved.label": { + "string": "Feature saved", + "developer_comment": "Fallback status bar message shown when a feature of an unrecognised geometry type has been saved." + }, + "map_status_bar.points_saved.label": { + "string": "{count} points saved", + "developer_comment": "Status bar message showing how many points have been saved. {count} is the number of saved points." + }, + "map_status_bar.capturing.label": { + "string": "Capturing location...", + "developer_comment": "Status bar message shown while the device is actively capturing a GPS location." + }, + "map_status_bar.remove_point.label": { + "string": "Remove point", + "developer_comment": "Full label for the remove button shown on larger screens in the map status bar." + }, + "map_status_bar.view_details.label": { + "string": "View details", + "developer_comment": "Label for the button that opens the feature properties panel." + }, + "map_status_bar.advanced.label": { + "string": "Advanced", + "developer_comment": "Label for the button that opens the advanced coordinate editing panel." + }, + "map_status_bar.vertex_longitude.label": { + "string": "Longitude: {longitude}", + "developer_comment": "Longitude component of the selected vertex info shown in the map status bar. {longitude} is the numeric longitude value." + }, + "map_status_bar.vertex_latitude.label": { + "string": "Latitude: {latitude}", + "developer_comment": "Latitude component of the selected vertex info shown in the map status bar. {latitude} is the numeric latitude value." + }, + "map_status_bar.vertex_altitude.label": { + "string": "Altitude: {altitude} m", + "developer_comment": "Altitude component of the selected vertex info shown in the map status bar. {altitude} is the numeric altitude value in metres." + }, + "map_status_bar.vertex_accuracy.label": { + "string": "Accuracy: {accuracy}", + "developer_comment": "Accuracy component of the selected vertex info shown in the map status bar. {accuracy} is the pre-formatted accuracy value." + }, + "map_update_coords_dialog.header.title": { + "string": "Import data to replace location", + "developer_comment": "Title shown in the header of the dialog for importing coordinates via paste or file upload." + }, + "map_update_coords_dialog.paste_data.label": { + "string": "Paste data in ODK format", + "developer_comment": "Label for the paste input field in the import coordinates dialog." + }, + "map_update_coords_dialog.paste_data.hint": { + "string": "Enter coordinates as: Lat Long Altitude Accuracy. Separate multiple points with a semicolon (;).", + "developer_comment": "Helper text explaining the expected format for pasted coordinate data." + }, + "map_update_coords_dialog.upload_file.label": { + "string": "Upload a GeoJSON file", + "developer_comment": "Label for the file upload section in the import coordinates dialog." + }, + "map_update_coords_dialog.upload_file.action": { + "string": "Upload file", + "developer_comment": "Label for the button that opens the file chooser to upload a GeoJSON file." + }, + "map_update_coords_dialog.file_empty.error": { + "string": "File is empty.", + "developer_comment": "Error shown when the uploaded file contains no content." + }, + "map_update_coords_dialog.unsupported_file.error": { + "string": "Unsupported file type. Please upload a .geojson file.", + "developer_comment": "Error shown when the uploaded file is not a supported GeoJSON format." + }, + "map_update_coords_dialog.parse_failed.error": { + "string": "Failed to parse file. Ensure it is a valid GeoJSON.", + "developer_comment": "Error shown when the uploaded file could not be parsed as valid GeoJSON." + }, + "map_update_coords_dialog.incorrect_geometry.error": { + "string": "Incorrect geometry type.", + "developer_comment": "Error shown when the imported data contains a geometry type incompatible with the current field." + }, + "media_block.fetch.error": { + "string": "Cannot fetch media. Verify the URL and fetch settings.", + "developer_comment": "Displayed when media (image, audio or video) fetching fails." + }, + "media_block.not_found.error": { + "string": "Media not found. File: {file}", + "developer_comment": "Displayed when the server returns a 404 or non-200 status." + }, + "media_block.unknown.error": { + "string": "Cannot fetch media. Unknown error. File: {file}", + "developer_comment": "Generic fallback for network failures or unexpected exceptions." + }, + "video_block.load.error": { + "string": "Failed to load video. File: {file}", + "developer_comment": "Displayed when a video element fires a load error. {file} is the resource URL." + }, + "audio_block.load.error": { + "string": "Failed to load audio. File: {file}", + "developer_comment": "Displayed when an audio element fires a load error. {file} is the resource URL." + }, + "image_block.load.error": { + "string": "Failed to load image. File: {file}", + "developer_comment": "Displayed when an image element fires a load error. {file} is the resource URL." + }, + "rank_control.open.label": { + "string": "Rank items", + "developer_comment": "The label for the button that opens the rank control." + }, + "trigger_control.okay.label": { + "string": "Okay", + "developer_comment": "The label for the 'Okay' button." + }, + "input_geopoint.get_location.label": { + "string": "Get location", + "developer_comment": "Label for the button to start capturing the device's current GPS location." + }, + "input_geopoint.try_again.label": { + "string": "Try again", + "developer_comment": "Label for the button to retry capturing the GPS location after one has already been saved." + }, + "input_geopoint.location_error.title": { + "string": "Cannot access location", + "developer_comment": "Title of the error message shown when the browser cannot access the device's location." + }, + "input_geopoint.location_error.description": { + "string": "Grant location permission in the browser settings and make sure location is turned on.", + "developer_comment": "Description of what the user should do to resolve a location access error." + }, + "geolocation_dialog.header.title": { + "string": "Finding your location", + "developer_comment": "Title shown in the header of the dialog while the device is searching for a GPS fix." + }, + "geolocation_dialog.accuracy_threshold.info": { + "string": "Location will be saved at {accuracyThreshold} m", + "developer_comment": "Informational message shown while waiting for a GPS fix. {accuracyThreshold} is the accuracy threshold in metres at which the location will be automatically saved." + }, + "geolocation_dialog.elapsed_time.label": { + "string": "Time taken to capture location:", + "developer_comment": "Label preceding the elapsed time counter shown while waiting for a GPS fix." + }, + "geolocation_dialog.previous_accuracy.info": { + "string": "Previous saved location at {accuracy} m", + "developer_comment": "Shows the accuracy of the previously saved location in metres. {accuracy} is the numeric accuracy value." + }, + "geolocation_dialog.save.label": { + "string": "Save location", + "developer_comment": "Label for the button to save the currently captured GPS location." + }, + "geopoint_accuracy.good.label": { + "string": "Good accuracy", + "developer_comment": "Label shown when the GPS accuracy is within the good threshold." + }, + "geopoint_accuracy.acceptable.label": { + "string": "Good accuracy", + "developer_comment": "Label shown when the GPS accuracy is within the acceptable threshold (between good and unacceptable)." + }, + "geopoint_accuracy.poor.label": { + "string": "Poor accuracy", + "developer_comment": "Label shown when the GPS accuracy does not meet the acceptable threshold." + }, + "geopoint_accuracy.unknown.label": { + "string": "Unknown accuracy", + "developer_comment": "Label shown when no GPS accuracy value is available yet." + }, + "upload_image_header.take_picture.label": { + "string": "Take picture", + "developer_comment": "Label for the button to take a picture using the device's camera." + }, + "upload_image_header.choose_image.label": { + "string": "Choose image", + "developer_comment": "Label for the button to choose an image from the device's gallery or file system." + }, + "upload_control.choose_file.label": { + "string": "Choose file", + "developer_comment": "Label for the button to choose a file from the device. Used in the audio, video, and generic file upload headers." + }, + "upload_control.drag_and_drop.placeholder": { + "string": "Drag and drop files here to upload", + "developer_comment": "Placeholder text shown in the upload area when no file has been selected." + }, + "upload_control.file_too_large.error": { + "string": "Selected file size exceeds the maximum allowed", + "developer_comment": "Error message shown when the selected file exceeds the maximum allowed size." + }, + "upload_control.must_be_image.error": { + "string": "Selected file must be an image file", + "developer_comment": "Error message shown when the selected file is not an image." + }, + "upload_control.must_be_video.error": { + "string": "Selected file must be a video file", + "developer_comment": "Error message shown when the selected file is not a video." + }, + "upload_control.must_be_audio.error": { + "string": "Selected file must be an audio file", + "developer_comment": "Error message shown when the selected file is not an audio file." + }, + "upload_control.invalid_file_type.error": { + "string": "Selected file does not match expected type", + "developer_comment": "Error message shown when the selected file does not match the expected media type." + }, + "upload_delete_dialog.header.title": { + "string": "Delete uploaded file?", + "developer_comment": "Title of the confirmation dialog shown before deleting an uploaded file." + }, + "upload_delete_dialog.body.message": { + "string": "Are you sure you want to delete this file?", + "developer_comment": "Body text of the confirmation dialog shown before deleting an uploaded file." + }, + "repeat.add.label": { + "string": "Add", + "developer_comment": "Label for the button to add a repeat instance." + }, + "repeat.instance.placeholder": { + "string": "Repeat Item", + "developer_comment": "Placeholder for the label of a repeat instance." + } +} diff --git a/packages/web-forms/locales/strings_es.json b/packages/web-forms/locales/strings_es.json new file mode 100644 index 000000000..b8db06907 --- /dev/null +++ b/packages/web-forms/locales/strings_es.json @@ -0,0 +1,422 @@ +{ + "form_load_failure_dialog.header.title": { + "string": "Ocurrió un error al cargar este formulario", + "developer_comment": "The title text shown in the header of the dialog." + }, + "form_load_failure_dialog.details.label": { + "string": "Detalles técnicos del error", + "developer_comment": "The label for the clickable summary that reveals technical error information." + }, + "odk_web_forms.submit.label": { + "string": "Enviar", + "developer_comment": "Label for the primary form submission button." + }, + "odk_web_forms.validation.error": { + "string": "{count, plural, one {# pregunta con error} many {# preguntas con errores} other {# preguntas con errores}}", + "developer_comment": "Error banner message. {count} is the number of validation violations." + }, + "odk_web_forms.geolocation.error": { + "string": "Ubicación no disponible. Active el GPS y los permisos del navegador, luego reinicie el formulario para intentarlo de nuevo.", + "developer_comment": "Error message shown when background geolocation fails." + }, + "odk_web_forms.delete.label": { + "string": "Eliminar", + "developer_comment": "Generic label for a delete action button." + }, + "odk_web_forms.cancel.label": { + "string": "Cancelar", + "developer_comment": "Generic label for a cancel action button." + }, + "odk_web_forms.remove.label": { + "string": "Quitar", + "developer_comment": "Generic label for a remove action button." + }, + "odk_web_forms.save.label": { + "string": "Guardar", + "developer_comment": "Generic label for a save action button." + }, + "geolocation_formatted_value.accuracy.label": { + "string": "Precisión: {accuracy} m,", + "developer_comment": "Displays the GPS accuracy in metres. {accuracy} is the numeric accuracy value." + }, + "geolocation_formatted_value.latitude.label": { + "string": "Latitud: {latitude},", + "developer_comment": "Displays the GPS latitude coordinate. {latitude} is the numeric latitude value." + }, + "geolocation_formatted_value.longitude.label": { + "string": "Longitud: {longitude}.", + "developer_comment": "Displays the GPS longitude coordinate. {longitude} is the numeric longitude value." + }, + "validation_message.required.error": { + "string": "Este campo es obligatorio.", + "developer_comment": "Default error shown when a required field has no value and the form designer did not specify a custom message." + }, + "validation_message.constraint.error": { + "string": "No cumple los requisitos.", + "developer_comment": "Default error shown when a field's value fails a constraint and the form designer did not specify a custom message." + }, + "map_async.load_error.message": { + "string": "No se puede cargar el mapa", + "developer_comment": "Error message shown when the map component fails to load." + }, + "map_block.graphics_error.title": { + "string": "Se detectó un problema de gráficos", + "developer_comment": "Title of the error shown when the browser cannot render the map due to WebGL being unavailable." + }, + "map_block.graphics_error.message": { + "string": "Su navegador no puede mostrar el mapa en este momento. Active la configuración de aceleración de gráficos.", + "developer_comment": "Description of the WebGL error, instructing the user to enable hardware acceleration in their browser." + }, + "map_block.location_error.title": { + "string": "No se puede acceder a la ubicación", + "developer_comment": "Title of the error shown in the map when the browser denies geolocation permission." + }, + "map_block.location_error.message": { + "string": "Otorgue permisos de ubicación en la configuración del navegador y asegúrese de que la ubicación esté activada.", + "developer_comment": "Description of what the user should do to resolve a geolocation access error in the map." + }, + "map_block.get_location.label": { + "string": "Obtener ubicación", + "developer_comment": "Label for the button in the map overlay that starts watching the device's GPS location." + }, + "map_block.drag_vertex_instruction.placed": { + "string": "Presione y arrastre para mover un punto", + "developer_comment": "Instruction shown on the map when a point has been placed and the user can drag points and vertices." + }, + "map_block.drag_vertex_instruction.default": { + "string": "Presione para colocar un punto", + "developer_comment": "Instruction shown on the map before a point is placed, when the user can drag points and vertices." + }, + "map_block.drag_instruction.placed": { + "string": "Presione para mover el punto", + "developer_comment": "Instruction shown on the map when a point has been placed and the user can drag the feature." + }, + "map_block.drag_instruction.default": { + "string": "Use el botón de ubicación para centrar el mapa en su posición actual", + "developer_comment": "Instruction shown on the map before a point is placed, when the user can drag the feature." + }, + "map_confirm_dialog.delete_shape.header": { + "string": "¿Eliminar todo el polígono?", + "developer_comment": "Title of the confirmation dialog shown before deleting a shape." + }, + "map_confirm_dialog.delete_trace.header": { + "string": "¿Eliminar toda la linea?", + "developer_comment": "Title of the confirmation dialog shown before deleting a trace." + }, + "map_confirm_dialog.delete_shape.body": { + "string": "¿Está seguro de que desea eliminar todo el polígono y comenzar de nuevo?", + "developer_comment": "Body text of the confirmation dialog shown before deleting a shape." + }, + "map_confirm_dialog.delete_trace.body": { + "string": "¿Está seguro de que desea eliminar toda la linea y comenzar de nuevo?", + "developer_comment": "Body text of the confirmation dialog shown before deleting a trace." + }, + "map_advanced_panel.longitude.label": { + "string": "Longitud", + "developer_comment": "Label for the longitude input field in the advanced panel." + }, + "map_advanced_panel.longitude.error": { + "string": "La longitud no es válida", + "developer_comment": "Validation error shown when the longitude value entered is out of range." + }, + "map_advanced_panel.latitude.label": { + "string": "Latitud", + "developer_comment": "Label for the latitude input field in the advanced panel." + }, + "map_advanced_panel.latitude.error": { + "string": "La latitud no es válida", + "developer_comment": "Validation error shown when the latitude value entered is out of range." + }, + "map_advanced_panel.altitude.label": { + "string": "Altitud", + "developer_comment": "Label for the altitude input field in the advanced panel." + }, + "map_advanced_panel.accuracy.label": { + "string": "Precisión", + "developer_comment": "Label for the accuracy input field in the advanced panel." + }, + "map_advanced_panel.import_data.label": { + "string": "Importar datos para reemplazar ubicación", + "developer_comment": "Label for the link that opens the import/paste dialog from the advanced panel." + }, + "map_controls.zoom_fit_all.description": { + "string": "Mostrar todos los elementos en el mapa", + "developer_comment": "Aria-label for the map control button that zooms to fit all features in view." + }, + "map_controls.current_location.description": { + "string": "Encuentre su ubicación", + "developer_comment": "Aria-label for the map control button that centers the map on the device's current GPS location." + }, + "map_controls.undo.description": { + "string": "Deshacer última acción", + "developer_comment": "Aria-label for the map control button that undoes the last map edit." + }, + "map_controls.delete.description": { + "string": "Eliminar un vértice o todos los vértices", + "developer_comment": "Aria-label for the map control button that deletes the selected vertex or all vertices." + }, + "map_controls.open_advanced.description": { + "string": "Ediciones avanzadas manuales", + "developer_comment": "Aria-label for the map control button that opens the advanced coordinate editing panel." + }, + "map_controls.open_full_screen.description": { + "string": "Expandir a pantalla completa", + "developer_comment": "Aria-label for the map control button that expands the map to full screen." + }, + "map_controls.close_full_screen.description": { + "string": "Salir de pantalla completa", + "developer_comment": "Aria-label for the map control button that exits full-screen mode." + }, + "map_controls.open_info.description": { + "string": "Mostrar información sobre las acciones del mapa", + "developer_comment": "Aria-label for the map control button that opens the info dialog listing available map actions." + }, + "map_info_dialog.header.title": { + "string": "¿Cómo usar el mapa?", + "developer_comment": "Title shown in the header of the dialog that explains available map actions." + }, + "map_properties.remove_selection.label": { + "string": "Quitar selección", + "developer_comment": "Label for the button that removes the currently saved feature selection." + }, + "map_properties.save_selected.label": { + "string": "Guardar selección", + "developer_comment": "Label for the button that saves the currently selected map feature." + }, + "map_status_bar.no_trace_saved.label": { + "string": "Ninguna linea guardada", + "developer_comment": "Status bar message shown when no trace (linestring) has been saved yet." + }, + "map_status_bar.no_shape_saved.label": { + "string": "Ningún polígono guardado", + "developer_comment": "Status bar message shown when no shape (polygon) has been saved yet." + }, + "map_status_bar.no_point_saved.label": { + "string": "Ningún punto guardado", + "developer_comment": "Status bar message shown when no point has been saved yet." + }, + "map_status_bar.point_saved.label": { + "string": "Punto guardado", + "developer_comment": "Status bar message shown when a point has been saved." + }, + "map_status_bar.trace_saved.label": { + "string": "Linea guardada", + "developer_comment": "Status bar message shown when a trace (linestring) has been saved." + }, + "map_status_bar.shape_saved.label": { + "string": "Polígono guardado", + "developer_comment": "Status bar message shown when a shape (polygon) has been saved." + }, + "map_status_bar.feature_saved.label": { + "string": "Elemento guardado", + "developer_comment": "Fallback status bar message shown when a feature of an unrecognised geometry type has been saved." + }, + "map_status_bar.points_saved.label": { + "string": "{count} puntos guardados", + "developer_comment": "Status bar message showing how many points have been saved. {count} is the number of saved points." + }, + "map_status_bar.capturing.label": { + "string": "Obteniendo ubicación...", + "developer_comment": "Status bar message shown while the device is actively capturing a GPS location." + }, + "map_status_bar.remove_point.label": { + "string": "Eliminar punto", + "developer_comment": "Full label for the remove button shown on larger screens in the map status bar." + }, + "map_status_bar.view_details.label": { + "string": "Ver detalles", + "developer_comment": "Label for the button that opens the feature properties panel." + }, + "map_status_bar.advanced.label": { + "string": "Avanzado", + "developer_comment": "Label for the button that opens the advanced coordinate editing panel." + }, + "map_status_bar.vertex_longitude.label": { + "string": "Longitud: {longitude}", + "developer_comment": "Longitude component of the selected vertex info shown in the map status bar. {longitude} is the numeric longitude value." + }, + "map_status_bar.vertex_latitude.label": { + "string": "Latitud: {latitude}", + "developer_comment": "Latitude component of the selected vertex info shown in the map status bar. {latitude} is the numeric latitude value." + }, + "map_status_bar.vertex_altitude.label": { + "string": "Altitud: {altitude} m", + "developer_comment": "Altitude component of the selected vertex info shown in the map status bar. {altitude} is the numeric altitude value in metres." + }, + "map_status_bar.vertex_accuracy.label": { + "string": "Precisión: {accuracy}", + "developer_comment": "Accuracy component of the selected vertex info shown in the map status bar. {accuracy} is the pre-formatted accuracy value." + }, + "map_update_coords_dialog.header.title": { + "string": "Importar datos para reemplazar ubicación", + "developer_comment": "Title shown in the header of the dialog for importing coordinates via paste or file upload." + }, + "map_update_coords_dialog.paste_data.label": { + "string": "Pegue los datos en formato ODK", + "developer_comment": "Label for the paste input field in the import coordinates dialog." + }, + "map_update_coords_dialog.paste_data.hint": { + "string": "Ingrese las coordenadas como: Latitud Longitud Altitud Precisión. Separe múltiples puntos con un punto y coma (;).", + "developer_comment": "Helper text explaining the expected format for pasted coordinate data." + }, + "map_update_coords_dialog.upload_file.label": { + "string": "Subir un archivo GeoJSON", + "developer_comment": "Label for the file upload section in the import coordinates dialog." + }, + "map_update_coords_dialog.upload_file.action": { + "string": "Subir archivo", + "developer_comment": "Label for the button that opens the file chooser to upload a GeoJSON file." + }, + "map_update_coords_dialog.file_empty.error": { + "string": "El archivo está vacío.", + "developer_comment": "Error shown when the uploaded file contains no content." + }, + "map_update_coords_dialog.unsupported_file.error": { + "string": "Tipo de archivo no compatible. Por favor, suba un archivo .geojson.", + "developer_comment": "Error shown when the uploaded file is not a supported GeoJSON format." + }, + "map_update_coords_dialog.parse_failed.error": { + "string": "Error al convertir el archivo. Asegúrese de que sea un GeoJSON válido.", + "developer_comment": "Error shown when the uploaded file could not be parsed as valid GeoJSON." + }, + "map_update_coords_dialog.incorrect_geometry.error": { + "string": "Tipo de geometría incorrecto.", + "developer_comment": "Error shown when the imported data contains a geometry type incompatible with the current field." + }, + "media_block.fetch.error": { + "string": "No fue posible obtener el archivo multimedia. Compruebe la URL y los ajustes de descarga", + "developer_comment": "Displayed when media (image, audio or video) fetching fails." + }, + "media_block.not_found.error": { + "string": "Archivo multimedia no encontrado. Archivo: {file}", + "developer_comment": "Displayed when the server returns a 404 or non-200 status." + }, + "media_block.unknown.error": { + "string": "No fue posible obtener el archivo multimedia. Error desconocido. Archivo: {file}", + "developer_comment": "Generic fallback for network failures or unexpected exceptions." + }, + "video_block.load.error": { + "string": "Error al cargar el video. Archivo: {file}", + "developer_comment": "Displayed when a video element fires a load error. {file} is the resource URL." + }, + "audio_block.load.error": { + "string": "Error al cargar el audio. Archivo: {file}", + "developer_comment": "Displayed when an audio element fires a load error. {file} is the resource URL." + }, + "image_block.load.error": { + "string": "Error al cargar la imagen. Archivo: {file}", + "developer_comment": "Displayed when an image element fires a load error. {file} is the resource URL." + }, + "rank_control.open.label": { + "string": "Ordenar elementos", + "developer_comment": "The label for the button that opens the rank control." + }, + "trigger_control.okay.label": { + "string": "Aceptar", + "developer_comment": "The label for the 'Okay' button." + }, + "input_geopoint.get_location.label": { + "string": "Obtener ubicación", + "developer_comment": "Label for the button to start capturing the device's current GPS location." + }, + "input_geopoint.try_again.label": { + "string": "Intentar de nuevo", + "developer_comment": "Label for the button to retry capturing the GPS location after one has already been saved." + }, + "input_geopoint.location_error.title": { + "string": "No se puede acceder a la ubicación", + "developer_comment": "Title of the error message shown when the browser cannot access the device's location." + }, + "input_geopoint.location_error.description": { + "string": "Otorgue permisos de ubicación en la configuración del navegador y asegúrese de que la ubicación esté activada.", + "developer_comment": "Description of what the user should do to resolve a location access error." + }, + "geolocation_dialog.header.title": { + "string": "Buscando su ubicación", + "developer_comment": "Title shown in the header of the dialog while the device is searching for a GPS fix." + }, + "geolocation_dialog.accuracy_threshold.info": { + "string": "La ubicación se guardará a los {accuracyThreshold} m", + "developer_comment": "Informational message shown while waiting for a GPS fix. {accuracyThreshold} is the accuracy threshold in metres at which the location will be automatically saved." + }, + "geolocation_dialog.elapsed_time.label": { + "string": "Tiempo transcurrido para capturar la ubicación:", + "developer_comment": "Label preceding the elapsed time counter shown while waiting for a GPS fix." + }, + "geolocation_dialog.previous_accuracy.info": { + "string": "Ubicación anterior guardada a los {accuracy} m", + "developer_comment": "Shows the accuracy of the previously saved location in metres. {accuracy} is the numeric accuracy value." + }, + "geolocation_dialog.save.label": { + "string": "Guardar ubicación", + "developer_comment": "Label for the button to save the currently captured GPS location." + }, + "geopoint_accuracy.good.label": { + "string": "Buena precisión", + "developer_comment": "Label shown when the GPS accuracy is within the good threshold." + }, + "geopoint_accuracy.acceptable.label": { + "string": "Precisión aceptable", + "developer_comment": "Label shown when the GPS accuracy is within the acceptable threshold (between good and unacceptable)." + }, + "geopoint_accuracy.poor.label": { + "string": "Precisión deficiente", + "developer_comment": "Label shown when the GPS accuracy does not meet the acceptable threshold." + }, + "geopoint_accuracy.unknown.label": { + "string": "Precisión desconocida", + "developer_comment": "Label shown when no GPS accuracy value is available yet." + }, + "upload_image_header.take_picture.label": { + "string": "Tomar una foto", + "developer_comment": "Label for the button to take a picture using the device's camera." + }, + "upload_image_header.choose_image.label": { + "string": "Seleccionar imagen", + "developer_comment": "Label for the button to choose an image from the device's gallery or file system." + }, + "upload_control.choose_file.label": { + "string": "Seleccionar archivo", + "developer_comment": "Label for the button to choose a file from the device. Used in the audio, video, and generic file upload headers." + }, + "upload_control.drag_and_drop.placeholder": { + "string": "Arrastre y suelte los archivos aquí para cargarlos", + "developer_comment": "Placeholder text shown in the upload area when no file has been selected." + }, + "upload_control.file_too_large.error": { + "string": "El tamaño del archivo seleccionado excede el máximo permitido", + "developer_comment": "Error message shown when the selected file exceeds the maximum allowed size." + }, + "upload_control.must_be_image.error": { + "string": "El archivo seleccionado debe ser una imagen", + "developer_comment": "Error message shown when the selected file is not an image." + }, + "upload_control.must_be_video.error": { + "string": "El archivo seleccionado debe ser un video", + "developer_comment": "Error message shown when the selected file is not a video." + }, + "upload_control.must_be_audio.error": { + "string": "El archivo seleccionado debe ser un audio", + "developer_comment": "Error message shown when the selected file is not an audio file." + }, + "upload_control.invalid_file_type.error": { + "string": "El tipo de archivo no es válido", + "developer_comment": "Error message shown when the selected file does not match the expected media type." + }, + "upload_delete_dialog.header.title": { + "string": "¿Eliminar el archivo cargado?", + "developer_comment": "Title of the confirmation dialog shown before deleting an uploaded file." + }, + "upload_delete_dialog.body.message": { + "string": "¿Está seguro de que desea eliminar este archivo?", + "developer_comment": "Body text of the confirmation dialog shown before deleting an uploaded file." + }, + "repeat.add.label": { + "string": "Agregar", + "developer_comment": "Label for the button to add a repeat instance." + }, + "repeat.instance.placeholder": { + "string": "Elemento", + "developer_comment": "Placeholder for the label of a repeat instance." + } +} diff --git a/packages/web-forms/package.json b/packages/web-forms/package.json index 0cc5c9b56..60785d933 100644 --- a/packages/web-forms/package.json +++ b/packages/web-forms/package.json @@ -27,10 +27,11 @@ "yarn": "4.11.0" }, "scripts": { - "build": "npm-run-all -nl 'build:*'", + "build": "npm-run-all -l build:clean build:translations build:demo build:js", "build:clean": "rimraf dist/ dist-demo/", "build:demo": "vite build --mode demo --outDir dist-demo", "build:js": "vite build", + "build:translations": "node scripts/merge-translations.js", "dev": "vite", "dist-demo": "yarn build && yarn vite serve dist-demo --port 5174", "test": "npm-run-all -nl 'test:*'", @@ -86,10 +87,12 @@ "vue": "^3.5.29" }, "dependencies": { + "@formatjs/intl": "^4.1.4", "@mdi/js": "^7.4.47", "dompurify": "^3.3.0", "image-blob-reduce": "^4.1.0", "ol": "^10.7.0", + "primelocale": "^2.3.1", "vue-draggable-plus": "^0.6.1" }, "publishConfig": { diff --git a/packages/web-forms/scripts/merge-translations.js b/packages/web-forms/scripts/merge-translations.js new file mode 100644 index 000000000..91fa2d39f --- /dev/null +++ b/packages/web-forms/scripts/merge-translations.js @@ -0,0 +1,82 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const I18N_FILE_EXTENSION = '.i18n.json'; +const OUTPUT_FILE = 'locales/strings_en.json'; +const ENCODING = 'utf-8'; + +const parseI18nFile = async (file, rootDir) => { + const path = relative(rootDir, file); + try { + const content = JSON.parse(await readFile(file, ENCODING)); + if (typeof content !== 'object' || content == null || Array.isArray(content)) { + throw new Error('Invalid JSON file'); + } + return content; + } catch (error) { + throw new Error(`An error occurred when reading i18n JSON file in ${path}: ${error.message}`); + } +}; + +const findI18nFiles = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + const results = await Promise.all( + entries.map((entry) => { + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + return findI18nFiles(fullPath); + } + return fullPath.endsWith(I18N_FILE_EXTENSION) ? [fullPath] : []; + }) + ); + return results.flat().sort(); +}; + +const mergeI18nFiles = async (srcDir, rootDir) => { + const files = await findI18nFiles(srcDir); + if (!files.length) { + // eslint-disable-next-line no-console + console.warn(`No ${I18N_FILE_EXTENSION} files found in ${relative(rootDir, srcDir)}`); + } + + // Load all files in parallel, then merge sequentially to detect duplicates + const contents = await Promise.all(files.map((file) => parseI18nFile(file, rootDir))); + const merged = {}; + for (const [index, content] of contents.entries()) { + const duplicates = Object.keys(content).filter((key) => key in merged); + if (duplicates.length) { + throw new Error( + `Duplicate translation keys in ${relative(rootDir, files[index])}: ${duplicates.join(', ')}` + ); + } + Object.assign(merged, content); + } + + return merged; +}; + +const run = async () => { + const rootDir = process.cwd(); + const outputFile = resolve(rootDir, OUTPUT_FILE); + try { + const merged = await mergeI18nFiles(resolve(rootDir, 'src'), rootDir); + await mkdir(dirname(outputFile), { recursive: true }); + await writeFile(outputFile, JSON.stringify(merged, null, 2) + '\n', ENCODING); + // eslint-disable-next-line no-console + console.log(`Merged → ${relative(rootDir, outputFile)}`); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Merge failed:', error); + process.exit(1); + } +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void run(); +} else { + console.warn( + `[Warning] The ${fileURLToPath(import.meta.url)} was imported as a module.\n` + + `This script is designed to run as a standalone CLI tool.` + ); +} diff --git a/packages/web-forms/src/components/FormLoadFailureDialog.i18n.json b/packages/web-forms/src/components/FormLoadFailureDialog.i18n.json new file mode 100644 index 000000000..45ebacd25 --- /dev/null +++ b/packages/web-forms/src/components/FormLoadFailureDialog.i18n.json @@ -0,0 +1,10 @@ +{ + "form_load_failure_dialog.header.title": { + "string": "An error occurred while loading this form", + "developer_comment": "The title text shown in the header of the dialog." + }, + "form_load_failure_dialog.details.label": { + "string": "Technical error details", + "developer_comment": "The label for the clickable summary that reveals technical error information." + } +} diff --git a/packages/web-forms/src/components/FormLoadFailureDialog.vue b/packages/web-forms/src/components/FormLoadFailureDialog.vue index 0365d7d59..127a925bc 100644 --- a/packages/web-forms/src/components/FormLoadFailureDialog.vue +++ b/packages/web-forms/src/components/FormLoadFailureDialog.vue @@ -1,22 +1,17 @@ diff --git a/packages/web-forms/src/components/common/map/AsyncMap.vue b/packages/web-forms/src/components/common/map/AsyncMap.vue index 74344d7d6..c71903956 100644 --- a/packages/web-forms/src/components/common/map/AsyncMap.vue +++ b/packages/web-forms/src/components/common/map/AsyncMap.vue @@ -9,7 +9,9 @@ import type { Mode, SingleFeatureType } from '@/components/common/map/getModeCon import type { SelectItem } from '@getodk/xforms-engine'; import type { Feature } from 'geojson'; import ProgressSpinner from 'primevue/progressspinner'; -import { computed, type DefineComponent, onMounted, shallowRef } from 'vue'; +import { TRANSLATE } from '@/lib/constants/injection-keys.ts'; +import type { Translate } from '@/lib/locale/useLocale.ts'; +import { computed, type DefineComponent, inject, onMounted, shallowRef } from 'vue'; type MapBlockComponent = DefineComponent<{ featureCollection: { type: string; features: Feature[] }; @@ -31,6 +33,8 @@ interface AsyncMapProps { const props = defineProps(); const emit = defineEmits(['save']); +const t: Translate = inject(TRANSLATE)!; + const STATES = { READY: 'ready', LOADING: 'loading', @@ -71,9 +75,8 @@ onMounted(loadMap);