Kathuko › Oracle APEX › Part 12

Part 12

Turnout in Spanish

In this part Turnout learns a second language. Every label, button, message and category name appears in Spanish for anyone who picks Español, and the app remembers the choice the next time they sign in.

Sofía and Lucas in the sample data prefer Spanish, and so do many of the people a Tampa running club wants to reach. Translation is often left until the end of a project and then found to be hard. Turnout was built for it from the first table: TO_USERS.PREFERRED_LANGUAGE has existed since Part 2.

APEX translates in three places, and each has its own tool:

Text Example Tool Lesson
Anything you typed into the app Button labels, page titles, help text, email templates The translated application and its XLIFF file 12.2 to 12.3
Text your PL/SQL builds "You're going. Your ticket will be in My events." Text messages and apex_lang.message 12.4
Text that lives in your tables Category names like Running and Chess Dynamic translations and apex_lang.lang 12.5

The rule for the whole part: never write language in two places. English lives in the app, Spanish lives in the translation, and the code picks one at run time. Nothing in Turnout says if lang = 'es'.

Before you start

You need Turnout as it was at the end of Part 11. If you don't have it, import the Start here app, turnout-app-part-11.zip, from the downloads at the end of this page.

No package changes: TO_AUTH has set the session language at sign-in since Part 5, waiting for this part. One short script, in Lesson 12.4, creates the badge messages. The lessons below use app 193569 and its Spanish copy 193569001, which are the course author's. Your app has its own number: wherever you see 193569, read yours.

The order matters, because the XLIFF file only holds strings that exist when you seed: build everything in Lessons 12.1, 12.4 and 12.5 first, then seed, translate, apply and publish (12.2, 12.3, 12.6).

Lesson 12.1 One app, two languages

APEX doesn't keep two copies of your pages to edit. It keeps one primary application in English, and generates a translated application from it, in Spanish, every time you publish. You never open the translated app in App Builder.

Setting Where Turnout
Primary language Shared Components › Globalization English (en)
Language derived from Shared Components › Globalization Session
Translated application Shared Components › Application Translations › Define application languages 193569001, language es

In APEXlang the first two are a few lines in application.apx:

globalization {
    primaryLanguage: en
    languageDerivedFrom: session
}

Why "Session". APEX can also follow the browser's language, but then a Spanish speaker on an English laptop has no way to switch. With Session, the language belongs to the APEX session, and three things set it:

  1. The menu bar. Español and English are entries in the navigation bar list that add &p_lang=es or &p_lang=en to the current page's address. A visitor can switch without signing in.
  2. Signing in. TO_AUTH.FINISH_SIGN_IN ends with apex_util.set_session_lang(preferred_language), so Sofía sees Spanish from her first page.
  3. The Profile page. Saving a new language calls apex_util.set_session_lang straight away, so the very next page is in the new language.

Only one of the two menu entries shows at a time. Español has the condition nvl(apex_util.get_session_lang, 'en') <> 'es', and English has the opposite. Both labels stay as they are in every language, because each one is written in the language it switches to.

Build it.

  1. Shared Components › Globalization Attributes. Application Primary Language English (en). Switch Translate Application on, set Translation Method to Application-Based, and Translation Language Derived From to Session. Automatic Time Zone off. Apply Changes. (With Text Message-Based, the Application Translations page hides the seed, XLIFF and publish steps this part uses.)
  2. Shared Components › Application Translations › Define application languages › Create. Translation Application: your app's number followed by 001 (for app 123456, 123456001; the number must not end in 0 and must not be in use). Language Spanish (es). Create.
  3. Shared Components › Navigation Bar List › Navigation Bar › Create Entry: Sequence 3, List Entry Label Español, Target type URL, URL Target f?p=&APP_ID.:&APP_PAGE_ID.:&APP_SESSION.&p_lang=es. Under Conditions: Type Expression, PL/SQL nvl(apex_util.get_session_lang, 'en') <> 'es'. Create.
  4. One more: Sequence 4, label English, URL f?p=&APP_ID.:&APP_PAGE_ID.:&APP_SESSION.&p_lang=en, condition Expression apex_util.get_session_lang = 'es'.
  5. Page 40, process Save profile. After the call to to_user_api.update_profile(...);, add:
