Files
flib/dictionary.lua
2022-02-15 20:04:24 -07:00

409 lines
14 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

local table = require("__flib__.table")
--- An easy-to-use dictionary system for storing localised string translations.
local flib_dictionary = {}
local inner_separator = ""
local separator = ""
local max_depth = settings.global["flib-dictionary-levels-per-batch"].value
local translation_timeout = 180
-- Depending on the value of `use_local_storage`, this will be tied to `global` or will be re-generated during `on_load`
local raw = {}
local use_local_storage = false
local function key_value(key, value)
return key .. inner_separator .. value .. separator
end
--- @class RawDictionary
local RawDictionary = {}
--- Adds a new translation to this dictionary.
---
--- This method **must not** be called after control stage initialization and migration. Doing so will result in
--- different languages having different sets of data.
--- @param internal string An unique, language-agnostic identifier for this translation.
--- @param translation LocalisedString
function RawDictionary:add(internal, translation)
local to_add = { "", internal, inner_separator, translation, separator }
local ref = self.ref
local i = self.i + 1
if i < 20 then
ref[i] = to_add
self.i = i
else
local r_i = self.r_i + 1
if r_i <= max_depth then
local new_level = { "", to_add }
ref[i] = new_level
self.ref = new_level
self.i = 2
self.r_i = r_i
else
local s_i = self.s_i + 1
self.s_i = s_i
local new_set = { "", to_add }
self.ref = new_set
self.strings[s_i] = new_set
self.i = 2
self.r_i = 1
end
end
end
--- Creates a new `RawDictionary`.
---
--- If `keep_untranslated` is `true`, translations that failed (begin with `Unknown key: `) will be added to the dictionary with their internal name as their translated name.
--- @param name string
--- @param keep_untranslated boolean
--- @param initial_contents? table<string, LocalisedString>
--- @return RawDictionary
function flib_dictionary.new(name, keep_untranslated, initial_contents)
if raw[name] then
error("Dictionary with the name `" .. name .. "` already exists.")
end
local initial_string = { "" }
local self = setmetatable({
-- Indices
i = 1,
r_i = 1,
s_i = 1,
-- Internal
ref = initial_string,
strings = { initial_string },
-- Meta
name = name,
}, { __index = RawDictionary })
for key, value in pairs(initial_contents or {}) do
self:add(key, value)
end
raw[name] = { strings = self.strings, keep_untranslated = keep_untranslated }
return self
end
--- Initializes the module's script data table.
---
--- Must be called at the **beginning** of `on_init` for initial setup, and at the **beginning** of
--- `on_configuration_changed` to reset all ongoing translations.
function flib_dictionary.init()
if not global.__flib then
global.__flib = {}
end
global.__flib.dictionary = {
in_process = {},
players = {},
raw = {},
translated = {},
}
if use_local_storage then
raw = {}
else
raw = global.__flib.dictionary.raw
end
end
--- Sets up the module's local references.
---
--- Must be called at the **beginning** of `on_load`.
---
--- If using `dictionary.set_use_local_storage`, your dictionaries must be re-generated **after** this function is
--- called.
function flib_dictionary.load()
if not use_local_storage and global.__flib and global.__flib.dictionary then
raw = global.__flib.dictionary.raw
end
end
--- Request all dictionaries for the given player.
---
--- The dictionary system stores dictionaries by language, not by player. Thus, if this player's language has already
--- been translated, the module will simply return the already existing dictionaries instead of translating them again.
---
--- If you wish to re-translate dictionaries, call `dictionary.init` and call this function for all online players.
---
--- The player must be connected to the game in order to call this function. Calling this function on a disconnected
--- player will throw an error.
--- @param player LuaPlayer
function flib_dictionary.translate(player)
if not player.connected then
error("Player must be connected to the game before this function can be called!")
end
local player_data = global.__flib.dictionary.players[player.index]
if player_data then
return
end
global.__flib.dictionary.players[player.index] = {
player = player,
status = "get_language",
requested_tick = game.tick,
}
player.request_translation({ "", "FLIB_LOCALE_IDENTIFIER", separator, { "locale-identifier" } })
end
local function request_translation(player_data)
local string = raw[player_data.dictionary].strings[player_data.i]
-- We use `while` instead of `if` here just in case a dictionary doesn't have any strings in it
while not string do
local next_dictionary = next(raw, player_data.dictionary)
if next_dictionary then
-- Set the next dictionary and reset index
player_data.dictionary = next_dictionary
player_data.i = 1
string = raw[next_dictionary].strings[1]
else
-- We're done!
player_data.status = "finished"
return
end
end
player_data.player.request_translation({
"",
key_value("FLIB_DICTIONARY_MOD", script.mod_name),
key_value("FLIB_DICTIONARY_NAME", player_data.dictionary),
key_value("FLIB_DICTIONARY_LANGUAGE", player_data.language),
key_value("FLIB_DICTIONARY_STRING_INDEX", player_data.i),
string,
})
player_data.requested_tick = game.tick
end
--- Checks for a skipped translation and re-request it after three seconds.
---
--- Must be called **during** `on_tick`.
---
--- This is to handle a very specific edge-case where translations that are requested on the same tick that a game is
--- saved will not be returned when that save is loaded.
function flib_dictionary.check_skipped()
local script_data = global.__flib.dictionary
local tick = game.tick
for _, player_data in pairs(script_data.players) do
-- If it's been longer than the timeout, request the string again
-- This is to solve a very rare edge case where translations requested on the same tick that a singleplayer game
-- is saved will not be returned when that save is loaded
if (player_data.requested_tick or 0) + translation_timeout <= tick then
if player_data.status == "get_language" then
player_data.player.request_translation({ "", "FLIB_LOCALE_IDENTIFIER", separator, { "locale-identifier" } })
end
if player_data.status == "translating" then
request_translation(player_data)
end
end
end
end
--- Escape match special characters
local function match_literal(s)
return string.gsub(s, "%-", "%%-")
end
local dictionary_match_string = key_value("^FLIB_DICTIONARY_MOD", match_literal(script.mod_name))
.. key_value("FLIB_DICTIONARY_NAME", "(.-)")
.. key_value("FLIB_DICTIONARY_LANGUAGE", "(.-)")
.. key_value("FLIB_DICTIONARY_STRING_INDEX", "(%d-)")
.. "(.*)$"
--- Processes a returned translation batch, then request the next batch or return the finished dictionaries.
---
--- Must be called **during** `on_string_translated`.
--- @param event_data on_string_translated
--- @return TranslationFinishedOutput?
function flib_dictionary.process_translation(event_data)
if not event_data.translated then
return
end
local script_data = global.__flib.dictionary
if string.find(event_data.result, "FLIB_DICTIONARY_NAME") then
local _, _, dict_name, dict_lang, string_index, translation = string.find(
event_data.result,
dictionary_match_string
)
if dict_name and dict_lang and string_index and translation then
local language_data = script_data.in_process[dict_lang]
-- In some cases, this can fire before on_configuration_changed
if not language_data then
return
end
local dictionary = language_data.dictionaries[dict_name]
if not dictionary then
return
end
local dict_data = raw[dict_name]
local player_data = script_data.players[event_data.player_index]
-- If this number does not match, this is a duplicate, so ignore it
if tonumber(string_index) == player_data.i then
-- Extract current string's translations
for str in string.gmatch(translation, "(.-)" .. separator) do
local _, _, key, value = string.find(str, "^(.-)" .. inner_separator .. "(.-)$")
if key then
-- If `keep_untranslated` is true, then use the key as the value if it failed
local failed = string.find(value, "Unknown key:")
if failed and dict_data.keep_untranslated then
value = key
elseif failed then
value = nil
end
if value then
dictionary[key] = value
end
end
end
-- Request next translation
player_data.i = player_data.i + 1
request_translation(player_data)
if player_data.status == "finished" then
-- We're done!
script_data.translated[dict_lang] = language_data.dictionaries
script_data.in_process[dict_lang] = nil
for _, player_index in pairs(language_data.players) do
script_data.players[player_index] = nil
end
return { dictionaries = language_data.dictionaries, language = dict_lang, players = language_data.players }
end
end
end
elseif string.find(event_data.result, "^FLIB_LOCALE_IDENTIFIER") then
local _, _, language = string.find(event_data.result, "^FLIB_LOCALE_IDENTIFIER" .. separator .. "(.*)$")
if language then
local player_data = script_data.players[event_data.player_index]
-- Handle a duplicate
if not player_data or player_data.status == "translating" then
return
end
player_data.language = language
-- Check if this language is already translated or being translated
local dictionaries = script_data.translated[language]
if dictionaries then
script_data.players[event_data.player_index] = nil
return { dictionaries = dictionaries, language = language, players = { event_data.player_index } }
end
local in_process = script_data.in_process[language]
if in_process then
table.insert(in_process.players, event_data.player_index)
player_data.status = "waiting"
return
end
-- Set up player data for translating
player_data.status = "translating"
player_data.dictionary = next(raw)
player_data.i = 1
-- Add language to in process data
script_data.in_process[language] = {
dictionaries = table.map(raw, function(_)
return {}
end),
players = { event_data.player_index },
}
-- Start translating
request_translation(player_data)
end
end
end
--- Cancels the translation of the player's dictionaries if they are the currently translating player.
---
--- If multiple players are waiting on these dictionaries, the translation duties will be handed off to the next player
--- in the list.
---
--- Must be called **during** `on_player_left_game`.
--- @param player_index number
function flib_dictionary.cancel_translation(player_index)
local script_data = global.__flib.dictionary
local player_data = script_data.players[player_index]
if player_data then
if player_data.status == "translating" then
local in_process = script_data.in_process[player_data.language]
if not in_process then
error("Dafuq?")
end
if #in_process.players > 1 then
-- Copy progress to another player with the same language
local first_player = in_process.players[1]
local first_player_data = script_data.players[first_player]
first_player_data.status = "translating"
first_player_data.dictionary = player_data.dictionary
first_player_data.i = player_data.i
-- Resume translating with the new player
request_translation(first_player_data)
else
-- Completely cancel the translation
script_data.in_process[player_data.language] = nil
end
elseif player_data.status == "waiting" then
local in_process = script_data.in_process[player_data.language]
-- Remove this player from the players table
for i, pi in pairs(in_process.players) do
if pi == player_index then
table.remove(in_process.players, i)
break
end
end
end
-- Delete this player's data
script_data.players[player_index] = nil
end
end
--- Set whether or not the module is using local storage mode.
---
--- Must be called in the **root scope** of your mod if you wish to use local storage.
---
--- Using local storage is a technique to reduce the amount of script data that the module saves in the `global` table.
--- If you enable this, you **must** re-build all of your dictionaries **during** `on_load`, but **after** calling
--- `dictionary.load`. Failure to do so will result in a desync.
---
--- **Only use this function if you understood the above explanation and if you are familiar with how desyncs can occur.**
---
--- # Examples
---
--- ```lua
--- local dictionary = require("__flib__.dictionary")
--- dictionary.set_use_local_storage(true)
--- ```
--- @param value boolean
function flib_dictionary.set_use_local_storage(value)
use_local_storage = value
end
--- A "raw" dictionary containing the actual `LocalisedString`s for translation.
---
--- This object **must not** be stored in the `global` table. Doing so will result in a desync if the game is saved and
--- loaded and you attempt to call methods on it.
---
--- # Examples
---
--- ```lua
--- local MyDictionary = dictionary.new("my_dictionary")
--- MyDictionary:add("iron-ore", {"item-name.iron-ore"})
--- ```
--- @class RawDictionary
--- The results of a translated language.
--- @class TranslationFinishedOutput
--- @field language string The language that was translated.
--- @field dictionaries table<string, table<string, string>> The resulting dictionaries.
--- @field players number[] The players who were waiting for this language to complete translation.
return flib_dictionary