feat: Add translations and language support

This commit is contained in:
jamesread
2025-11-10 13:20:40 +00:00
parent 3967b91cf0
commit 2ed564a403
17 changed files with 669 additions and 135 deletions

View File

@@ -12,43 +12,84 @@ import { createConnectTransport } from '@connectrpc/connect-web'
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import router from './resources/vue/router.js'
import App from './resources/vue/App.vue'
import combinedTranslations from '../lang/combined_output.json'
import {
initMarshaller
} from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js'
function initClient () {
function getSelectedLanguage() {
const storedLanguage = localStorage.getItem('olivetin-language');
if (storedLanguage && storedLanguage !== 'auto') {
return storedLanguage;
}
if (storedLanguage === 'auto') {
localStorage.removeItem('olivetin-language');
}
if (navigator.languages && navigator.languages.length > 0) {
return navigator.languages[0];
}
return 'en';
}
async function initClient () {
const transport = createConnectTransport({
baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
})
window.client = createClient(OliveTinApiService, transport)
window.initResponse = await window.client.init({})
const i18nSettings = createI18n({
legacy: false,
locale: getSelectedLanguage(),
fallbackLocale: 'en',
messages: combinedTranslations.messages,
postTranslation: (translated) => {
const params = new URLSearchParams(window.location.search)
if (params.has('debug-translations')) {
return '____'
} else {
return translated
}
}
})
return i18nSettings
}
function setupVue () {
function setupVue (i18nSettings) {
const app = createApp(App)
app.use(router)
app.use(i18nSettings)
// Make i18n instance accessible globally for language switching
window.i18n = i18nSettings.global
app.mount('#app')
}
function main () {
initClient()
// Expose websocket connection function globally so App.vue can call it after successful init
async function main () {
window.checkWebsocketConnection = checkWebsocketConnection
setupVue()
const i18nSettings = await initClient()
setupVue(i18nSettings)
initMarshaller()
// window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
// window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
}
main() // call self

View File

@@ -17,9 +17,10 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.8.0",
"picocrank": "^1.8.1",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3"
},
"devDependencies": {
@@ -732,6 +733,50 @@
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@intlify/core-base": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.12",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2362,9 +2407,9 @@
"license": "ISC"
},
"node_modules/picocrank": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.0.tgz",
"integrity": "sha512-YPGmXvw7vvjIcgrAe3io87kZDM+NUa+aiEYxk8CVqBzgI4koXeF+2VEGPHBwknZBBEbJfXsSdnxVwXrLKpWKfw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.1.tgz",
"integrity": "sha512-g3JIVY8W5EVDGG+tG83Z0vzhMnj+J/RYkt6/ssdzZofR+6EWfFqE1+DQiyqb6rzjNuj0Y3xy3bpIprtzY9SQ6Q==",
"license": "ISC",
"dependencies": {
"@hugeicons/core-free-icons": "^1.0.16",
@@ -3307,6 +3352,26 @@
}
}
},
"node_modules/vue-i18n": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

View File

@@ -30,9 +30,10 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.8.0",
"picocrank": "^1.8.1",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3"
}
}

View File