apex_util.set_session_lang(:P40_LANGUAGE);
  1. Still on page 40, item P40_LANGUAGE: change Help › Help Text to Turnout speaks English and Spanish. Your choice applies as soon as you save.

Check: run Discover. The navigation bar shows Español. Click it: the page reloads with English beside Sign In instead, though nothing is Spanish yet. That comes when you publish.

Lesson 12.2 Seed and export the XLIFF file

Stop if you haven't done Lessons 12.4 and 12.5 yet. Seeding only picks up strings that exist when you seed. Build the text messages and dynamic translations first, then come back here.

Seeding copies every translatable string from the primary app into the translation repository. Exporting writes those strings to an XLIFF file: an XML file with one trans-unit per string, holding the English source and a target for the translation.

  1. Shared Components › Application Translations › Seed translatable text. Pick the Spanish mapping and Seed.
  2. Download XLIFF file. Pick Spanish, "All translatable elements", and download. The file is named after both apps: f193569_193569001_en_es.xlf.

A trans-unit looks like this. The id says what the string is: S-13 is a button label, S-14 an item label, S-470 an email body, and the long number is the component's id.

<trans-unit id="S-13-5852931153315370529-193569">
<source>Cancel my RSVP</source>
<target>Cancel my RSVP</target>
</trans-unit>

Before translating, every target is a copy of its source. The file you download is a working document, not a backup: it holds the app's strings as of the moment you seeded.

Lesson 12.3 Translating the file

Turnout's file has about 270 strings. A few, the category names and the text messages from Lessons 12.4 and 12.5, arrive already in Spanish. Leave the units for WHEN_SHORT and WHEN_LONG exactly as they are: they're date formats, not words. You only ever change what's between <target> and </target>.

Four rules keep a translated file safe to apply:

Leave alone Example Why
Substitutions &P20_TITLE., &APP_USER. APEX replaces them with values at run time
Email placeholders #EVENT_TITLE#, #CLUB_NAME# The outbox fills them when the email is sent (Part 10)
HTML tags and attributes <h2>, <a href="#INVITE_LINK#"> They're markup, not words
Names Turnout, Google, GitHub Brand names don't translate

Then four choices make the Spanish read as one voice:

  • Tú, not usted. Turnout talks to people the way a club does. The two text messages already used tú, so everything else follows.
  • One word per idea. A ticket is always entrada, never sometimes boleto. Waitlist is always lista de espera. Keep a glossary as you go.
  • Buttons say what happens. RSVP becomes Confirmar asistencia, and Cancel my RSVP becomes Cancelar mi asistencia: the same pair, so people can see they're opposites.
  • Messages stay short. "Saved." is Guardado., not a full sentence. Spanish runs about 20 percent longer than English, so short English helps.
English Spanish
Discover · Clubs · My events Descubrir · Clubes · Mis eventos
RSVP · Cancel my RSVP Confirmar asistencia · Cancelar mi asistencia
You're going · On the waitlist Vas a ir · En la lista de espera
Show this at the door Muéstrala en la puerta
Find your people Encuentra a tu gente

You don't have to type 259 translations. Use the course's glossary to fill your file:

  1. Open Fill your XLIFF file. It runs in your browser and uploads nothing.
  2. Choose the file you downloaded in Lesson 12.2. It fills every string the course knows and lists any that are left.
  3. Download the filled file and translate the few that are left in a text editor, between <target> and </target>. Then go to Lesson 12.6 (you built 12.4 and 12.5 before seeding).

Read a dozen of the filled strings anyway, to see the four choices above in action. For reference, the downloads include the course's own translated file, f193569_193569001_en_es.xlf. Don't apply it to your app: every trans-unit id carries the author's app and component numbers, so APEX would match none of them.

The glossary travels with the app. The course repo keeps every English string and its Spanish in translations/turnout_es_glossary.py (download: turnout_es_glossary.txt). The next time you seed, a short script fills the new file from the glossary, so only the strings that are really new need a person.

Lesson 12.4 Text messages: Spanish from PL/SQL

The XLIFF file only knows text typed into the app. The RSVP process builds its success message in PL/SQL, so the file never sees it. That's what text messages are for: named strings, stored once per language.

