Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Escaping Tags in XLIFF #2936

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ContentDeliveryConfigModel(
val autoPublish: Boolean,
val lastPublished: Long?,
val lastPublishedFiles: Collection<String>,
override var escapeHtml: Boolean,
) : RepresentationModel<ContentDeliveryConfigModel>(), Serializable, IExportParams {
override var languages: Set<String>? = null
override var format: ExportFormat = ExportFormat.JSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ContentDeliveryConfigModelAssembler(
autoPublish = entity.automationActions.isNotEmpty(),
lastPublished = entity.lastPublished?.time,
lastPublishedFiles = entity.lastPublishedFiles ?: listOf(),
escapeHtml = entity.escapeHtml,
).also {
it.copyPropsFrom(entity)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,8 @@ The `{snakeLanguageTag}` placeholder is the same as `{languageTag}` but in snake
The Android specific `{androidLanguageTag}` placeholder is the same as `{languageTag}`
but in Android format. (e.g., en-rUS)
"""

const val HTML_ESCAPE_DESCRIPTION = """If true, HTML tags are escaped in the exported file. (Supported in the XLIFF format only).

e.g. Key <b>hello</b> will be exported as &lt;b&gt;hello&lt;/b&gt;"""
}
7 changes: 7 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/dtos/IExportParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_IN_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_NOT_IN_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FORMAT_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.HTML_ESCAPE_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.LANGUAGES_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.LANGUAGES_EXAMPLE
import io.tolgee.dtos.ExportParamsDocs.MESSAGE_FORMAT_DESCRIPTION
Expand Down Expand Up @@ -94,6 +95,11 @@ interface IExportParams {
)
var fileStructureTemplate: String?

@get:Schema(
description = HTML_ESCAPE_DESCRIPTION,
)
var escapeHtml: Boolean

fun copyPropsFrom(other: IExportParams) {
this.languages = other.languages
this.format = other.format
Expand All @@ -109,6 +115,7 @@ interface IExportParams {
this.messageFormat = other.messageFormat
this.supportArrays = other.supportArrays
this.fileStructureTemplate = other.fileStructureTemplate
this.escapeHtml = other.escapeHtml
}

@get:Hidden
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.tolgee.dtos.request

import io.swagger.v3.oas.annotations.media.Schema
import io.tolgee.dtos.ExportParamsDocs
import io.tolgee.dtos.IExportParams
import io.tolgee.formats.ExportFormat
import io.tolgee.formats.ExportMessageFormat
Expand Down Expand Up @@ -71,4 +72,9 @@ class ContentDeliveryConfigRequest() : IExportParams {
override var messageFormat: ExportMessageFormat? = null

override var fileStructureTemplate: String? = null

@Schema(
description = ExportParamsDocs.HTML_ESCAPE_DESCRIPTION,
)
override var escapeHtml: Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_IN_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FILTER_TAG_NOT_IN_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.FORMAT_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.HTML_ESCAPE_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.MESSAGE_FORMAT_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.STRUCTURE_DELIMITER_DESCRIPTION
import io.tolgee.dtos.ExportParamsDocs.SUPPORT_ARRAYS_DESCRIPTION
Expand Down Expand Up @@ -82,7 +83,16 @@ data class ExportParams(
description = ExportParamsDocs.FILE_STRUCTURE_TEMPLATE_DESCRIPTION,
)
override var fileStructureTemplate: String? = null,
) : IExportParams {
@field:Parameter(description = SUPPORT_ARRAYS_DESCRIPTION)
override var supportArrays: Boolean = false
}
@field:Parameter(
description = SUPPORT_ARRAYS_DESCRIPTION,
)
override var supportArrays: Boolean = false,

/**
* Enabling or disabling HTML escaping for XLIFF format. Some tools expect the XML/HTML tags escaped and don't accept raw unescaped tags.
*/
@field:Parameter(
description = HTML_ESCAPE_DESCRIPTION,
)
override var escapeHtml: Boolean = false,
) : IExportParams
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.tolgee.model.ILanguage
import io.tolgee.service.export.ExportFilePathProvider
import io.tolgee.service.export.dataProvider.ExportTranslationView
import io.tolgee.service.export.exporters.FileExporter
import org.apache.commons.text.StringEscapeUtils
import java.io.InputStream

class XliffFileExporter(
Expand Down Expand Up @@ -66,8 +67,17 @@ class XliffFileExporter(
text: String?,
plural: Boolean,
): String? {
val processedText =
text?.let {
if (exportParams.escapeHtml) {
StringEscapeUtils.escapeXml10(text)
} else {
text
}
}

return IcuToGenericFormatMessageConvertor(
text,
processedText,
plural,
projectIcuPlaceholdersSupport,
paramConvertorFactory = messageFormat.paramConvertorFactory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,8 @@ class ContentDeliveryConfig(

@ActivityLoggedProp
override var fileStructureTemplate: String? = null

@ColumnDefault("false")
@ActivityLoggedProp
override var escapeHtml: Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ContentDeliveryConfigService(
config.copyPropsFrom(dto)
setSlugForCreation(config, dto)
config.pruneBeforePublish = dto.pruneBeforePublish
config.escapeHtml = dto.escapeHtml
contentDeliveryConfigRepository.save(config)
if (dto.autoPublish) {
automationService.createForContentDelivery(config)
Expand Down
5 changes: 5 additions & 0 deletions backend/data/src/main/resources/db/changelog/schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4167,4 +4167,9 @@
<changeSet author="stanov (generated)" id="1740659624320-9">
<addForeignKeyConstraint baseColumnNames="linked_task_id" baseTableName="notification" constraintName="FKm1auuxar5ovwae9fqjp1eo05k" deferrable="false" initiallyDeferred="false" referencedColumnNames="id" referencedTableName="task" validate="true"/>
</changeSet>
<changeSet author="neo (generated)" id="1740409127117-1">
<addColumn tableName="content_delivery_config">
<column defaultValueBoolean="false" name="escape_html" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,72 @@ class XliffFileExporterTest {
)
}

@Test
fun `exports with HTML escaping when escapeHtml is true`() {
val built =
buildExportTranslationList {
add(
languageTag = "en",
keyName = "simple_html",
text = "<b>Bold text</b> and <i>italic text</i>",
)
add(
languageTag = "en",
keyName = "nested_html",
text = "<div><p>Nested <b>bold</b> text</p></div>",
)
add(
languageTag = "en",
keyName = "mixed_entities",
text = "<span>Copyright © 2024 & <b>Terms</b></span>",
)
add(
languageTag = "en",
keyName = "html_attributes",
text = "<a href='https://example.com' class='link'>Click here</a>",
)
}

val exporter =
getExporter(
built.translations,
exportParams = ExportParams(escapeHtml = true),
)
val data = getExported(exporter)
data.assertFile(
"en.xliff",
"""
|<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
| <file datatype="plaintext" original="" source-language="en" target-language="en">
| <header>
| <tool tool-id="tolgee.io" tool-name="Tolgee"/>
| </header>
| <body>
| <trans-unit id="simple_html">
| <source xml:space="preserve"/>
| <target xml:space="preserve">&lt;b&gt;Bold text&lt;/b&gt; and &lt;i&gt;italic text&lt;/i&gt;</target>
| </trans-unit>
| <trans-unit id="nested_html">
| <source xml:space="preserve"/>
| <target xml:space="preserve">&lt;div&gt;&lt;p&gt;Nested &lt;b&gt;bold&lt;/b&gt; text&lt;/p&gt;&lt;/div&gt;</target>
| </trans-unit>
| <trans-unit id="mixed_entities">
| <source xml:space="preserve"/>
| <target xml:space="preserve">&lt;span&gt;Copyright © 2024 &amp; &lt;b&gt;Terms&lt;/b&gt;&lt;/span&gt;</target>
| </trans-unit>
| <trans-unit id="html_attributes">
| <source xml:space="preserve"/>
| <target xml:space="preserve">&lt;a href='https://example.com' class='link'&gt;Click here&lt;/a&gt;</target>
| </trans-unit>
| </body>
| </file>
|</xliff>
|
""".trimMargin(),
)
}

private fun getExporter(
translations: List<ExportTranslationView>,
isProjectIcuPlaceholdersEnabled: Boolean = true,
Expand Down
11 changes: 11 additions & 0 deletions backend/data/src/test/resources/import/xliff/escape-html.xliff
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version='1.0' encoding='UTF-8'?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file original="" datatype="plaintext" source-language="en" target-language="cs">
<body>
<trans-unit id="simple_html" xml:space="preserve"><source><b>Bold text</b> and <i>italic text</i></source></trans-unit>
<trans-unit id="nested_html" xml:space="preserve"><source><div><p>Nested <b>bold</b> text</p></div></source></trans-unit>
<trans-unit id="mixed_entities" xml:space="preserve"><source><span>Copyright &#169; 2024 &amp; <b>Terms</b></span></source></trans-unit>
<trans-unit id="html_attributes" xml:space="preserve"><source><a href="https://example.com" class="link">Click here</a></source></trans-unit>
</body>
</file>
</xliff>
2 changes: 2 additions & 0 deletions e2e/cypress/common/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const testExportFormats = (
format: 'XLIFF',
expectedParams: {
format: 'XLIFF',
escapeHtml: false,
},
}
);
Expand Down Expand Up @@ -301,6 +302,7 @@ export type FormatTest = {
format: string;
structureDelimiter?: string;
supportArrays?: boolean;
escapeHtml?: boolean;
};
};

Expand Down
26 changes: 26 additions & 0 deletions e2e/cypress/e2e/projects/contentDelivery.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ describe('Content delivery', () => {
);
});

it('stores content delivery configuration for XLIFF format with HTML escaping', () => {
cy.gcy('content-delivery-add-button').click();
fillContentDeliveryConfigForm('XLIFF Test');

// Select XLIFF format
cy.gcy('export-format-selector').click();
cy.gcy('export-format-selector-item').contains('XLIFF').click();

// Enable HTML escaping
cy.gcy('export-escape_html-selector')
.find('input')
.should('not.be.checked')
.click();

// Save the configuration
saveForm();
waitForGlobalLoading();

// Verify the settings persist
openEditDialog('XLIFF Test');
cy.gcy('export-escape_html-selector').find('input').should('be.checked');

// Verify format is still XLIFF
gcy('export-format-selector').should('contain', 'XLIFF');
});

it('updates existing content delivery', () => {
const name = 'Azure edited';
gcyAdvanced({ value: 'content-delivery-list-item', name: 'Azure' })
Expand Down
1 change: 1 addition & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ declare namespace DataCy {
"expiration-date-field" |
"expiration-date-picker" |
"expiration-select" |
"export-escape_html-selector" |
"export-format-selector" |
"export-format-selector-item" |
"export-language-selector" |
Expand Down
6 changes: 6 additions & 0 deletions webapp/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@
"content_delivery_subtitle": "Project Content Delivery",
"content_delivery_translations_prune_before_publish_hint": "The data stored in the content storage will be removed before publishing. When not enabled, data from deleted languages or namespaces will be kept in the storage.",
"content_delivery_translations_prune_before_publish_label": "Prune before publishing",
"content_delivery_translations_escape_html_hint": "When enabled, HTML tags will be escaped in the exported file.",
"content_delivery_translations_escape_html_label": "Escape HTML",
"content_delivery_update_success": "Content delivery successfully updated!",
"content_delivery_update_title": "Edit content delivery",
"content_storage_config_invalid": "Storage configuration invalid",
Expand Down Expand Up @@ -659,6 +661,8 @@
"export_translations_states_label": "States",
"export_translations_support_arrays_hint": "When enabled keys like \"item[0]\" will be converted to json arrays.",
"export_translations_support_arrays_label": "Support arrays",
"export_translations_escape_html_hint": "When enabled HTML tags will be escaped in the exported file.",
"export_translations_escape_html_label": "Escape HTML",
"export_translations_title": "Export translations",
"failed_job_filter_indicator_label": "Prefiltering failed keys",
"feature-explanation-check-license-action": "Show license",
Expand Down Expand Up @@ -795,6 +799,8 @@
"import_only_update_without_add_key_label_hint": "When disabled, the creation of new keys is skipped and only the keys that already exist in the project are updated",
"import_override_key_descriptions_label": "Override key descriptions",
"import_override_key_descriptions_label_hint": "When enabled, key descriptions will be replaced by those provided in the imported files.",
"import_override_escape_html_label": "Escape HTML tags",
"import_override_escape_html_label_hint": "(XLIFF only) When enabled, HTML tags will be escaped with &lt;TAGNAME&gt; for opening tags and &lt;/TAGNAME&gt;&#13; for closing tags respectively.",
"import_resolution_accept_imported": "Accept imported",
"import_resolution_accept_old": "Accept old",
"import_resolve_conflicts_button": "Resolve conflicts",
Expand Down
24 changes: 24 additions & 0 deletions webapp/src/service/apiSchema.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,12 @@ export interface components {
};
ContentDeliveryConfigModel: {
autoPublish: boolean;
/**
* @description If true, HTML tags are escaped in the exported file.
*
* e.g. Key <b>hello</b> will be exported as &lt;b&gt;hello&lt;/b&gt;
*/
escapeHtml: boolean;
/**
* @description This is a template that defines the structure of the resulting .zip file content.
*
Expand Down Expand Up @@ -1435,6 +1441,12 @@ export interface components {
* @description Id of custom storage to use for content delivery. If null, default server storage is used. Tolgee Cloud provides default Content Storage.
*/
contentStorageId?: number;
/**
* @description If true, HTML tags are escaped in the exported file.
*
* e.g. Key <b>hello</b> will be exported as &lt;b&gt;hello&lt;/b&gt;
*/
escapeHtml: boolean;
/**
* @description This is a template that defines the structure of the resulting .zip file content.
*
Expand Down Expand Up @@ -2059,6 +2071,12 @@ export interface components {
mediaType: string;
};
ExportParams: {
/**
* @description If true, HTML tags are escaped in the exported file.
*
* e.g. Key <b>hello</b> will be exported as &lt;b&gt;hello&lt;/b&gt;
*/
escapeHtml: boolean;
/**
* @description This is a template that defines the structure of the resulting .zip file content.
*
Expand Down Expand Up @@ -10723,6 +10741,12 @@ export interface operations {
* e.g. Key hello[0] will be exported as {"hello": ["..."]}
*/
supportArrays?: boolean;
/**
* If true, HTML tags are escaped in the exported file.
*
* e.g. Key <b>hello</b> will be exported as &lt;b&gt;hello&lt;/b&gt;
*/
escapeHtml?: boolean;
};
path: {
projectId: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { useCdActions } from './useCdActions';
import { useExportHelper } from 'tg.hooks/useExportHelper';
import { CdPruneBeforePublish } from './CdPruneBeforePublish';
import { EscapeHtmlSelector } from 'tg.views/projects/export/components/EscapeHtmlSelector';

const StyledDialogContent = styled(DialogContent)`
display: grid;
Expand Down Expand Up @@ -170,6 +171,9 @@ export const CdDialog = ({ onClose, data }: Props) => {
)}
<CdAutoPublish />
<CdPruneBeforePublish />
{getFormatById(values.format).showEscapeHtml && (
<EscapeHtmlSelector />
)}
</StyledOptions>
</StyledDialogContent>
<DialogActions sx={{ justifyContent: 'space-between' }}>
Expand Down
Loading