@@ -8,7 +8,7 @@
<template #user-info>
<div class="flex-row user-info" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">Login</router-link></span>
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
<span id="username-text">{{ username }}</span>
</router-link>
@@ -19,35 +19,32 @@
</Header>
<div id="layout">
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation && !initError" />
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
<div id="content" initial-martial-complete="{{ hasLoaded }}">
<main title="Main content">
<section v-if="initError" class="error-container error" style="text-align: center; padding: 2em;">
<h2>Failed to Initialize OliveTin</h2>
<p><strong>Error Message:</strong> {{ initErrorMessage }}</p>
<p>Please check the your browser console first, and then the server logs for more details.</p>
<button @click="retryInit" class="bad">Retry</button>
</section>
<router-view v-else :key="$route.fullPath" />
<router-view :key="$route.fullPath" />
</main>
<footer title="footer" v-if="showFooter && !initError">
<footer title="footer" v-if="showFooter">
<p>
<img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
OliveTin {{ currentVersion }}
</p>
<p>
<span>
<a href="https://docs.olivetin.app" target="_new">Documentation</a>
<a href="https://docs.olivetin.app" target="_new">{{ t('docs') }}</a>
</span>
<span>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">Raise an issue on
GitHub</a>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
</span>
<span>{{ serverConnection }}</span>
<span>{{ t('connected') }}</span>
<span>
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
</span>
</p>
<p>
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@@ -55,10 +52,29 @@
</footer>
</div>
</div>
<dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
<div class="dialog-content" @click.stop>
<h2>Select Language</h2>
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
<option v-for="(name, code) in availableLanguages" :key="code" :value="code">
{{ name }}
</option>
</select>
<p class="browser-languages">
Browser languages:
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
<span v-else>Not available</span>
</p>
<div class="dialog-buttons">
<button @click="closeLanguageDialog">Close</button>
</div>
</div>
</dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
import Header from 'picocrank/vue/components/Header.vue';
@@ -67,13 +83,16 @@ import { Menu01Icon } from '@hugeicons/core-free-icons'
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import logoUrl from '../../OliveTinLogo.png';
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
const router = useRouter();
const sidebar = ref(null);
const username = ref('notset');
const isLoggedIn = ref(false);
const serverConnection = ref('Connected');
const serverConnection = ref(true);
const currentVersion = ref('?');
const bannerMessage = ref('');
const bannerCss = ref('');
@@ -82,10 +101,46 @@ const showFooter = ref(true)
const showNavigation = ref(true)
const showLogs = ref(true)
const showDiagnostics = ref(true)
const initError = ref(false)
const initErrorMessage = ref('')
const showLoginLink = ref(true)
const languageDialog = ref(null)
const browserLanguages = ref([])
const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
const languagePreference = ref(initialLanguagePreference || 'auto')
const selectedLanguage = ref(languagePreference.value)
// Available languages with display names
const availableLanguages = {
'auto': 'Browser Language',
'en': 'English',
'de-DE': 'Deutsch',
'es-ES': 'Español',
'it-IT': 'Italiano',
'zh-Hans-CN': '简体中文'
}
// Computed property to get current language display name
const currentLanguageName = computed(() => {
if (languagePreference.value === 'auto') {
return availableLanguages['auto']
}
return availableLanguages[languagePreference.value] || languagePreference.value
})
function getBrowserLanguage() {
if (navigator.languages && navigator.languages.length > 0) {
return navigator.languages[0]
}
if (navigator.language) {
return navigator.language
}
return 'en'
}
function toggleSidebar() {
if (sidebar.value && showNavigation.value) {
sidebar.value.toggle()
@@ -93,7 +148,10 @@ function toggleSidebar() {
}
function updateHeaderFromInit() {
if (window.initResponse) {
if (!window.initResponse) {
return
}
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
@@ -107,31 +165,28 @@ function updateHeaderFromInit() {
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
}
renderSidebar()
if (window.checkWebsocketConnection) {
window.checkWebsocketConnection()
}
// Export the function to window so other components can call it
window.updateHeaderFromInit = updateHeaderFromInit
async function requestInit() {
try {
const initResponse = await window.client.init({})
// Store init response first so the login view can read options (e.g., authLocalLogin)
window.initResponse = initResponse
// Check if login is required and redirect if so (after storing initResponse)
if (initResponse.loginRequired) {
router.push('/login')
return
}
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
}
window.updateHeaderFromInit()
function renderSidebar() {
if (!sidebar.value) {
return
}
if (typeof sidebar.value.clear === 'function') {
sidebar.value.clear()
}
if (showNavigation.value && sidebar.value) {
for (const rootDashboard of initResponse.rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
@@ -143,51 +198,79 @@ async function requestInit() {
}
sidebar.value.addSeparator()
sidebar.value.addRouterLink('Entities')
sidebar.value.addRouterLink('Entities', t('nav.entities'))
if (showLogs.value) {
sidebar.value.addRouterLink('Logs')
sidebar.value.addRouterLink('Logs', t('nav.logs'))
}
if (showDiagnostics.value) {
sidebar.value.addRouterLink('Diagnostics')
sidebar.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
}
}
hasLoaded.value = true;
initError.value = false;
function openLanguageDialog() {
selectedLanguage.value = languagePreference.value
// Only start websocket connection after successful init
if (window.checkWebsocketConnection) {
window.checkWebsocketConnection()
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
} else {
browserLanguages.value = []
}
} catch (error) {
console.error("Error initializing client", error)
initError.value = true
initErrorMessage.value = error.message || 'Failed to connect to OliveTin server'
window.initError = true
window.initErrorMessage = error.message || 'Failed to connect to OliveTin server'
window.initCompleted = false
serverConnection.value = 'Disconnected'
if (languageDialog.value) {
languageDialog.value.showModal()
}
}
function retryInit() {
initError.value = false
initErrorMessage.value = ''
window.initError = false
window.initErrorMessage = ''
window.initCompleted = false
requestInit()
function closeLanguageDialog() {
if (languageDialog.value) {
languageDialog.value.close()
}
}
function changeLanguage() {
if (!window.i18n || !selectedLanguage.value) {
return
}
if (selectedLanguage.value === 'auto') {
localStorage.removeItem('olivetin-language')
languagePreference.value = 'auto'
window.i18n.locale.value = getBrowserLanguage()
} else {
window.i18n.locale.value = selectedLanguage.value
localStorage.setItem('olivetin-language', selectedLanguage.value)
languagePreference.value = selectedLanguage.value
}
// Update sidebar with new translations
if (sidebar.value) {
renderSidebar()
}
closeLanguageDialog()
}
function handleDialogClick(event) {
// Close dialog when clicking on the backdrop
if (event.target === languageDialog.value) {
closeLanguageDialog()
}
}
window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => {
serverConnection.value = 'Connected';
// Initialize global state
window.initError = false
window.initErrorMessage = ''
window.initCompleted = false
requestInit()
serverConnection.value = true;
updateHeaderFromInit()
// Initialize selected language from stored preference
selectedLanguage.value = languagePreference.value
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
}
})
</script>
@@ -204,4 +287,51 @@ onMounted(() => {
.user-link:hover {
text-decoration: underline;
}
.language-dialog {
border: 1px solid var(--border-color, #ccc);
border-radius: 0.5rem;
padding: 0;
max-width: 400px;
width: 90%;
}
.language-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
padding: 1.5rem;
}
.dialog-content h2 {
margin-top: 0;
margin-bottom: 1rem;
}
.language-select {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
font-size: 1rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.25rem;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialog-buttons button {
padding: 0.5rem 1rem;
cursor: pointer;
}
.browser-languages {
font-size: 0.875rem;
color: var(--fg2, #555);
margin-bottom: 1rem;
}
</style>

View File

@@ -105,7 +105,7 @@ function waitForInitAndLoadDashboard() {
}, 1000)
// Check if init has completed successfully
if (window.initCompleted && window.initResponse) {
if (window.initResponse) {
getDashboard()
} else if (window.initError) {
// Init failed, show error immediately
@@ -118,7 +118,7 @@ function waitForInitAndLoadDashboard() {
} else {
// Init hasn't completed yet, poll for completion
checkInitInterval = setInterval(() => {
if (window.initCompleted && window.initResponse) {
if (window.initResponse) {
clearInterval(checkInitInterval)
checkInitInterval = null
getDashboard()

View File

@@ -1,12 +1,12 @@
<template>
<Section title="Logs" :padding="false">
<Section :title="t('logs.title')" :padding="false">
<template #toolbar>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input placeholder="Filter current page" v-model="searchText" />
<input :placeholder="t('search-filter')" v-model="searchText" />
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
@@ -16,26 +16,24 @@
</label>
</template>
<p class = "padding">This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
<p class = "padding">{{ t('logs.page-description') }}</p>
<div v-show="filteredLogs.length > 0">
<table class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Metadata</th>
<th>Status</th>
<th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td>
<span class="icon" v-html="log.actionIcon"></span>
<router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.actionTitle }}
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
@@ -59,8 +57,8 @@
</div>
<div v-show="logs.length === 0" class="empty-state">
<p>There are no logs to display.</p>
<router-link to="/">Return to index</router-link>
<p>{{ t('logs.no-logs-to-display') }}</p>
<router-link to="/">{{ t('return-to-index') }}</router-link>
</div>
</Section>
</template>
@@ -69,6 +67,7 @@
import { ref, computed, onMounted } from 'vue'
import Pagination from '../components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue'
import { useI18n } from 'vue-i18n'
const logs = ref([])
const searchText = ref('')
@@ -77,6 +76,8 @@ const currentPage = ref(1)
const loading = ref(false)
const totalCount = ref(0)
const { t } = useI18n()
const filteredLogs = computed(() => {
if (!searchText.value) {
return logs.value

2
lang/Makefile Normal file
View File

@@ -0,0 +1,2 @@
default:
go run main.go

36
lang/README.md Normal file
View File

@@ -0,0 +1,36 @@
Hey, thanks for reading this quick introduction to translations. The project founder
only speaks two languages; English, and Bad English (!), so the initial translations
have all been AI-generated. It is assumed that "something is better than nothing".
It would be most welcome to have human contributors who are native speakers improve these
translations, or add new ones.
## How to contribute
If a translation file does not exist for your locale, you can create one by copying
en.yaml and changing the locale code in the filename. You can view the language that
your browser reports by opening the "Select Language" dialog from the footer.
## File format
Internally, OliveTin uses the vue-i18n library for translations. This does support
language pluralization and other advanced features. For docs, check the following;
https://vue-i18n.intlify.dev/guide/essentials/pluralization.html
The translation files are in YAML format. Each file contains key-value pairs.
OliveTin developers then "process" these files into JSON format used for the app.
If you are able, it would be appreciated if you run `make` in the language directory
to process your language file before submitting a PR. This will ensure that the JSON
file is up to date. If you don't understand how to do this, don't worry; just submit
the YAML file and the developers will take care of it.
## Contributing improvements
Please check out the file `CONTRIBUTING.md` for instructions on how to submit a pull
request with your improvements.
As always, if you need any help, please feel free to raise an issue on GitHub or
jump into the Discord server for OliveTin.

View File

@@ -0,0 +1 @@
{"messages":{"de-DE":{"connected":"Verbunden","docs":"Dokumentation","login-button":"Login","logs.action":"Aktion","logs.metadata":"Metadaten","logs.no-logs-to-display":"Es gibt keine Protokolle zu anzeigen.","logs.page-description":"Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.","logs.status":"Status","logs.timestamp":"Zeitstempel","logs.title":"Protokolle","nav.actions":"Aktionen","nav.diagnostics":"Diagnostik","nav.entities":"Entitäten","nav.logs":"Protokolle","raise-issue":"Ein Problem melden auf GitHub","return-to-index":"Zurück zur Startseite","search-filter":"Filter aktuelle Seite","welcome":"Willkommen bei OliveTin"},"en":{"connected":"Connected","docs":"Documentation","login-button":"Login","logs.action":"Action","logs.metadata":"Metadata","logs.no-logs-to-display":"There are no logs to display.","logs.page-description":"This is a list of logs from actions that have been executed. You can filter the list by action title.","logs.status":"Status","logs.timestamp":"Timestamp","logs.title":"Logs","nav.actions":"Actions","nav.diagnostics":"Diagnostics","nav.entities":"Entities","nav.logs":"Logs","raise-issue":"Raise an issue on GitHub","return-to-index":"Return to index","search-filter":"Filter current page","welcome":"Welcome to OliveTin"},"es-ES":{"connected":"Conectado","docs":"Documentación","login-button":"Iniciar sesión","logs.action":"Acción","logs.metadata":"Metadatos","logs.no-logs-to-display":"No hay registros para mostrar.","logs.page-description":"Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.","logs.status":"Status","logs.timestamp":"Timestamp","logs.title":"Registros","nav.actions":"Acciones","nav.diagnostics":"Diagnósticos","nav.entities":"Entidades","nav.logs":"Registros","raise-issue":"Reportar un problema en GitHub","return-to-index":"Volver a la página principal","search-filter":"Filtrar página actual","welcome":"Bienvenido a OliveTin"},"it-IT":{"connected":"Connesso","docs":"Documentazione","login-button":"Login","logs.action":"Azione","logs.metadata":"Metadati","logs.no-logs-to-display":"Non ci sono registri da mostrare.","logs.page-description":"Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.","logs.status":"Status","logs.timestamp":"Timestamp","logs.title":"Registri","logs.user":"Utente","nav.actions":"Azioni","nav.diagnostics":"Diagnostica","nav.entities":"Entità","nav.logs":"Registri","raise-issue":"Segnala un problema su GitHub","return-to-index":"Torna alla pagina principale","search-filter":"Filtra la pagina corrente","welcome":"Benvenuto in OliveTin"},"zh-Hans-CN":{"connected":"已连接","docs":"文档","login-button":"登录","logs.action":"动作","logs.metadata":"元数据","logs.no-logs-to-display":"没有日志可显示。","logs.page-description":"这是一个动作执行日志列表。您可以按动作标题过滤列表。","logs.status":"状态","logs.timestamp":"时间戳","logs.title":"日志","nav.actions":"动作","nav.diagnostics":"诊断","nav.entities":"实体","nav.logs":"日志","raise-issue":"报告问题 on GitHub","return-to-index":"返回首页","search-filter":"过滤当前页面","welcome":"欢迎使用 OliveTin"}}}

20
lang/de-DE.yaml Normal file
View File

@@ -0,0 +1,20 @@
schemaVersion: 1
translations:
welcome: Willkommen bei OliveTin
nav.actions: Aktionen
nav.logs: Protokolle
nav.entities: Entitäten
nav.diagnostics: Diagnostik
connected: Verbunden
login-button: Login
raise-issue: Ein Problem melden auf GitHub
docs: Dokumentation
logs.title: Protokolle
logs.page-description: Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.
logs.timestamp: Zeitstempel
logs.action: Aktion
logs.metadata: Metadaten
logs.status: Status
logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.
return-to-index: Zurück zur Startseite
search-filter: Filter aktuelle Seite

20
lang/en.yaml Normal file
View File

@@ -0,0 +1,20 @@
schemaVersion: 1
translations:
welcome: Welcome to OliveTin
docs: Documentation
raise-issue: Raise an issue on GitHub
nav.actions: Actions
nav.logs: Logs
nav.entities: Entities
nav.diagnostics: Diagnostics
connected: Connected
login-button: Login
logs.title: Logs
logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.
logs.timestamp: Timestamp
logs.action: Action
logs.metadata: Metadata
logs.status: Status
logs.no-logs-to-display: There are no logs to display.
return-to-index: Return to index
search-filter: Filter current page

20
lang/es-ES.yaml Normal file
View File

@@ -0,0 +1,20 @@
schemaVersion: 1
translations:
welcome: Bienvenido a OliveTin
nav.actions: Acciones
nav.logs: Registros
nav.entities: Entidades
nav.diagnostics: Diagnósticos
connected: Conectado
login-button: Iniciar sesión
raise-issue: Reportar un problema en GitHub
docs: Documentación
logs.title: Registros
logs.page-description: Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.
logs.timestamp: Timestamp
logs.action: Acción
logs.metadata: Metadatos
logs.status: Status
logs.no-logs-to-display: No hay registros para mostrar.
return-to-index: Volver a la página principal
search-filter: Filtrar página actual

11
lang/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module github.com/OliveTin/OliveTin/langtool
go 1.24.9
require (
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

20
lang/go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

21
lang/it-IT.yaml Normal file
View File

@@ -0,0 +1,21 @@
schemaVersion: 1
translations:
welcome: Benvenuto in OliveTin
nav.actions: Azioni
nav.logs: Registri
nav.entities: Entità
nav.diagnostics: Diagnostica
docs: Documentazione
connected: Connesso
login-button: Login
raise-issue: Segnala un problema su GitHub
logs.title: Registri
logs.page-description: Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.
logs.timestamp: Timestamp
logs.action: Azione
logs.metadata: Metadati
logs.status: Status
logs.no-logs-to-display: Non ci sono registri da mostrare.
return-to-index: Torna alla pagina principale
search-filter: Filtra la pagina corrente
logs.user: Utente

125
lang/main.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/jamesread/golure/pkg/dirs"
log "github.com/sirupsen/logrus"
)
type LanguageFilev1 struct {
SchemaVersion int `json:"schemaVersion"`
Translations map[string]string `json:"translations"`
}
type CombinedTranslationsOutput struct {
Messages map[string]map[string]string `json:"messages"`
}
func main() {
combinedContent := getCombinedLanguageContent()
jsonData, err := json.Marshal(combinedContent)
if err != nil {
log.Fatalf("Error marshalling combined language content: %v", err)
}
err = os.WriteFile("combined_output.json", jsonData, 0644)
if err != nil {
log.Fatalf("Error saving combined language content to file: %v", err)
return
}
log.Infof("Combined language content saved to combined_output.json")
}
func getLanguageDir() string {
dirsToSearch := []string{
"../lang",
"../../../../lang/", // Relative to this file, for unit tests
"/app/lang/",
}
dir, _ := dirs.GetFirstExistingDirectory("lang", dirsToSearch)
return dir
}
func getCombinedLanguageContent() *CombinedTranslationsOutput {
output := &CombinedTranslationsOutput{
Messages: make(map[string]map[string]string),
}
languageDir := getLanguageDir()
files, err := os.ReadDir(languageDir)
if err != nil {
log.Errorf("Error reading language directory %s: %v", languageDir, err)
return output
}
for _, file := range filterLanguageFiles(files) {
languageName := strings.Replace(file.Name(), ".yaml", "", 1)
fullPath := filepath.Join(languageDir, file.Name())
log.Infof("Loading language file: %s", fullPath)
content, err := os.ReadFile(fullPath)
if err != nil {
log.Errorf("Error reading language file %s: %v", fullPath, err)
continue
}
var yamlData LanguageFilev1
err = yaml.Unmarshal(content, &yamlData)
if err != nil {
log.Errorf("Error reading language file %s: %v", fullPath, err)
continue
}
output.Messages[languageName] = yamlData.Translations
}
return output
}
func filterLanguageFiles(files []os.DirEntry) []os.DirEntry {
ret := make([]os.DirEntry, 0)
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(file.Name(), ".yaml") {
continue
}
ret = append(ret, file)
}
return ret
}
func parseAcceptLanguages(headerLanguage string) []string {
acceptLanguages := make([]string, 0)
for _, lang := range strings.Split(headerLanguage, ",") {
lang = strings.TrimSpace(lang)
acceptLanguages = append(acceptLanguages, lang)
}
return acceptLanguages
}

20
lang/zh-Hans-CN.yaml Normal file
View File

@@ -0,0 +1,20 @@
schemaVersion: 1
translations:
welcome: 欢迎使用 OliveTin
nav.actions: 动作
nav.logs: 日志
nav.entities: 实体
nav.diagnostics: 诊断
connected: 已连接
login-button: 登录
raise-issue: 报告问题 on GitHub
docs: 文档
logs.title: 日志
logs.page-description: 这是一个动作执行日志列表。您可以按动作标题过滤列表。
logs.timestamp: 时间戳
logs.action: 动作
logs.metadata: 元数据
logs.status: 状态
logs.no-logs-to-display: 没有日志可显示。
return-to-index: 返回首页
search-filter: 过滤当前页面