textMessage RSVP_GOING (
    message {
        text: "You're going. Your ticket will be in My events."
        language: en
    }
)

textMessage RSVP_GOING (
    message {
        text: "Vas a ir. Tu entrada estará en Mis eventos."
        language: es
    }
)

The process asks for the message by name, and APEX returns the one for the session's language:

apex_application.g_print_success_message :=
    case to_rsvp_api.rsvp(:P20_EVENT_ID, nvl(:P20_GUESTS, 0))
        when 'GOING' then apex_lang.message('RSVP_GOING')
        else apex_lang.message('RSVP_WAITLISTED')
    end;

Text messages also take substitutions, %0 to %9: apex_lang.message('SPOTS_LEFT', l_left) with "%0 spots left" in English and "Quedan %0 lugares" in Spanish. Word order changes between languages, so never build a sentence by joining translated pieces.

Dates are words too. Every page used to format times with 'fmDy, Mon DD "at" HH:fmMI AM'. In a Spanish session the database translates the day and month names, but the literal "at" stays English, and Spanish puts the day before the month and uses a 24-hour clock. So the whole format mask became a text message:

Message English Spanish
WHEN_SHORT fmDy, Mon DD "at" HH:fmMI AM fmDy DD "de" Mon "a las" HH24:fmMI
WHEN_LONG fmDay, Month DD "at" HH:fmMI AM fmDay DD "de" Month "a las" HH24:fmMI
to_char(e.starts_at at time zone c.time_zone, apex_lang.message('WHEN_SHORT')) as event_when

The Saturday Long Run reads "Sat, Oct 24 at 7:00 AM" in English and "Sáb 24 de Oct a las 7:00" in Spanish, from one query.

The same trick for errors. Part 3's error handler looks for a text message named after a constraint, such as TO_CLUBS_SLUG_UK. Add a Spanish version of that message and constraint errors arrive in Spanish too.

Build it.

  1. Shared Components › Text Messages › Create Text Message, eight times: each Static ID below in English (en) and again in Spanish (es). The list opens filtered to the primary language, so remove that filter to see the Spanish ones. Type the text exactly, quotes included:
Static ID Language Text
RSVP_GOING en You're going. Your ticket will be in My events.
RSVP_GOING es Vas a ir. Tu entrada estará en Mis eventos.
RSVP_WAITLISTED en The event is full, so you're on the waitlist. We'll email you if a spot opens.
RSVP_WAITLISTED es El evento está lleno, así que estás en la lista de espera. Te escribiremos si se libera un lugar.
WHEN_SHORT en fmDy, Mon DD "at" HH:fmMI AM
WHEN_SHORT es fmDy DD "de" Mon "a las" HH24:fmMI
WHEN_LONG en fmDay, Month DD "at" HH:fmMI AM
WHEN_LONG es fmDay DD "de" Month "a las" HH24:fmMI
  1. Page 20, process RSVP: replace the two quoted messages with apex_lang.message('RSVP_GOING') and apex_lang.message('RSVP_WAITLISTED'), as in the code above.
  2. The dates. On each page below, find the format mask in the query or process and replace the quoted mask, quotes and all, with the message call:
Page Where Replace with
1 Discover Events region query apex_lang.message('WHEN_SHORT')
10 Club Upcoming events query apex_lang.message('WHEN_SHORT')
20 Event Load event process apex_lang.message('WHEN_LONG')
30 My events Going and Waiting queries (two) apex_lang.message('WHEN_SHORT')
31 My ticket Load ticket process apex_lang.message('WHEN_LONG')
100 Organize Events query apex_lang.message('WHEN_SHORT')

So to_char(e.starts_at at time zone c.time_zone, 'fmDy, Mon DD "at" HH:fmMI AM') becomes to_char(e.starts_at at time zone c.time_zone, apex_lang.message('WHEN_SHORT')). Leave the shorter masks alone ('fmMon DD' on the charts, 'fmHH:fmMI AM' on check-in): they have no words in them.

  1. The badges. Card badges like "Waitlist" and "3 spots left", roles like "Organizer", and counts like "4 upcoming events" are built in SQL too, so they need text messages as well. There are 34 of them, which is typing, not learning, so a script makes them: download part12_badge_messages.sql, change 123456 at the top to your app's number, and run it in SQL Commands. It prints 34 text messages created. Then swap the quoted words for the messages:
