Some More About Translating Business Central Apps

I’ve written before about using Azure Cognitive Services to translate the captions in the .xlf file that is generated when you compile your Business Central app. For us, the motivation is to make our apps available in as many countries as possible in AppSource.

Since then S√łren Alexandersen has announced that it will not be necessary to provide all of a country’s official languages to make your app available in that country.

If you going to provide translations you might be interested in how to improve upon a the approach of the last post.

The Problem

The problem of course is that we are relying on machine translation to translate very short phrases or single words. A single word can mean different things and be translated in many different ways into other languages depending on the context. Context that the machine translation doesn’t have. That’s what makes language and etymology simultaneously fascinating and infuriating.

The problem is compounded by abbreviations and acronyms. You and I know that “Prod. Order” is short for “Production Order”. But “Prod” is itself an English word that has nothing to do with manufacturing.

We know that FA is likely short for “fixed asset” but if you don’t know that the context is an ERP system it could mean a whole range of things. How is Azure supposed to translate it?

What we need is some domain-specific knowledge.

The Solution

When we think about it we know that we’ve already got thousands of translations of captions into the languages that we want – if only we can get them into a useful format. We’ve got Docker images of Business Central localisations. They contain the base app for the location complete with source/target pairs for each caption.

If you can get hold of the xlf file it’s a relatively simple job to search for a trans-unit that has a source node matching the caption that you want to translate and find the corresponding translated target node.

As an example, I’ve created a container called ch from the image mcr.microsoft.com/businesscentral/sandbox:ch – the Swiss localisation of Business Central.

Find and expand the base application source.

$ContainerName = 'ch'
$Script = {Expand-Archive "C:\Applications.*\Base Application.Source.zip" -DestinationPath 'C:\run\my\base'}

Invoke-ScriptInBCContainer -containerName $ContainerName -scriptblock $Script

This script will find the zip file containing the localised base application and extract it to the ‘C:\run\my\base’ folder. This will take a few minutes but when it is done you should see a Translations folder containing, in the case of Switzerland, four .xlf files.

The following script will load the fr-CH .xlf file into an Xml Document, search for a trans-unit node which has a child source node matching a given string and return the target i.e. the fr-CH translation.

$Language = 'fr'
$enUSCaption = 'Prod. Order Line No.'
[xml]$xlf = Get-Content (Get-ChildItem "C:\ProgramData\NavContainerHelper\Extensions\$ContainerName\my\base\Translations" -Filter "*$Language*").FullName

$NSMgr.AddNamespace('x',$xlf.DocumentElement.NamespaceURI)
$xlf.SelectSingleNode("/x:xliff/x:file/x:body/x:group/x:trans-unit[x:source='$enUSCaption']", $NSMgr).target

Which returns “N¬į ligne O.F.” – cool.

Some Obvious Points

I’m going to leave it there for this post, save for making a few obvious points.

  • This is hopelessly inefficient. Downloading the localised Docker image, creating the container, extracting the base app – all to get at the .xlf files. We’re going to want a smarter solution before using this approach in any volume and for more languages
  • Each .xlf file is 60+MB – that takes a while to load into memory – you’ll want to keep the variable in scope and reuse it for multiple searches rather than reloading the document
  • Not all of the US English captions you create in your app will exist in the base application – you’ll still want to send those off for translation.

Maybe we can start to address these points next time…

Prompting the User for Input with PowerShell

Sometimes you need to prompt the user to provide some value before you can complete your PowerShell script. You’ve got a few different options depending on what you’re asking the user to select from.

Parameters

Setting a parameter as mandatory without providing a value will prompt the user to enter one, like this:

function Invoke-AmazingPowerShellFunction {
  Param(
    [Parameter(Mandatory=$true)]
    [string]$ImportantParameter
  )
}

Setting the parameter type ([string] in this case) isn’t essential but will help validate that the input is at least of the right type. The trouble with users is that they can, and will, enter any old nonsense as the parameter value and you need to be able to handle it.

The ValidateSet attribute helps out where you have a fixed set of values that are the only valid ones.

function Invoke-AmazingPowerShellFunction {
  Param(
    [Parameter(Mandatory=$true)]
    [ValidateSet('This','Or This','Or Possibly This')]
    [string]$ImportantParameter
  )
} 

If you don’t know at design-time what the valid options are going to be then you need a different approach.

Out-GridView

Out-GridView has an OutputMode parameter which allows you to specify whether the user should be able to select a value and if so, a single value or multiple values. It also allows you to set a title for the window and provides a filter to help the user find the right value. Good for when there is a lot to choose from. We use it, for example, to choose a project from Azure DevOps.

