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:
- The menu bar. Español and English are entries in the navigation bar list that add
&p_lang=esor&p_lang=ento the current page's address. A visitor can switch without signing in. - Signing in.
TO_AUTH.FINISH_SIGN_INends withapex_util.set_session_lang(preferred_language), so Sofía sees Spanish from her first page. - The Profile page. Saving a new language calls
apex_util.set_session_langstraight 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.
- 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.)
- 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. - Shared Components › Navigation Bar List › Navigation Bar › Create Entry: Sequence
3, List Entry LabelEspañol, Target type URL, URL Targetf?p=&APP_ID.:&APP_PAGE_ID.:&APP_SESSION.&p_lang=es. Under Conditions: Type Expression, PL/SQLnvl(apex_util.get_session_lang, 'en') <> 'es'. Create. - One more: Sequence
4, labelEnglish, URLf?p=&APP_ID.:&APP_PAGE_ID.:&APP_SESSION.&p_lang=en, condition Expressionapex_util.get_session_lang = 'es'. - Page 40, process Save profile. After the call to
to_user_api.update_profile(...);, add:
apex_util.set_session_lang(:P40_LANGUAGE);
- 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.
- Shared Components › Application Translations › Seed translatable text. Pick the Spanish mapping and Seed.
- 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:
- Open Fill your XLIFF file. It runs in your browser and uploads nothing.
- Choose the file you downloaded in Lesson 12.2. It fills every string the course knows and lists any that are left.
- 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.
- 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 |
- Page 20, process RSVP: replace the two quoted messages with
apex_lang.message('RSVP_GOING')andapex_lang.message('RSVP_WAITLISTED'), as in the code above. - 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.
- 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, change123456at 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.
- 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 |
- Wrap the category in
apex_lang.lang(...)on three pages: - Page 1, Events query:cat.name as categorybecomesapex_lang.lang(cat.name) as category. - Page 2, Clubs query:cat.name || nvl2(c.city, ...)becomesapex_lang.lang(cat.name) || nvl2(c.city, ...). - Page 32, Clubs query:cat.name || ' · ' || c.citybecomesapex_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:
- 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.
- 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:
- Change the primary app (or import a new version of it).
- Seed again. Strings you already translated keep their Spanish; new or changed strings arrive as English.
- Translate only those. Export the file, fill it from the glossary, translate what's left.
- 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
- Sign out, click Español, and open Discover. The page, the hero and the categories are in Spanish.
- Sign in as
sofia.alvarez@example.com. Her profile says Español, so Spanish stays on after sign-in. - Open Beginner Lessons (Bay Chess Club, which Sofía belongs to) and press Confirmar asistencia. The message is Spanish, from a text message.
- 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 fromTO_USERS.PREFERRED_LANGUAGEwhen the message is queued. - Add a text message for
TO_CLUBS_SLUG_UKin both languages and see the constraint error arrive in Spanish. - Turn the "spots left" text on Discover into a text message with a
%0substitution. - 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.
- Start hereturnout-app-part-11.zipTurnout at the end of Part 11, if you're starting at Part 12
- Finishedturnout-app-part-12.zipTurnout at the end of Part 12. Import it to compare, or to catch up
- part12 badge messagespart12_badge_messages.sqlLesson 12.4: creates the 34 badge and role messages
- f193569 193569001 en esf193569_193569001_en_es.xlfLesson 12.3: the translated XLIFF file, as a reference
- turnout es glossaryturnout_es_glossary.txtLesson 12.3: every English string and its Spanish
- Part 12 kitturnout-part-12.zipEverything above in one zip, with a README
- Start overturnout_reset.sqlRemoves every TO_ object from your schema
Part 12 quiz
5 questions. Get 4 right to pass. You can retake it as often as you like.