(Re)Translating Nook Exchange

(Re)Translating Nook Exchange

 2021.11.24, last updated 2021.11.30 -  Jan Reggie Dela Cruz -  ~26 Minutes

In this post, I contribute to an open source project called Nook Exchange by introducing translations for several items in the website. I sourced these translations from an existing spreadsheet, and transformed the CSV data to JSON files for the project to use.

Background

The home page for Nook Exchange. Setting the language to German yields the new items, here such as the “Luxury car”, untranslated.

The home page for Nook Exchange. Setting the language to German yields the new items, here such as the “Luxury car”, untranslated.

Nook Exchange   is an Animal Crossing: New Horizons catalog site, where users can list which in-game items they have and they’re willing to part. A lot of items have recently been introduced in the game in a recent update, and the website has yet to update their translations for other languages.

The Nook Exchange project contains files which can be modified to accomodate these translations, and a recent spreadsheet by AeonSake   , which I will refer to as the “Aeon Spreadsheet”, contains the translations of these new items, directly sourcing them from the in-game files. However, the “format” for the Aeon Spreadsheet and that of the Nook Exchange files differ by a lot.

Objectives

I should be able to do the following:

  • Read the appropriate files from Nook Exchange and parse them properly
  • Read the Aeon Spreadsheet to extract data for all translations
  • Update the translation files in Nook Exchange using the data from the Aeon Spreadsheet

Note that I said “extract data for all translations”. I will override some of the old translations in the Nook Exchange project, as the game update has also introduced new translations to some old items.

I will be using yarn and TypeScript for this project, because that just works. The source code can be found in janreggie/nook-exchange-translator   .

Nook Exchange object structure

Before we can even get started changing the Nook Exchange files, we have to understand the files that we will be working on:

  • /src/items.json: Items
  • /src/variants.json: Adjectives
  • /src/translations/<loc>.json: Translations

Items

Central to the project’s purpose, Items represent the items found in the game. They’re all represented as a list in a single /src/items.json that looks like the following:

[
  {
    "id": 13018,
    "name": "Animal nose",
    "category": "accessories",
    "variants": [],
    "image": "AccessoryMouthHalloween0",
    "flags": 2,
    "source": "Able Sisters",
    "buy": 560,
    "sell": 140,
    "tags": [ "1.5.0" ]
  },
  {
    "id": 4463,
    "name": "3D glasses",
    "category": "accessories",
    "variants": [ 2 ],
    "image": "AccessoryGlassThreed",
    "flags": 2,
    "source": "Able Sisters",
    "buy": 490,
    "sell": 122,
    "tags": [ "1.0.0" ]
  },
  {
    "id": 12543,
    "name": "Nintendo Switch",
    "category": "miscellaneous",
    "variants": [ 2, 1 ],
    "image": "FtrSwitch_Remake",
    "flags": 6,
    "source": "Nintendo; Nook Shopping Daily Selection",
    "buy": 29980,
    "sell": 7495,
    "kitCost": 7,
    "tags": [ "1.1.0" ]
  },
  {
    "id": 1779,
    "name": "Table with cloth",
    "category": "housewares",
    "variants": [ 1, 8 ],
    "image": "FtrTableCloth_Remake",
    "flags": 10,
    "source": "Nook's Cranny",
    "buy": 5100,
    "sell": 1275,
    "kitCost": 2,
    "tags": [ "1.0.0", "restaurant", "party", "table" ]
  },
  {
    "id": 3449,
    "name": "Wooden stool",
    "category": "housewares",
    "variants": [ 8, 5 ],
    "image": "FtrWoodenStoolS_Remake",
    "flags": 12,
    "source": "Crafting",
    "sell": 480,
    "kitCost": 2,
    "recipe": [ 168, "Peppy villagers", [ 4, "Wood" ] ],
    "tags": [ "1.0.0", "wooden", "chair" ]
  },
  // ...
]

Most of these fields will be irrelevant to us. Let us focus on the following four:

  • id: number lists the unique (for Nook Exchange) ID of the Item.
  • name: string lists the unique name of the Item in the American English locale, with some minor modifications that will be described much later.
  • variants: [] | [number] | [number, number] describes the number of Variants and Patterns of this item. More on this in the next section.
  • recipe? : [number, string, ...Array<[number,string]>] describes the Material Items that this Item may need. More on this much later.

The Item’s ID will be important in interacting with the other files in Nook Exchange, while Name will be important in interacting with the Aeon Spreadsheet.

Variants, Patterns, and Adjectives

Ordering an Item with a certain Variant (here, &ldquo;Neon blue &amp; neon red&rdquo;).

Ordering an Item with a certain Variant (here, “Neon blue & neon red”).

&ldquo;Customizing&rdquo; an in-game item by changing its Variant and Pattern. Notice the tool-tip.

“Customizing” an in-game item by changing its Variant and Pattern. Notice the tool-tip.

An item in-game may have multiple different “variations”, represented as a change in the item’s appearance, color, design, etc. In the game’s internal files, these “variations” are in the form of “Variants” and “Patterns”: the former determining the item’s general appearance, and the latter determining a variation of a certain feature of the item.

This Item only has one &ldquo;Variant&rdquo; (itself) but multiple &ldquo;Patterns&rdquo;, in this case, the color of the sheet.

This Item only has one “Variant” (itself) but multiple “Patterns”, in this case, the color of the sheet.

The &ldquo;Wooden stool&rdquo; has eight Variants and five Patterns

The “Wooden stool” has eight Variants and five Patterns

Let us name all the Variants or Patterns of a certain Item its “Adjectives”. Here is a sample of /src/variants.json. Note that key "13018" (“Animal nose”) does not exist in the file, because in the previous section we see it having variants: [].

