diff --git a/addons/t9n/__init__.py b/addons/t9n/__init__.py
new file mode 100644
index 0000000000000..0650744f6bc69
--- /dev/null
+++ b/addons/t9n/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/addons/t9n/__manifest__.py b/addons/t9n/__manifest__.py
new file mode 100644
index 0000000000000..6ac8c7a01d92a
--- /dev/null
+++ b/addons/t9n/__manifest__.py
@@ -0,0 +1,19 @@
+{
+ "name": "Translations",
+ "version": "1.0",
+ "category": "TODO: find the appropriate category",
+ "description": "TODO: write a description of the module",
+ "depends": ["base", "web"],
+ "application": True,
+ "assets": {
+ "web.assets_backend": [
+ "t9n/static/src/**/*",
+ ],
+ },
+ "data": [
+ "security/ir.model.access.csv",
+ "views/t9n_project_views.xml",
+ "views/t9n_menu_views.xml",
+ ],
+ "license": "LGPL-3",
+}
diff --git a/addons/t9n/models/__init__.py b/addons/t9n/models/__init__.py
new file mode 100644
index 0000000000000..22e0bdf014aa7
--- /dev/null
+++ b/addons/t9n/models/__init__.py
@@ -0,0 +1,5 @@
+from . import language
+from . import message
+from . import project
+from . import resource
+from . import translation
diff --git a/addons/t9n/models/language.py b/addons/t9n/models/language.py
new file mode 100644
index 0000000000000..91f9a9232d182
--- /dev/null
+++ b/addons/t9n/models/language.py
@@ -0,0 +1,8 @@
+from odoo import fields, models
+
+
+class Language(models.Model):
+ _name = "t9n.language"
+ _description = "Language"
+
+ name = fields.Char("Language", required=True)
diff --git a/addons/t9n/models/message.py b/addons/t9n/models/message.py
new file mode 100644
index 0000000000000..a36404dcabcf4
--- /dev/null
+++ b/addons/t9n/models/message.py
@@ -0,0 +1,24 @@
+from odoo import fields, models
+
+
+class Message(models.Model):
+ """Models a localizable message, i.e. any textual content to be translated.
+ Messages are retrieved from a Resource.
+ A Message localized to a specific Language becomes a Translation.
+ """
+
+ _name = "t9n.message"
+ _description = "Localizable message"
+
+ body = fields.Text(
+ help="The actual, textual content to be translated.",
+ )
+ resource_id = fields.Many2one(
+ comodel_name="t9n.resource",
+ help="The resource (typically a file) from which the entry is coming from.",
+ )
+ translation_ids = fields.One2many(
+ comodel_name="t9n.translation",
+ inverse_name="source_id",
+ string="Translations",
+ )
diff --git a/addons/t9n/models/project.py b/addons/t9n/models/project.py
new file mode 100644
index 0000000000000..424edfecc080d
--- /dev/null
+++ b/addons/t9n/models/project.py
@@ -0,0 +1,36 @@
+from odoo import fields, models, api, _
+from odoo.exceptions import ValidationError
+
+
+class Project(models.Model):
+ """A project is a collection of Resources to be localized into a given set
+ of Languages.
+ """
+
+ _name = "t9n.project"
+ _description = "Translation project"
+
+ name = fields.Char("Project", required=True)
+ src_lang_id = fields.Many2one(
+ comodel_name="t9n.language",
+ string="Source Language",
+ help="The original language of the messages you want to translate.",
+ )
+ resource_ids = fields.One2many(
+ comodel_name="t9n.resource",
+ inverse_name="project_id",
+ string="Resources",
+ )
+ target_lang_ids = fields.Many2many(
+ comodel_name="t9n.language",
+ string="Languages",
+ help="The list of languages into which the project can be translated.",
+ )
+
+ @api.constrains("src_lang_id", "target_lang_ids")
+ def _check_source_and_target_languages(self):
+ for record in self:
+ if record.src_lang_id in record.target_lang_ids:
+ raise ValidationError(
+ _("Target languages must be different from source language.")
+ )
diff --git a/addons/t9n/models/resource.py b/addons/t9n/models/resource.py
new file mode 100644
index 0000000000000..792f71b9ca39e
--- /dev/null
+++ b/addons/t9n/models/resource.py
@@ -0,0 +1,16 @@
+from odoo import fields, models
+
+
+class Resource(models.Model):
+ _name = "t9n.resource"
+ _description = "Resource file"
+
+ name = fields.Char("Resource")
+ message_ids = fields.One2many(
+ comodel_name="t9n.message",
+ inverse_name="resource_id",
+ string="Entries to translate",
+ )
+ project_id = fields.Many2one(
+ comodel_name="t9n.project",
+ )
diff --git a/addons/t9n/models/translation.py b/addons/t9n/models/translation.py
new file mode 100644
index 0000000000000..4d511dd2dba5d
--- /dev/null
+++ b/addons/t9n/models/translation.py
@@ -0,0 +1,20 @@
+from odoo import fields, models
+
+
+class Translation(models.Model):
+ _name = "t9n.translation"
+ _description = "Message translated into a language"
+
+ body = fields.Text(
+ help="The actual content of the translation.",
+ )
+ source_id = fields.Many2one(
+ comodel_name="t9n.message",
+ string="Source message",
+ help="The original text, the source of the translation.",
+ )
+ lang_id = fields.Many2one(
+ comodel_name="t9n.language",
+ string="Language",
+ help="The language to which the translation translates the original message.",
+ )
diff --git a/addons/t9n/security/ir.model.access.csv b/addons/t9n/security/ir.model.access.csv
new file mode 100644
index 0000000000000..892a06d3a359f
--- /dev/null
+++ b/addons/t9n/security/ir.model.access.csv
@@ -0,0 +1,6 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_t9n_project_system,t9n.project.system,t9n.model_t9n_project,base.group_system,1,1,1,1
+access_t9n_language_system,t9n.language.system,t9n.model_t9n_language,base.group_system,1,1,1,1
+access_t9n_message_system,t9n.message.system,t9n.model_t9n_message,base.group_system,1,1,1,1
+access_t9n_resource_system,t9n.resource.system,t9n.model_t9n_resource,base.group_system,1,1,1,1
+access_t9n_translation_system,t9n.translation.system,t9n.model_t9n_translation,base.group_system,1,1,1,1
diff --git a/addons/t9n/static/src/core/app.js b/addons/t9n/static/src/core/app.js
new file mode 100644
index 0000000000000..79ff2d525fff2
--- /dev/null
+++ b/addons/t9n/static/src/core/app.js
@@ -0,0 +1,10 @@
+import { Component } from "@odoo/owl";
+
+/**
+ * The "root", the "homepage" of the translation application.
+ */
+export class App extends Component {
+ static components = {};
+ static props = {};
+ static template = "t9n.App";
+}
diff --git a/addons/t9n/static/src/core/app.xml b/addons/t9n/static/src/core/app.xml
new file mode 100644
index 0000000000000..737753809f19d
--- /dev/null
+++ b/addons/t9n/static/src/core/app.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ Hello World!
+
+
+
diff --git a/addons/t9n/static/src/web/open_app_action.js b/addons/t9n/static/src/web/open_app_action.js
new file mode 100644
index 0000000000000..dc66682513b26
--- /dev/null
+++ b/addons/t9n/static/src/web/open_app_action.js
@@ -0,0 +1,18 @@
+import { Component, xml } from "@odoo/owl";
+
+import { App } from "@t9n/core/app";
+
+import { registry } from "@web/core/registry";
+import { standardActionServiceProps } from "@web/webclient/actions/action_service";
+
+/**
+ * Wraps the application root, allowing us to open the application as a result
+ * of a call to the "t9n.open_app" client action.
+ */
+export class OpenApp extends Component {
+ static components = { App };
+ static props = { ...standardActionServiceProps };
+ static template = xml``;
+}
+
+registry.category("actions").add("t9n.open_app", OpenApp);
diff --git a/addons/t9n/views/t9n_menu_views.xml b/addons/t9n/views/t9n_menu_views.xml
new file mode 100644
index 0000000000000..a37d1dc343985
--- /dev/null
+++ b/addons/t9n/views/t9n_menu_views.xml
@@ -0,0 +1,11 @@
+
+
+
+ Translate
+ translate
+ t9n.open_app
+ main
+
+
+
+
diff --git a/addons/t9n/views/t9n_project_views.xml b/addons/t9n/views/t9n_project_views.xml
new file mode 100644
index 0000000000000..dbd1c557a8be5
--- /dev/null
+++ b/addons/t9n/views/t9n_project_views.xml
@@ -0,0 +1,35 @@
+
+
+
+ Projects
+ t9n.project
+ tree,form
+
+
+
+ t9n.project.form
+ t9n.project
+
+
+
+
+