In passing I’ve also found Out-GridView useful when working with complex types e.g. from a web service response and I just want to be able to browse the values in the object. You can pipe anything to it and it will render it into a nice grid.

Write-Host ("You selected {0}" -f ('1','2','3' | Out-GridView -OutputMode Single -Title 'Please select a value'))

Roll Your Own

Recently I wanted to prompt the user to make a selection between some options in the terminal. In my experience the Out-GridView window doesn’t always open in the foreground and if you’re using multiple monitors won’t necessary open near the window you’re executing the script in. I thought I’d try keeping the focus in the terminal window instead.

I couldn’t find anything already in PowerShell to print a list of options and prompt the user to choose one, so I wrote the below. I’d be interested to know if I’ve missed something obvious already built in though.

It takes a collection of strings that represent the options to choose between and some text that you want to prompt the user with. The options are printed with numbers next to them, waits for some input from the user with Read-Host and matches it to their selection.

0 is hard-coded as a cancel option and will return an empty string, otherwise the string of the user’s selection is returned.

function Get-SelectionFromUser {
    param (
        [Parameter(Mandatory=$true)]
        [string[]]$Options,
        [Parameter(Mandatory=$true)]
        [string]$Prompt        
    )
    
    [int]$Response = 0;
    [bool]$ValidResponse = $false    

    while (!($ValidResponse)) {            
        [int]$OptionNo = 0

        Write-Host $Prompt -ForegroundColor DarkYellow
        Write-Host "[0]: Cancel"

        foreach ($Option in $Options) {
            $OptionNo += 1
            Write-Host ("[$OptionNo]: {0}" -f $Option)
        }

        if ([Int]::TryParse((Read-Host), [ref]$Response)) {
            if ($Response -eq 0) {
                return ''
            }
            elseif($Response -le $OptionNo) {
                $ValidResponse = $true
            }
        }
    }

    return $Options.Get($Response - 1)
} 

You Can Ditch Our Build Helper for Dynamics 365 Business Central

I’m a bit of a minimalist when it comes to tooling, so I’m always happy to ditch a tool because its functionality can be provided by something else I’m already using.

In a previous post I described how we use our Build Helper AL app to prep a test suite with the test codeunits and methods that you want to run. Either as part of a CI/CD pipeline or to run from VS Code.

Freddy K has updated the navcontainerhelper PowerShell module and improved the testing capabilities – see this post for full details.

The new extensionId parameter for the Run-TestsInBCContainer function removes the need to prepare the test suite before running the tests. Happily, that means we can dispense with downloading, publishing, installing, synchronising and calling the Build Helper app.

The next version of our own PowerShell module will read the app id from app.json and use the extensionId parameter to run the tests. Shout out to Freddy for making it easier than ever to run the tests from the shell ūüĎć

AL Build Helper for Dynamics 365 Business Central Builds

If you’re interested in setting up a build pipeline to build apps for Business Central then you’re probably interested in running the automated tests as part of it. (I take it you are writing automated tests?)

Turns out getting your test codeunits and methods populated into a test suite ready to run isn’t straightforward. We use a separate “Build Helper” app that exposes a couple of web service methods to prep and clear a test suite. It helps us get the container ready for running Run-TestsInBCContainer (from the navcontainerhelper module).

I’ve uploaded a couple of versions of the app to a GitHub repo here: https://github.com/CleverDynamics/al-build-helper. One for BC15 and the other for BC14 and earlier.

I use it all the time for running test from VS Code as well as in our build pipelines. Our PowerShell module has an Install-BuildHelper function to download and install it. Alternatively you could slip some PowerShell like the below into your pipeline and smoke it.

$Container = 'de'
$Company = 'CRONUS DE'
$User = 'admin'
$Password = 'P@ssword1'
$TestSuite = 'DEFAULT'
$StartRange = 130000
$EndRange = 160000
$WSPort = '7047'
$BuildHelperUrl = 'https://github.com/CleverDynamics/al-build-helper/raw/master/Clever%20Dynamics_Build%20Helper_BC14.app'

$Credential = [PSCredential]::new($user, (ConvertTo-SecureString $Password -AsPlainText -Force))
$BHPath = Join-Path $env:Temp 'BH.app'
Download-File $BuildHelperUrl $BHPath
Publish-NavContainerApp $Container -appfile $BHPath -sync -install
$BH = New-WebServiceProxy ('http://{0}:{1}/NAV/WS/{2}/Codeunit/AutomatedTestMgt' -f (Get-NavContainerIpAddress $Container), $WSPort, $Company) -Credential $Credential
$BH.GetTests($TestSuite, $StartRange, $EndRange)

The above is BC14 and assumes that you’ve got the navcontainerhelper module loaded (so you can use Publish-NavContainerApp). For BC15 you’d change the script slightly to the below (different URL for Build Helper, the instance name is “BC” rather than “NAV”).