{
  "4463": [ "White", "Black" ], // 3D glasses
  "12543": [ // Nintendo Switch
    [ "Neon blue & neon red", "Gray" ],
    [ "NA" ]
  ],
  "3449": [ // Wooden stool
    [ "Light wood", "White wood", "Cherry wood", "Dark wood",
      "Black", "Green", "Blue", "Pink wood" ],
    [ "None", "Orange", "Pink", "Blue", "Green" ]
  ],
  "1779": [ // Table with cloth
    [ "" ],
    [ "White", "Brown", "Green", "Red",
      "Pink", "Yellow", "Light green", "Light blue" ]
  ],
  // ...
}

The JSON file can be described as follows:

export type Adjectives = string[] | [string[], string[]]
type rawAdjectives = { [key: string]: Adjectives }

where key is the Item’s ID. The former string[] is followed by a subcategory of Items (more on this much later, don’t worry) while the latter is followed by most other Items.

While "NA" and "" are Adjectives that describe the “Nintendo Switch”’s only Pattern and the “Table with cloth”’s only Variant, I will not be treating them as such, because they will not appear in the Aeon Spreadsheet, as we will see in a while.

Note: The “Wooden stool” has five variants listed in Nook Exchange while the screencap in the game shows six. The “sixth Pattern” means that the Item is “custom-design customizable”, something which will be irrelevant in our case but is described in the flags field of that Item.

Translations

A &ldquo;Wooden stool&rdquo; in European Spanish. Notice, in the DIY section, the Materials that this Item will require: four &ldquo;Tablas normales&rdquo;.

A “Wooden stool” in European Spanish. Notice, in the DIY section, the Materials that this Item will require: four “Tablas normales”.

Finally, the Translations list the translations of all the Items and Adjectives per locale. They are stored in /src/translations/<loc>.json, where <loc> is as follows:

  • de : German
  • en-gb : British English
  • es-eu : European Spanish
  • es-us : (Latin) American Spanish
  • fr-eu : European French
  • fr-us : American (Canadian) French
  • it : Italian
  • ja : Japanese
  • ko : Korean
  • nl : Dutch
  • ru : Russian
  • zh-cn : Simplified Chinese
  • zh-tw : Traditional Chinese

Let us take a look at a sample of /src/translations/es-eu.json for the Items that I have listed above:

{
  "items": {
    "13018": [ "Nariz de animal" ],
    "4463": [
      "Gafa 3D",
      [ "Blanco", "Negro" ]
    ],
    "12543": [
      "Nintendo Switch",
      [ [ "Rojo neón / azul néon", "Gris" ],
        [ "" ] ]
    ],
    "1779": [
      "Mesa con mantel",
      [ [ "" ],
        [ "Blanco", "Marrón", "Verde", "Rojo",
          "Rosa", "Amarillo", "Verde claro", "Celeste" ] ]
    ],
    "3449": [
      "Taburete de madera",
      [
        [ "Madera clara", "Blanco", "Cereza", "Madera oscura",
          "Negro", "Verde", "Azul", "Rosa" ],
        [ "Ninguna", "Naranja", "Rosa", "Azul", "Verde" ] ]
    ],
    // ...
  },
  "materials": {
    "Wood": "Tablas normales",
    "Hardwood": "Tablas rígidas",
    "Maple leaf": "Hoja de arce",
    "Giant clam": "Almeja gigante",
    // ...
  }
}

For this file, we see a JSON Object with two keys:

  • items: Translations for each Item and its Adjectives, mapped via IDs
  • materials: Translations for special “Material Items”

Observe as well that the Patterns for the “Nintendo Switch” and the Variants for the “Wooden stool” are translated as [""].

The JSON file can be described as follows:

interface rawTranslations {
  items: { [key: string]: [ string, (string[] | [string[], string[]])? ] };
  materials: { [key: string]: string };
}

Sidenote on materials

In the game, Materials are special Items which the player can use to create some particular Item. For example, by referring to its entry in items.json, we see that the Materials for the “Wooden stool” is “Wood”, four of them.

Earlier, it is mentioned that an Item has an optional recipe field, that lists the “Materials” for a certain Item. materials lists all the translations for these Materials.

Parsing the source files