Page Where Change To
1 Discover and 10 Club Events query, the badge 'Open to all' · 'Waitlist' · to_rsvp_api.spots_left(e.id) \|\| ' spots left' apex_lang.message('BADGE_OPEN') · apex_lang.message('BADGE_WAITLIST') · apex_lang.message('BADGE_SPOTS_LEFT', to_rsvp_api.spots_left(e.id))
20 Event Load event, :P20_SPOTS 'Open to all' · 'Full: new RSVPs join the waitlist' · l_left \|\| ' left' apex_lang.message('BADGE_OPEN') · apex_lang.message('SPOTS_FULL') · apex_lang.message('SPOTS_N_LEFT', l_left)
30 My events Going query, the badge the case on r.guests case when r.guests = 1 then apex_lang.message('BADGE_GUEST') when r.guests > 1 then apex_lang.message('BADGE_GUESTS', r.guests) else apex_lang.message('BADGE_GOING') end
30 My events Waiting query, the badge 'Number ' \|\| r.waitlist_position \|\| ' in line' apex_lang.message('BADGE_IN_LINE', r.waitlist_position)
32 My clubs Clubs query 'Waiting for approval' · 'Owner' · 'Organizer' · 'Member' · (select count(*) ...) \|\| ' upcoming events' apex_lang.message('ROLE_PENDING') · ROLE_OWNER · ROLE_ORGANIZER · ROLE_MEMBER · apex_lang.message('CLUB_UPCOMING', (select count(*) ...))
100 Organize Events query, the badge 'Draft' · ... \|\| ' going' · ... \|\| ' of ' \|\| e.capacity apex_lang.message('BADGE_DRAFT') · apex_lang.message('BADGE_N_GOING', to_rsvp_api.spots_taken(e.id)) · apex_lang.message('BADGE_N_OF_M', to_rsvp_api.spots_taken(e.id), e.capacity)

%0 and %1 in a message are filled from the extra arguments, in order. That's why "3 spots left" is one message with %0, not "3" joined to a translated " spots left": Spanish puts the number in a different place, Quedan 3 lugares.

Check: run Discover in English. The dates and badges look exactly as before; a typo in a message name shows the name itself instead of the text, so you'll spot it.

Lesson 12.5 Dynamic translations: Spanish for data

Category names live in TO_CATEGORIES, because site admins add them. They aren't in the app, so the XLIFF file can't translate them, and they aren't built by PL/SQL either. Dynamic translations cover this case: a list of from-and-to pairs per language.

dynamicTranslation running-es (
    language: es
    fromText: Running
    toText: Correr
)

The Discover query wraps the column in apex_lang.lang:

select e.title, c.name as club_name,
       apex_lang.lang(cat.name) as category
  from ...

In an English session apex_lang.lang('Running') returns Running; in Spanish it returns Correr.

When to use which. Dynamic translations suit short lists that rarely change. For data people write themselves, like event titles and descriptions, don't translate at all: the Saturday Long Run is called whatever its organizer called it. If a club wants both languages, that's a column per language in the table, which is a design change (Part 2), not a translation.

Build it.

  1. Shared Components › Application Translations, then Dynamic Translations under Translation Utilities. Click Create six times, each with Language Spanish (es):
Translate From Text Translate To Text
Running Correr
Cycling Ciclismo
Hiking Senderismo
Chess Ajedrez
Book club Club de lectura
Board games Juegos de mesa
  1. Wrap the category in apex_lang.lang(...) on three pages: - Page 1, Events query: cat.name as category becomes apex_lang.lang(cat.name) as category. - Page 2, Clubs query: cat.name || nvl2(c.city, ...) becomes apex_lang.lang(cat.name) || nvl2(c.city, ...). - Page 32, Clubs query: cat.name || ' · ' || c.city becomes apex_lang.lang(cat.name) || ' · ' || c.city.

Check: click Español and open Discover. Before you publish anything, the categories already read Correr and Ajedrez, and dates read "Sáb 24 de Oct a las 7:00". Now do Lessons 12.2 and 12.3 if you haven't, then the steps below.