$Container = 'bc15'
$Company = 'My Company'
$User = 'admin'
$Password = 'P@ssword1'
$TestSuite = 'DEFAULT'
$StartRange = 130000
$EndRange = 160000
$WSPort = '7047'
$BuildHelperUrl = 'https://github.com/CleverDynamics/al-build-helper/raw/master/Clever%20Dynamics_Build%20Helper.app'

$Credential = [PSCredential]::new($user, (ConvertTo-SecureString $Password -AsPlainText -Force))
$BHPath = Join-Path $env:Temp 'BH.app'
Download-File $BuildHelperUrl $BHPath
Publish-NavContainerApp $Container -appfile $BHPath -sync -install
$BH = New-WebServiceProxy ('http://{0}:{1}/BC/WS/{2}/Codeunit/AutomatedTestMgt' -f (Get-NavContainerIpAddress $Container), $WSPort, $Company) -Credential $Credential
$BH.GetTests($TestSuite, $StartRange, $EndRange)

No doubt, given the rate of change in Business Central there will be a different/better way to do this by the time BC15/wave 2/Fall ’19/whatever the heck we call it is released – but this how we build against BC15 for now.

Feel free to use anything you find helpful with my blessing…but not necessarily my support. No warranties, own risk etc.

Calling SOAP Services from PowerShell

Like most of my posts this has its origin in Microsoft Dynamics 365 Business Central development – specifically our build process – although it isn’t limited to that.

We had a need to call a SOAP web service from PowerShell (see below for the background if you’re interested). In the past I’ve used Invoke-WebRequest and added content-type and a SOAPAction header to the request. Similar to this.

Joel, one of the guys in my team introduced me to New-WebServiceProxy. Well…that’s been an oversight, it makes life so much simpler, as a quick example will illustrate. As the name suggests, PowerShell reads the definition of the service and creates a PowerShell object and complex types that you need to interact with it.

Invoke-WebRequest

First, this is the old, cumbersome way that I would have used to call a SOAP web service from PowerShell.

$Credential = [System.Management.Automation.PSCredential]::new('admin',(ConvertTo-SecureString 'P@ssword1' -AsPlainText -Force))
$Body = '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:sal="urn:microsoft-dynamics-schemas/page/salesorder">
<soapenv:Header/>
<soapenv:Body>
<sal:Read>
<sal:No>101005</sal:No>
</sal:Read>
</soapenv:Body>
</soapenv:Envelope>'
Invoke-WebRequest -Credential $Credential -Uri http://localhost:60799/NAV/WS/CRONUS%20International%20Ltd./Page/SalesOrder -Headers (@{SOAPAction='Read'}) -Method Post -Body $Body -ContentType application/xml

Create a credential object, define the body (copied from the request created by SoapUI) then call with Invoke-WebRequest setting the credential, content-type, headers and body.

New-WebServiceProxy

And now the enlightened way.

$Credential = [System.Management.Automation.PSCredential]::new('admin',(ConvertTo-SecureString 'P@ssword1' -AsPlainText -Force))
$Client = New-WebServiceProxy -Uri 'http://localhost:60799/NAV/WS/CRONUS%20International%20Ltd./Page/SalesOrder' -Credential $Credential
$Client.Read('101005')

Create the $Client object with New-WebServiceProxy and call the method that you are interested in. If you use PowerShell ISE you can browse the intellisense like a champion.

PowerShell WebService Proxy Intellisense.JPG

If the service uses complex data types you’ll find that PowerShell has auto-generated the types for you to access them. Look under [Microsoft.PowerShell.Commands.NewWebServiceProxy.AutogeneratedTypes…] – you could use it with Business Central services that take an xmlport as a VAR parameter, for example (use the [ref] keyword in PowerShell).

What Are You Calling SOAP for Anyway?

If you’re only interested in the PowerShell details you can stop reading now – thanks for your attention.

If you’re still here then I’ll explain why we’re doing this. As part of our build process for AL apps we need to prepare the DEFAULT test suite with the test codeunits and methods that we want to run. We then run the tests with Run-TestsInNavContainer in the navcontainerhelper module.

We used to import a codeunit from a text file and use Invoke-NavContainerCodeunit to call its methods. In the last few builds of Business Central some bug in the platform has stopped this working.

Freddy posted here about installing an app and calling its API as a replacement for importing fob files. Inspired by that, we’ve done the same. Publish an app that includes a codeunit exposed as a web service and call that service to prep the test suite.

Perhaps we should have used an API page and REST instead? I don’t know if there are any strong arguments either way. This particular cat can be skinned in several different ways (I wonder how that idiom translates to non-UK readers).