I have copied the relevant JSON files in /src/*.json and /src/translations to the /old-json folder in the nook-exchange-translator repository.

I used a tool called quicktype   to easily parse the entire JSON file and create “converters” for each, which parse these JSON files into usable objects and vice versa. While the interfaces generated by quicktype work, I modified them slightly to suit my needs.

The following code simply reads items.json and variants.json and prints out a small sample of their entries:

import { Convert as ItemsConvert, Item as ItemType } from './json-parser/Items'
import { Convert as AdjectivesConvert, Adjectives as AdjectivesType } from './json-parser/Adjectives'
import { LowercaseName } from './util'

const OLD_JSON_DIR = './old-json'

async function main () {
  // Read Nook Exchange data. Mapped using their Nook Exchange ID
  const NookExchangeItems : Map<number, ItemType> = ItemsConvert.fileToItems(`${OLD_JSON_DIR}/items.json`)
  const NookExchangeAdjectives : Map<number, AdjectivesType> = AdjectivesConvert.fileToAdjectives(`${OLD_JSON_DIR}/variants.json`)
  const nameToNookExchangeId = new Map<string, number>()
  for (const [k, v] of NookExchangeItems.entries()) {
    if (nameToNookExchangeId.has(v.name.toLocaleLowerCase())) {
      console.error(`Duplicate item ${v.name} in list of items`)
    }
    nameToNookExchangeId.set(LowercaseName(v.name), k)
  }

  const allMaterials = new Set<string>() // All material Items.
  for (const item of NookExchangeItems.values()) {
    if (!item.recipe) {
      continue
    }
    const [, , ...requirements] = item.recipe
    for (const item of requirements) {
      allMaterials.add(item[1])
    }
  }

  // Read some items to see if things go well
  for (const id of [13018, 4463, 12543, 1779, 3449]) {
    console.log(`For ID ${id}`)
    console.log('Item: ', NookExchangeItems.get(id))
    console.log('Adjectives: ', NookExchangeAdjectives.get(id))
    console.log()
  }
}

main()

Here, the “converters” ItemsConvert.fileToItems and AdjectivesConvert.fileToAdjectives read the respective JSON files and create two Map objects whose keys are the Item IDs.

I have also created a nameToNookExchangeId mapping the Nook Exchange Item names to their respective IDs, as well as allMaterials : Set<string> listing all mateirals, which I will be using much later on in recreating the Translations JSON files.

Running the above code yields the following output, which I have modified the slightly to reduce the line count:

$ yarn dev

For ID 13018
Item:  {
  id: 13018,
  name: 'Animal nose',
  category: 'accessories',
  variants: [],
  image: 'AccessoryMouthHalloween0',
  flags: 2,
  source: 'Able Sisters',
  buy: 560,
  sell: 140,
  tags: [ '1.5.0' ],
  recipe: undefined,
  kitCost: undefined
}
Adjectives:  undefined

For ID 4463
Item:  {
  id: 4463,
  name: '3D glasses',
  category: 'accessories',
  variants: [ 2 ],
  image: 'AccessoryGlassThreed',
  flags: 2,
  source: 'Able Sisters',
  buy: 490,
  sell: 122,
  tags: [ '1.0.0' ],
  recipe: undefined,
  kitCost: undefined
}
Adjectives:  [ 'White', 'Black' ]

For ID 12543
Item:  {
  id: 12543,
  name: 'Nintendo Switch',
  category: 'miscellaneous',
  variants: [ 2, 1 ],
  image: 'FtrSwitch_Remake',
  flags: 6,
  source: 'Nintendo; Nook Shopping Daily Selection',
  buy: 29980,
  sell: 7495,
  tags: [ '1.1.0' ],
  recipe: undefined,
  kitCost: 7
}
Adjectives:  [ [ 'Neon blue & neon red', 'Gray' ], [ 'NA' ] ]

For ID 1779
Item:  {
  id: 1779,
  name: 'Table with cloth',
  category: 'housewares',
  variants: [ 1, 8 ],
  image: 'FtrTableCloth_Remake',
  flags: 10,
  source: "Nook's Cranny",
  buy: 5100,
  sell: 1275,
  tags: [ '1.0.0', 'restaurant', 'party', 'table' ],
  recipe: undefined,
  kitCost: 2
}
Adjectives:  [
  [ '' ],
  [ 'White', 'Brown', 'Green', 'Red',
    'Pink', 'Yellow', 'Light green', 'Light blue' ]
]

For ID 3449
Item:  {
  id: 3449,
  name: 'Wooden stool',
  category: 'housewares',
  variants: [ 8, 5 ],
  image: 'FtrWoodenStoolS_Remake',
  flags: 12,
  source: 'Crafting',
  buy: undefined,
  sell: 480,
  tags: [ '1.0.0', 'wooden', 'chair' ],
  recipe: [ 168, 'Peppy villagers', [ 4, 'Wood' ] ],
  kitCost: 2
}
Adjectives:  [
  [ 'Light wood', 'White wood', 'Cherry wood', 'Dark wood',
    'Black', 'Green', 'Blue', 'Pink wood' ],
  [ 'None', 'Orange', 'Pink', 'Blue', 'Green' ]
]

Okay. Let’s move on.

Aeon Spreadsheet and CSV files’ structure

I downloaded the Google Sheet into the nook-exchange-translator directory and ran the following command to convert the spreadsheet into a bunch of CSV files:

xlsx2csv -a "ACNH Translations [v2.0.0].xlsx" aeon-csvs

The structure of the files is very straightforward: the header of the Aeon CSVs lists the various locales, and each row contains the translations for each “entity” of the game, where an “entity” is a much more general representation of an in-game item. Entites include the Items in the Nook Exchange project, its Variants and Patterns, and other “objects” that the project doesn’t list.

Let us look at some rows for a few files: Furniture.csv, Item Variant Names.csv, and Item Pattern Names.csv:

Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh

// Furniture.csv
Ftr_12543,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,консоль Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch
Ftr_01779,Tischdeckentisch,table with cloth,tavolo con tovaglia,tafel met tafelkleed,стол со скатертью,table nappée,mesa con mantel,table with cloth,table nappée,mesa con mantel,クロスつきテーブル,사각 테이블,附桌巾桌子,附桌巾桌子
Ftr_03449,Holzschemel,wooden stool,sgabello di legno,houten kruk,деревянный табурет,tabouret en bois,taburete de madera,wooden stool,tabouret en bois,taburete de madera,もくせいスツール,목제 스툴,木製凳子,木制凳子

// Item Variant Names.csv
Ftr_12543_0,Neon-blau/neon-rot,Neon blue & neon red,Blu neon/rosso neon,Neonblauw-neonrood,Неон. синий и красный,Bleu néon et rouge néon,Rojo neón / azul néon,Neon blue & neon red,Bleu néon et rouge néon,Rojo neón y azul neón,ネオンブルー・ネオンレッド,네온 블루&네온 레드,電光藍‧電光紅,电光蓝·电光红
Ftr_12543_1,Grau,Gray,Grigio,Grijs,Серый,Gris,Gris,Gray,Gris,Gris,グレー,그레이,灰色,灰色
Ftr_03449_0,Helles Holz,Light wood,Legno chiaro,Blank,Светлое дерево,Bois clair,Madera clara,Light wood,Bois clair,Madera clara,ライトウッド,라이트 우드,淺木色,浅木色
Ftr_03449_1,Weißes Holz,White wood,Legno bianco,Wit,Белое дерево,Bois blanc,Blanco,White wood,Bois blanc,Blanco,ホワイトウッド,화이트 우드,白木色,白木色
Ftr_03449_2,Kirschholz,Cherry wood,Legno di ciliegio,Rood,Вишневое дерево,Merisier,Cereza,Cherry wood,Merisier,Cereza,チェリーウッド,체리 우드,櫻桃木色,樱桃木色
Ftr_03449_3,Dunkles Holz,Dark wood,Legno scuro,Bruin,Темное дерево,Bois sombre,Madera oscura,Dark wood,Bois sombre,Madera oscura,ダークウッド,다크 우드,深木色,深木色
Ftr_03449_4,Schwarz,Black,Nero,Zwart,Черный цвет,Noir,Negro,Black,Noir,Negro,ブラック,블랙,黑色,黑色
Ftr_03449_5,Grün,Green,Verde,Groen,Зеленый цвет,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
Ftr_03449_6,Blau,Blue,Blu,Blauw,Синий цвет,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
Ftr_03449_7,Rosa Holz,Pink wood,Legno rosa,Roze,Розовое дерево,Bois rose,Rosa,Pink wood,Bois rose,Madera rosada,ピンクウッド,핑크 우드,粉紅木色,粉红木色

// Item Pattern Names.csv
Ftr_01779_0,Weiß,White,Bianco,Wit,Белый,Blanc,Blanco,White,Blanc,Blanco,ホワイト,화이트,白色,白色
Ftr_01779_1,Braun,Brown,Marrone,Bruin,Коричневый,Brun,Marrón,Brown,Brun,Marrón,ブラウン,브라운,棕色,棕色
Ftr_01779_2,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
Ftr_01779_3,Rot,Red,Rosso,Rood,Красный,Rouge,Rojo,Red,Rouge,Rojo,レッド,레드,紅色,红色
Ftr_01779_4,Rosa,Pink,Rosa,Roze,Розовый,Rose,Rosa,Pink,Rose,Rosa,ピンク,핑크,粉紅色,粉红
Ftr_01779_5,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
Ftr_01779_6,Hellgrün,Light green,Verde chiaro,Lichtgroen,Светло-зеленый,Vert clair,Verde claro,Light green,Vert clair,Verde claro,ライトグリーン,라이트 그린,淺綠色,浅绿色
Ftr_01779_7,Hellblau,Light blue,Blu chiaro,Lichtblauw,Голубой,Bleu clair,Celeste,Light blue,Bleu pâle,Celeste,ライトブルー,라이트 블루,淺藍色,浅蓝色
Ftr_03449_0,Ohne,None,Nulla,Geen,Без подушки,Sans,Natural,None,Sans,Natural,なし,없음,無,无
Ftr_03449_1,Orange,Orange,Arancio,Oranje,Оранжевый,Orange,Naranja,Orange,Orange,Naranja,オレンジ,오렌지,橘色,橘色
Ftr_03449_2,Rosa,Pink,Rosa,Roze,Розовый,Rose,Rosa,Pink,Rose,Rosa,ピンク,핑크,粉紅色,粉红
Ftr_03449_3,Blau,Blue,Blu,Blauw,Синий,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
Ftr_03449_4,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色

Using the IDs for Item files such as Furniture.csv (the “Aeon ID”), one can look at the Variants and Patterns at Item Variant Names.csv and Item Pattern Names.csv. As noticed, Item Variant Names.csv doesn’t list the one (empty) Variant for the “Table with cloth” and Item Pattern Names.csv doesn’t list the one (empty) Pattern for the “Nintendo Switch”.

Parsing these files should be simple. I created a custom type AeonTranslations which contains the ID of that “entity” as well as its translations:

import { parseFile } from 'fast-csv'

export interface AeonTranslations {
  Id: string;
  EUde: string;
  EUen: string;
  EUit: string;
  EUnl: string;
  EUru: string;
  EUfr: string;
  EUes: string;
  USen: string;
  USfr: string;
  USes: string;
  JPja: string;
  KRko: string;
  TWzh: string;
  CNzh: string;
}

// This will be important in writing the new JSON translations files
export function GetTranslation (translations : AeonTranslations, locale: string) : string {
  switch (locale) {
    case 'de': return translations.EUde
    case 'en-gb': return translations.EUen
    // Other cases go here...
    default: return ''
  }
}

export async function Parser (filename : string) : Promise<AeonTranslations[]> {
  return new Promise((resolve, reject) => {
    const result : AeonTranslations[] = []
    parseFile(filename, { headers: true })
      .on('error', (error : any) => reject(error))
      .on('data', (row : AeonTranslations) => result.push(row))
      .on('end', () => resolve(result))
  })
}

Putting it in our /src/app.ts:

import { AeonTranslations, GetTranslation, Parser as AeonParser } from './aeon-parser'

async function main() {
  // ...

  const allItemTranslations = new Map<string, AeonTranslations>() // Aeon ID -> AeonTranslations
  const nameToAeonId = new Map<string, string>() // lowercaseName -> AeonTranslations
  const insertAeonTranslation = (translation : AeonTranslations) => {
    if (allItemTranslations.has(translation.Id)) {
      throw new Error(`Item ${translation.USen} of ID ${translation.Id} already exists as ${allItemTranslations.get(translation.Id)!.USen}`)
    }
    allItemTranslations.set(translation.Id, translation)

    const name = LowercaseName(translation.USen)
    if (nameToAeonId.has(name)) {
      throw new Error(`In inserting ${translation.Id}, item ${name} already exists as ID ${nameToAeonId.get(name)}`)
    }
    nameToAeonId.set(name, translation.Id)
  }
  const insertAeonTranslations = (translations : AeonTranslations[]) => { translations.forEach(item => insertAeonTranslation(item)) }

  const nonSpecialCategories = [ 'Art', 'Bug Models', 'Bugs', 'Crafting Items', 'Dishes', 'Door Deco', 'Etc', 'Event Items', 'Fencing', 'Fish Models', 'Fish', 'Floors', 'Fossils', 'Furniture', 'Gyroids', 'Money', 'Music', 'Plants', 'Posters', 'Rugs', 'Sea Creatures', 'Shells', 'Tools', 'Turnips', 'Umbrellas', 'Wallpaper' ]
  insertAeonTranslations(await OpenAeons(nonSpecialCategories))

  // All Adjective Translations go here.
  // Map AeonItemId -> Translation.USen -> Translations object
  const allAdjectiveVariantTranslations = new Map<string, Map<string, AeonTranslations>>()
  const allAdjectivePatternTranslations = new Map<string, Map<string, AeonTranslations>>()

  for (const item of (await OpenAeon('Item Variant Names'))) {
    const ids = item.Id.split('_')
    if (ids.length !== 3) {
      throw new Error(`invalid id ${item.Id} for item ${item.USen}`)
    }
    const itemId = ids[0] + '_' + ids[1]

    let mp = allAdjectiveVariantTranslations.get(itemId)
    if (mp === undefined) {
      mp = new Map()
      allAdjectiveVariantTranslations.set(itemId, mp)
    }
    mp.set(item.USen, item)
  }

  for (const item of (await OpenAeon('Item Pattern Names'))) {
    const ids = item.Id.split('_')
    if (ids.length !== 3) {
      console.error(`invalid id ${item.Id} for item ${item.USen}`)
      return
    }
    const itemId = ids[0] + '_' + ids[1]

    let mp = allAdjectivePatternTranslations.get(itemId)
    if (mp === undefined) {
      mp = new Map()
      allAdjectivePatternTranslations.set(itemId, mp)
    }
    mp.set(item.USen, item)
  }
}

// Opens an Aeon CSV
async function OpenAeon (name : string) : Promise<Array<AeonTranslations>> {
  return AeonParser(`${AEON_CSV_DIR}/${name}.csv`)
}

async function OpenAeons (names : Array<string>) : Promise<AeonTranslations[]> {
  return (await Promise.all(names.map(name => OpenAeon(name)))).reduce((a, b) => a.concat(b))
}

What I have set-up here are the following:

  • allItemTranslations contains all Translations for entities (except Adjectives) in the Aeon Spreadsheet. They are mapped using their Aeon IDs.
  • nameToAeonId maps the lowercase American English translation of that entity into that entity’s Aeon ID, just like nameToNookExchangeId. These lowercase “names” are the bridge between the Aeon CSVs and the Nook Exchange JSONs, because the Nook Exchange files’ IDs are different to that of the Aeon entities'.
    • While the above examples does make it appear that they are the same, this isn’t the case for all items in both files. We will also soon observe that mapping lowercase names will be problematic.
  • allAdjectiveVariantTranslations and allAdjectivePatternTranslations are self explanatory, maps whose keys are Aeon IDs, and values are themselves maps, whose keys are the American English translation and values are the corresponding AeonTranslations object.
    • An example: allAdjectiveVariantTranslations['Ftr_03449']['Dark wood'] returns an AeonTranslations object which contains the translations of the “Dark wood” variation for item with ID "Ftr_03449" (“Wooden stool”).
    • Mapping the American English translations will be useful because the Translations files that we will be creating will be based from /src/variants.json, which store the Adjectives in American English.

Special case: Clothing

Let us take a look at /aeon-csvs/Wetsuits.csv:

Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh
1258,Nook-Inc.-Taucheranzug,Nook Inc. wet suit,muta Nook Inc.,Nook Inc.-duikpak,гидрокостюм Nook Inc.,combi plongée Nook Inc.,traje de buceo de Nook Inc.,Nook Inc. wet suit,combi plongée Nook Inc.,traje de buceo de Nook Inc.,たぬきかいはつマリンスーツ,Nook Inc. 잠수복,Nook Inc.潛水衣,Nook Inc.潜水衣
1259,Blattmuster-Taucheranzug,leaf-print wet suit,muta con foglia,duikpak met bladerprint,гидрокостюм с листьями,combi plongée motif feuille,traje de buceo hoja,leaf-print wet suit,combi plongée motif feuille,traje de buceo hoja,はっぱがらマリンスーツ,나뭇잎 잠수복,葉子圖案潛水衣,叶子图案潜水衣
1260,Streifen-Taucheranzug,horizontal-striped wet suit,muta a righe orizzontali,gestreept duikpak,гидрокостюм в полоску,combi plongée à rayures,traje de buceo a rayas,horizontal-striped wet suit,combi plongée à rayures,traje de buceo a rayas,ボーダーのマリンスーツ,줄무늬 잠수복,粗橫紋潛水衣,粗横纹潜水衣

Let us then take a look at /Wetsuits Variants.csv:

Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh
1258_MarineSuit_12970,Keine Varianten,No Variations,Nessuna variazione,Geen variaties,Нет вариантов,Sans variation,Sin variaciones,No Variations,Sans variation,Sin variaciones,バリエーションなし,종류 없음,無其他種類,无其他种类
1259_MarineSuit_12981,Hellblau,Light blue,Blu chiaro,Lichtblauw,Голубой,Bleu clair,Celeste,Light blue,Bleu pâle,Celeste,ライトブルー,라이트 블루,淺藍色,浅蓝色
1259_MarineSuit_13098,Lila,Purple,Viola,Paars,Фиолетовый,Violet,Púrpura,Purple,Mauve,Morado,パープル,퍼플,紫色,紫色
1259_MarineSuit_13099,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
1259_MarineSuit_13100,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
1260_MarineSuit_12986,Rot,Red,Rosso,Rood,Красный,Rouge,Rojo,Red,Rouge,Rojo,レッド,레드,紅色,红色
1260_MarineSuit_13101,Blau,Blue,Blu,Blauw,Синий,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
1260_MarineSuit_13102,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
1260_MarineSuit_13103,Schwarz,Black,Nero,Zwart,Черный,Noir,Negro,Black,Noir,Negro,ブラック,블랙,黑色,黑色

Inserting these “Clothing Items” should be straightforward, although with a few differences:

  • Clothing Items only have Variants, not Patterns. In /src/items.json, their variants field is either an empty [] or contain one value e.g., [ 2 ].
  • The Variants of these Items can be found in multiple files just like the Items themselves, unlike the others which are all in Item Variant Names.csv.
  • There is a slight difference in how the IDs for the Variants are structured. No worries about this.

Let us put them all in allItemTranslations and allAdjectiveVariantTranslations:

async function main() {
  // ...

  // Read data from Clothing
  const clothingCategories = ['Accessories', 'Bags', 'Bottoms', 'Caps', 'Dress-Up', 'Handbags', 'Helmets', 'Shoes', 'Socks', 'Tops', 'Wetsuits' ]
  insertAeonTranslations(await OpenAeons(clothingCategories))

  // All Clothing Items have "variants", not "patterns".
  const clothingAdjectives = await OpenAeons(clothingCategories.map(name => name + ' Variants'))
  for (const item of clothingAdjectives) { // item.Id is in form ItemId_Category_VariantId
    const ids = item.Id.split('_')
    if (ids.length !== 3) {
      throw new Error(`invalid id ${item.Id} for item ${item.USen}`)
    }
    const itemId = ids[0]

    let mp = allAdjectiveVariantTranslations.get(itemId)
    if (mp === undefined) {
      mp = new Map()
      allAdjectiveVariantTranslations.set(itemId, mp)
    }
    mp.set(item.USen, item)
  }
}

Rectifying some items

Before I continue, running the above code will not work:

$ yarn dev
yarn run v1.22.17
$ ts-node ./src/app.ts
/home/janreggie/dev/nook-exchange-translator/src/app.ts:46
      throw new Error(`In inserting ${translation.Id}, item ${name} already exists as ID ${nameToAeonId.get(name)}`)
            ^
Error: In inserting 077, item tank already exists as ID Ftr_13515
    at insertAeonTranslation (/home/janreggie/dev/nook-exchange-translator/src/app.ts:46:13)
    at /home/janreggie/dev/nook-exchange-translator/src/app.ts:50:104
    at Array.forEach (<anonymous>)
    at insertAeonTranslations (/home/janreggie/dev/nook-exchange-translator/src/app.ts:50:88)
    at /home/janreggie/dev/nook-exchange-translator/src/app.ts:144:3
    at Generator.next (<anonymous>)
    at fulfilled (/home/janreggie/dev/nook-exchange-translator/src/app.ts:5:58)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

When the AeonTranslation object of name “tank” and ID 077 (clothing) is inserted, an error is thrown, saying that “tank” already exists as ID Ftr_13515. Let us look at these two items:

// Furniture.csv
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank,réservoir,cisterna,タンク,탱크,燃料桶,燃料桶

// Tops.csv
077,Tanktop,tank,canotta,tanktop,топ,débardeur,top básico,tank,camisole,top básico,タンクトップ,탱크톱,坦克背心,坦克背心

As seen, there are two entities whose American English name is “tank”. This will be problematic, as we will be unsure which we should link to the two “tanks” in Nook Exchange.

Let’s look at how Nook Exchange deals with it in /src/items.json:

[
  {
    "id": 3192,
    "name": "Tank",
    "category": "tops",
    "variants": [
      5
    ],
    "image": "TopsTexTopTshirtsNTanktop",
    "flags": 2,
    "source": "Able Sisters",
    "buy": 350,
    "sell": 87,
    "tags": [
      "1.0.0"
    ]
  },
  {
    "id": 13515,
    "name": "Tank (houseware)",
    "category": "housewares",
    "variants": [
      8,
      7
    ],
    "image": "FtrTankFactory_Remake",
    "flags": 10,
    "source": "HHP Office",
    "buy": 44000,
    "sell": 11000,
    "kitCost": 7,
    "tags": [
      "2.0.0",
      "lab",
      "facility",
      "ranch"
    ]
  },
]

Note that all name fields in items.json are unique. Because of this, the creator of the project had to create a distinction between the “tank” of water and the “tank” top. Solving this issue can be done by changing the column corresponding to USen in Furniture.csv:

// Old row
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank,réservoir,cisterna,タンク,탱크,燃料桶,燃料桶

// New row
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank (houseware),réservoir,cisterna,タンク,탱크,燃料桶,燃料桶

There will be other special files that we will need to rectify.

Special case: Art

In the game, and in the CSV file Art.csv, there exist “real” and “fake” art entities, which are indistinguishible by name.

The Nook Exchange project distinguishes “Real Art” Items from “Fake Art” Items. For example, the Item with name “Scary painting” is distinct from “Scary painting (fake)”.

I have added " (fake)" at the end of every USen entry and moved on.

Special case: Special items and “plural” entities

There are a lot more of these “duplicate” entities in files such as Etc.csv and Crafting Items.csv. Consider the following lines in Crafting Items.csv:

CraftMaterial_02767,Weichholz,softwood,legno morbido,zachthout,мягкая древесина,bois tendre,tabla flexible,softwood,bois tendre,tabla flexible,やわらかいもくざい,부드러운 목재,軟木材,软木材
CraftMaterial_02767_pl,Weichholz,softwood,legna morbida,zachthout,мягкая древесина,bois tendre,tablas flexibles,softwood,bois tendre,tablas flexibles,やわらかいもくざい,부드러운 목재,軟木材,软木材

The first row contains the translations of a single “softwood” item while the second row contains the translations of multiple “softwood” items (i.e., a stack of softwood). The translations may be different in Italian and Spanish, but they are the same for American English.

Looking at /old-json/translations/es-eu.json in the materials section, “Softwood” is translated as “Tablas flexibles”. I chose to eliminate the singular entries because that’s what the project currently does.

Special case: Photos

Photos.csv lists the translations of all “Photo Items” in the game. They are all very similar to each other, such that they all have the same Variants.

Looking at Item Variant Names.csv, only one Photo Item’s variants is listed: that of Bromide_06426 (“Cyrano’s Photo”). This is probably done to save space in the game developers’ part, because, after all, all Photo Items’ variants are the same.

I “populated” the other Photo Items’ variants using the data I have from Bromide_06426:

async function main() {
  // ...

  const photoVariants = allAdjectiveVariantTranslations.get('Bromide_06426')!
  for (const photoItem of await OpenAeon('Photos')) {
    insertAeonTranslation(photoItem)
    const localVariants = new Map()
    for (const [k, v] of photoVariants) {
      localVariants.set(k, { ...v, Id: photoItem.Id }) // Override Id for this particular photoItem
    }
    allAdjectiveVariantTranslations.set(photoItem.Id, localVariants)
  }
}

Special case: Unreleased items

Consider the following line in Tops.csv:

1234,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン

This entity is untranslated! This is perhaps an “unreleased item” in the game whose data appears in the CSV files. I have eliminated them all, since they cause some problems especially for entities with duplicate names, probably because their current names are just placeholders for some future thing in the game.

Recreating the translation files

In this step, the new translations JSON files will be stored in ./new-json, to be used by Nook Exchange.

The actual code is a bit overwhelming, and can be found in janreggie/nook-exchange-translator   , but I’ll be rewriting sections of it at a time here.

Before I continue with the code proper, here are some new constants we’ll be using:

10
11
const NEW_JSON_DIR = './new-json'
const LOCALES = ['de', 'en-gb', 'es-eu', 'es-us', 'fr-eu', 'fr-us', 'it', 'ja', 'ko', 'nl', 'ru', 'zh-cn', 'zh-tw']

Establishing a local translations type

163
164
165
166
167
168
169
170
171
172
// Write down the new Translations
for (const locale of LOCALES) {
  console.log(`Working at locale ${locale}`)
  const localize = (translations : AeonTranslations) => CapitalizeName(GetTranslation(translations, locale))
  const localTranslations : TranslationsType = {
    items: new Map(),
    materials: new Map()
  }

  // To continue...

Over here, we are iterating for every locale, and creating some hashmaps for our localTranslations object which will be written later on.

Additionally, we see a localize function here. It just takes in an AeonTranslations object, which we recall as a list of translations of some entity (Item or Adjective), and returns the appropriate translation for this locale.

The CapitalizeName function is here because all translations in the Nook Exchange project have their first character capitalized, except for Russian (and CJK, obviously) names:

// util.ts

// capitalize the first letter of a name
export function CapitalizeName (name : string) : string {
  if (!name) { return '' }
  const first = name.charAt(0)
  if (/[а-яА-ЯЁё]/.test(first)) { return name } // Skip Russian to follow Nook Exchange
  return first.toUpperCase() + name.slice(1)
}

Going through every item and its translations

170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
  // Previous section...

  // Iterate for every Item that exists both in the Aeon CSVs and Nook Exchange JSON
  for (const [aeonItemId, aeonTranslations] of allItemTranslations) {
    const nookExchangeId = nameToNookExchangeId.get(LowercaseName(aeonTranslations.USen))
    if (nookExchangeId === undefined) {
      console.error(`Cannot find appropriate Nook Exchange ID for ${aeonTranslations.USen}, skipping...`)
      continue
    }

    const nookExchangeItem = NookExchangeItems.get(nookExchangeId)
    if (!nookExchangeItem) {
      throw new Error(`No Nook Exchange Item for ID ${nookExchangeId}, ${aeonTranslations.USen}`)
    }

    // Next section...

Recall that allItemTranslations contains the Translations for all (non-Adjective) entities. Some of these entities don’t belong at all in the Nook Exchange project, so they’re printed out, and nothing happens.

Note the use of LowercaseName because we have observed, albeit for a brief while, in §“Parsing the source files” that the keys for nameToNookExchangeId are the names in lowercase.

Looking for its and its Adjectives’ translations

183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
    // Previous section... 

    const localizedItem = localize(aeonTranslations)
    const localizedAdjectives = (() : (AdjectivesType | undefined) => {
      const originalAdjectives = NookExchangeAdjectives.get(nookExchangeId)
      if (originalAdjectives === undefined) { return undefined }

      switch (nookExchangeItem.variants.length) {
        case 0:
          return undefined

        case 1: {
          const aeonVariants = allAdjectiveVariantTranslations.get(aeonItemId)!
          return (originalAdjectives as string[]).map(variant => localize(aeonVariants.get(variant)!))
        }

        case 2: {
          const originalVariants = (originalAdjectives as [string[], string[]])[0]
          const originalPatterns = (originalAdjectives as [string[], string[]])[1]
          const aeonVariants = allAdjectiveVariantTranslations.get(aeonItemId)!
          const aeonPatterns = allAdjectivePatternTranslations.get(aeonItemId)!
          return [
            originalVariants.length === 1 ? [''] : (originalVariants).map(variant => localize(aeonVariants.get(variant)!)),
            originalPatterns.length === 1 ? [''] : (originalPatterns).map(pattern => localize(aeonPatterns.get(pattern)!))
          ]
        }
      }
    })()

    localTranslations.items.set(nookExchangeId, {
      item: localizedItem,
      adjectives: localizedAdjectives
    })
  }

  // Next section...

localizedItem is just the translation of that particular item. Nothing too complicated there. But localizedAdjectives is a different story.

The switch statement is there to determine what kind of combination of Variants and Patterns does the item have, or if it has none to begin with.

The key part here is the map statement, which takes in each element of originalAdjectives (recall that NookExchangeAdjectives is directly sourced from variants.json) and finds its equivalent in aeonVariants and aeonPatterns.

Note as well the original...length === 1 statement in case 2. Remember that some Items have “only one” Variant or Pattern in variants.json, (whether that be [''] or ['NA']) which says that that Item’s Variants or Patterns in the Aeon CSVs don’t exist at all, so running a aeonVariants.get or aeonPatterns.get will yield here undefined.

With the inner for loop closed, let’s move on.

Going through every Material Item

216
217
218
219
220
221
222
223
224
  // Previous section...

  // Let's roll per material
  for (const material of allMaterials) {
    const aeonId = nameToAeonId.get(LowercaseName(material))!
    localTranslations.materials.set(material, localize(allItemTranslations.get(aeonId)!))
  }

  // Next section...

Recall allMaterials in §“Parsing the source files”. This separate section of the JSON Translations will have to be filled out.

This is also the reason why I included some extra entities in allItemTranslations. For example, in Money.csv, only the entities “50,000 Bells” and “99,000 Bells” are used, and the rest are just a waste.

Writing the files

222
223
224
225
226
227
228
229
230
231
232
  // Previous section...

  // And now, let's print per locale!
  writeFile(
    `${NEW_JSON_DIR}/${locale}.json`,
    TranslationsConvert.translationsToJson(localTranslations),
    err => { if (err) { throw (err) } }
  )
}

// End of main function.

Finally, it writes the files using fs.writeFile. Nothing too surprising here.

Curious error: Watering can’s variants

Running the code above without making any adjustments to variants.json will yield an error. Doing some debugging (by console.log‘ing every nookExchangeItem in the inner for loop) lets us know that the operations stop at some item of ID 2379.

In variants.json, we see that item:

{
  // ...
  "2379": [
    [
      "Blue",
      "Orange",
      "Green",
      "Red",
      "Purple",
      "",
      "White",
      "Black"
    ],
    [
      "None"
    ]
  ],
  // ...
}

Curiously, one of the Variants of Item 2379 (Watering Can) is an empty string. Looking at the appropriate line in Item Variant Names.csv, I was able to deduce that the missing entry is “Pink”. I filled this one up, and running the code now works.

Checking the new files

The new files in /new-json will be different to /old-json/translations, obviously because of adding the translations for these new items, but some Items and their Adjectives have indeed changed.

I copied the files in /new-json over to /src/translations, overriding the existing files, then minifying each. I tested my local Nook Exchange instance, and the new translations work!

A luxury car&rsquo;s models, or rather, die Modelle des Luxuswagens

A luxury car’s models, or rather, die Modelle des Luxuswagens

Some diffs

In this section I’ll be using the European Spanish localization because that’s the non-English language I’m most familiar with.

The new update has updated some of the names of these items. For example, this “desert wallpaper” is now known as the “Moroccan wallpaper”:

A change in the translation of an item. Left is old, right is new.

A change in the translation of an item. Left is old, right is new.

There are several changes like that. And then, there is this curious diff that I stumbled upon:

These are both &lsquo;Camiseta n.º 1&rsquo;, right?

These are both ‘Camiseta n.º 1’, right?

Somehow they are different. Let us analyze both strings:

$ echo 'Camiseta n.º 1' | hexdump -C  # Left
00000000  43 61 6d 69 73 65 74 61  20 6e 2e c2 ba 20 31 0a  |Camiseta n... 1.|
00000010
$ echo 'Camiseta n.º 1' | hexdump -C  # Right
00000000  43 61 6d 69 73 65 74 61  20 6e 2e c2 ba c2 a0 31  |Camiseta n.....1|
00000010  0a                                                |.|
00000011

The difference between the two is that the old translation separates the 'º' and '1' with a space (0x20) while the new one separates the two with a non-breaking space (0xC2A0). Wild.

Closing

This has been quite a project for me. It took me about two weeks to do all of this, going back and forth with the code to make sure it’s presentable for this blogpost, as well as a lot of debugging to make sure that the output format is “very close” to the Nook Exchange files.

I would like to thank Paul Shen (paulshen)   for creating the Nook Exchange project, and for AeonSake   for ripping the translations from the game. They have done great things for this game’s community.

This is one of those times when I’m glad that I’m a programmer. I was using an open-source service, got frustrated with a missing feature, and used code to implement that feature.

As of now, the change is still an open PR   . I hope it will be merged in the near future. Edit: the author created the translations himself   . The pink watering can is still treated as an empty string, though.