Lesson 12.6 Apply, publish, and the step people miss

The translated file goes back into APEX in two steps, and then APEX builds the Spanish app:

  1. Shared Components › Application Translations › Apply XLIFF Translation File. Upload the file, then select it in the list and Apply it to the Spanish mapping. Uploading alone only stores it.
  2. Publish translated applications. Pick Spanish and Publish. This generates app 193569001 from the primary app plus the repository.

The symptom of a missed step. If you publish before applying, Turnout switches to Spanish but almost nothing changes: the menu, labels and buttons stay English. The giveaway is what is Spanish: event dates, category names and the RSVP message. Those come from text messages, dynamic translations and the database, which follow the session's language without publishing. When dates are Spanish and buttons aren't, the session is right and the published app is stale: apply, then publish again.

Check it. Click Español in the menu bar:

Where You should see
Menu Descubrir, Clubes, Mis eventos
Discover hero Encuentra a tu gente
A category Correr, Ajedrez
A card badge Lista de espera, Quedan 17 lugares
An event, signed in Confirmar asistencia
After you RSVP Vas a ir. Tu entrada estará en Mis eventos.

Lesson 12.7 When the app changes

A translated app is a snapshot. Change a label in the primary app and the Spanish app still shows the old one until you publish again. Every change follows the same loop:

  1. Change the primary app (or import a new version of it).
  2. Seed again. Strings you already translated keep their Spanish; new or changed strings arrive as English.
  3. Translate only those. Export the file, fill it from the glossary, translate what's left.
  4. Apply, then Publish.

Turnout had a string like that in this very part. The Profile page's help text said "Turnout in Spanish arrives in Part 12.", which stopped being true the moment Spanish arrived. The new text is "Turnout speaks English and Spanish. Your choice applies as soon as you save." Seeding again brings it into the repository as English, and the glossary fills in its Spanish.

Importing replaces translations too. An app import doesn't carry the translated application with it. After every import to production, seed and publish again. That's why it's on the go-live checklist in Part 13.

Checkpoint

Turnout now speaks Spanish everywhere a person reads it in the app: the menu, every page, every button and message, and the category names. Emails stay English for now; the first exercise fixes that. Visitors switch from the menu bar, and people who sign in get their own language straight away.

Try it as Sofía

  1. Sign out, click Español, and open Discover. The page, the hero and the categories are in Spanish.
  2. Sign in as sofia.alvarez@example.com. Her profile says Español, so Spanish stays on after sign-in.
  3. Open Beginner Lessons (Bay Chess Club, which Sofía belongs to) and press Confirmar asistencia. The message is Spanish, from a text message.
  4. On Perfil, change the language to English and save. The next page is English.

Files

File Change
apexlang/turnout/application.apx Primary language English, language derived from the session
apexlang/turnout/shared-components/lists.apx Español and English in the menu bar
apexlang/turnout/shared-components/messages.apx RSVP messages and the two date formats, in English and Spanish
turnout/pages/ (six pages: 1, 10, 20, 30, 31, 100) Dates use WHEN_SHORT or WHEN_LONG
apexlang/turnout/shared-components/dynamic-translations.apx Six category names in Spanish
apexlang/turnout/pages/p00040-profile.apx Saving a language switches the session; new help text
translations/f193569_193569001_en_es.xlf The translated XLIFF file
translations/turnout_es_glossary.py Every English string and its Spanish

Exercises

  • Send emails in each person's language (the Part 10 exercise): give each template a Spanish twin, such as EVENT_REMINDER_ES, and pick the template from TO_USERS.PREFERRED_LANGUAGE when the message is queued.
  • Add a text message for TO_CLUBS_SLUG_UK in both languages and see the constraint error arrive in Spanish.
  • Turn the "spots left" text on Discover into a text message with a %0 substitution.
  • Stretch: add Portuguese. Define a third mapping, seed, translate from a copy of the glossary, apply and publish, and add Português to the menu bar.

Downloads for Part 12

Everything this part uses. Each file is plain text you can open in any editor before you run it.

Part 12 quiz

5 questions. Get 4 right to pass. You can retake it as often as you like.