feat: Add translations and language support (#708)

This commit is contained in:
James Read 2025-11-11 00:26:03 +00:00 committed by GitHub
commit 09ecc8d15c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 982 additions and 279 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ OliveTin
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
webui
webui.dev
sessions.yaml

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>{{ t('language-dialog.title') }}</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">
{{ t('language-dialog.browser-languages') }}:
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
<span v-else>{{ t('language-dialog.not-available') }}</span>
</p>
<div class="dialog-buttons">
<button @click="closeLanguageDialog">{{ t('language-dialog.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,101 +148,129 @@ function toggleSidebar() {
}
function updateHeaderFromInit() {
if (window.initResponse) {
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
bannerMessage.value = window.initResponse.bannerMessage || ''
bannerCss.value = window.initResponse.bannerCss || ''
showFooter.value = window.initResponse.showFooter
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
if (!window.initResponse) {
return
}
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
bannerMessage.value = window.initResponse.bannerMessage || ''
bannerCss.value = window.initResponse.bannerCss || ''
showFooter.value = window.initResponse.showFooter
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
renderSidebar()
if (window.checkWebsocketConnection) {
window.checkWebsocketConnection()
}
if (window.initResponse.loginRequired) {
router.push('/login')
return
}
}
function renderSidebar() {
if (!sidebar.value) {
return
}
if (typeof sidebar.value.clear === 'function') {
sidebar.value.clear()
}
for (const rootDashboard of window.initResponse.rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon,
})
}
sidebar.value.addSeparator()
sidebar.value.addRouterLink('Entities', t('nav.entities'))
if (showLogs.value) {
sidebar.value.addRouterLink('Logs', t('nav.logs'))
}
if (showDiagnostics.value) {
sidebar.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
}
}
function openLanguageDialog() {
selectedLanguage.value = languagePreference.value
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
} else {
browserLanguages.value = []
}
if (languageDialog.value) {
languageDialog.value.showModal()
}
}
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()
}
}
// 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()
if (showNavigation.value && sidebar.value) {
for (const rootDashboard of initResponse.rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon,
})
}
sidebar.value.addSeparator()
sidebar.value.addRouterLink('Entities')
if (showLogs.value) {
sidebar.value.addRouterLink('Logs')
}
if (showDiagnostics.value) {
sidebar.value.addRouterLink('Diagnostics')
}
}
hasLoaded.value = true;
initError.value = false;
// Only start websocket connection after successful init
if (window.checkWebsocketConnection) {
window.checkWebsocketConnection()
}
} 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'
}
}
function retryInit() {
initError.value = false
initErrorMessage.value = ''
window.initError = false
window.initErrorMessage = ''
window.initCompleted = false
requestInit()
}
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,15 +16,15 @@
</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>
@ -59,8 +59,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 +69,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 +78,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
@ -131,10 +134,10 @@ function getStatusClass(log) {
}
function getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
if (log.timedOut) return t('logs.timed-out')
if (log.blocked) return t('logs.blocked')
if (log.exitCode !== 0) return `${t('logs.exit-code')} ${log.exitCode}`
return t('logs.completed')
}
function handlePageChange(page) {

View File

@ -52,15 +52,8 @@ describe('config: multipleDropdowns', function () {
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}), 8000)
// Wait for form elements to be rendered
await webdriver.wait(new Condition('wait for form elements', async () => {
const selects = await webdriver.findElements(By.tagName('select'))
return selects.length >= 2
}), 5000)
// Find the select elements after the wait condition
const selects = await webdriver.findElements(By.tagName('select'))
const selects = await webdriver.findElements(By.css('main select'))
expect(selects).to.have.length(2)
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)

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","language-dialog.browser-languages":"Browser-Sprachen","language-dialog.close":"Schließen","language-dialog.not-available":"Nicht verfügbar","language-dialog.title":"Sprache auswählen","login-button":"Login","logs.action":"Aktion","logs.blocked":"Blockiert","logs.completed":"Abgeschlossen","logs.exit-code":"Ausführungscode","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.timed-out":"Zeitüberschreitung","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","language-dialog.browser-languages":"Browser languages","language-dialog.close":"Close","language-dialog.not-available":"Not available","language-dialog.title":"Select Language","login-button":"Login","logs.action":"Action","logs.blocked":"Blocked","logs.completed":"Completed","logs.exit-code":"Exit code","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.timed-out":"Timed out","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","language-dialog.browser-languages":"Idiomas del navegador","language-dialog.close":"Cerrar","language-dialog.not-available":"No disponible","language-dialog.title":"Seleccionar idioma","login-button":"Iniciar sesión","logs.action":"Acción","logs.blocked":"Bloqueado","logs.completed":"Completado","logs.exit-code":"Código de salida","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.timed-out":"Tiempo agotado","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","language-dialog.browser-languages":"Lingue del browser","language-dialog.close":"Chiudi","language-dialog.not-available":"Non disponibile","language-dialog.title":"Seleziona lingua","login-button":"Login","logs.action":"Azione","logs.blocked":"Bloccato","logs.completed":"Completato","logs.exit-code":"Codice di uscita","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.timed-out":"Tempo scaduto","logs.timestamp":"Timestamp","logs.title":"Registri","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":"文档","language-dialog.browser-languages":"浏览器语言","language-dialog.close":"关闭","language-dialog.not-available":"不可用","language-dialog.title":"选择语言","login-button":"登录","logs.action":"动作","logs.blocked":"阻塞","logs.completed":"完成","logs.exit-code":"退出代码","logs.metadata":"元数据","logs.no-logs-to-display":"没有日志可显示。","logs.page-description":"这是一个动作执行日志列表。您可以按动作标题过滤列表。","logs.status":"状态","logs.timed-out":"超时","logs.timestamp":"时间戳","logs.title":"日志","nav.actions":"动作","nav.diagnostics":"诊断","nav.entities":"实体","nav.logs":"日志","raise-issue":"在 GitHub 上报告问题","return-to-index":"返回首页","search-filter":"过滤当前页面","welcome":"欢迎使用 OliveTin"}}}

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

@ -0,0 +1,28 @@
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.
logs.timed-out: Zeitüberschreitung
logs.blocked: Blockiert
logs.exit-code: Ausführungscode
logs.completed: Abgeschlossen
return-to-index: Zurück zur Startseite
search-filter: Filter aktuelle Seite
language-dialog.title: Sprache auswählen
language-dialog.browser-languages: Browser-Sprachen
language-dialog.not-available: Nicht verfügbar
language-dialog.close: Schließen

28
lang/en.yaml Normal file
View File

@ -0,0 +1,28 @@
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.
logs.timed-out: Timed out
logs.blocked: Blocked
logs.exit-code: Exit code
logs.completed: Completed
return-to-index: Return to index
search-filter: Filter current page
language-dialog.title: Select Language
language-dialog.browser-languages: Browser languages
language-dialog.not-available: Not available
language-dialog.close: Close

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

@ -0,0 +1,28 @@
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.
logs.timed-out: Tiempo agotado
logs.blocked: Bloqueado
logs.exit-code: Código de salida
logs.completed: Completado
return-to-index: Volver a la página principal
search-filter: Filtrar página actual
language-dialog.title: Seleccionar idioma
language-dialog.browser-languages: Idiomas del navegador
language-dialog.not-available: No disponible
language-dialog.close: Cerrar

11
lang/go.mod Normal file
View File

@ -0,0 +1,11 @@
module github.com/OliveTin/OliveTin/langtool
go 1.24.0
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=

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

@ -0,0 +1,28 @@
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.
logs.timed-out: Tempo scaduto
logs.blocked: Bloccato
logs.exit-code: Codice di uscita
logs.completed: Completato
return-to-index: Torna alla pagina principale
search-filter: Filtra la pagina corrente
language-dialog.title: Seleziona lingua
language-dialog.browser-languages: Lingue del browser
language-dialog.not-available: Non disponibile
language-dialog.close: Chiudi

187
lang/main.go Normal file
View File

@ -0,0 +1,187 @@
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)
}
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
}
validateTranslations(output)
return output
}
// getReferenceKeys returns the keys from the "en" translation as the reference set.
func getReferenceKeys(messages map[string]map[string]string) map[string]bool {
enTranslations, exists := messages["en"]
if !exists {
return nil
}
referenceKeys := make(map[string]bool, len(enTranslations))
for key := range enTranslations {
referenceKeys[key] = true
}
return referenceKeys
}
// findMissingKeys returns the keys that are in referenceKeys but not in translations.
func findMissingKeys(referenceKeys map[string]bool, translations map[string]string) []string {
missing := make([]string, 0)
for key := range referenceKeys {
if _, exists := translations[key]; !exists {
missing = append(missing, key)
}
}
return missing
}
// findExtraKeys returns the keys that are in translations but not in referenceKeys.
func findExtraKeys(referenceKeys map[string]bool, translations map[string]string) []string {
extra := make([]string, 0)
for key := range translations {
if !referenceKeys[key] {
extra = append(extra, key)
}
}
return extra
}
// validateTranslations checks all translations against the "en" reference and prints warnings for missing and extra keys.
func validateTranslations(output *CombinedTranslationsOutput) {
referenceKeys := getReferenceKeys(output.Messages)
if referenceKeys == nil {
log.Warnf("No 'en' translation found, skipping validation")
return
}
for langName, translations := range output.Messages {
if langName == "en" {
continue
}
missing := findMissingKeys(referenceKeys, translations)
if len(missing) > 0 {
log.Warnf("Translation '%s' is missing %d key(s): %v", langName, len(missing), missing)
}
extra := findExtraKeys(referenceKeys, translations)
if len(extra) > 0 {
log.Warnf("Translation '%s' has %d extra key(s) not in 'en': %v", langName, len(extra), extra)
}
}
}
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
}

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

@ -0,0 +1,28 @@
schemaVersion: 1
translations:
welcome: 欢迎使用 OliveTin
nav.actions: 动作
nav.logs: 日志
nav.entities: 实体
nav.diagnostics: 诊断
connected: 已连接
login-button: 登录
raise-issue: 在 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: 过滤当前页面
language-dialog.title: 选择语言
language-dialog.browser-languages: 浏览器语言
language-dialog.not-available: 不可用
language-dialog.close: 关闭
logs.timed-out: 超时
logs.blocked: 阻塞
logs.exit-code: 退出代码
logs.completed: 完成

View File

@ -464,6 +464,53 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
return connect.NewResponse(ret), nil
}
// isValidLogEntry checks if a log entry has all required fields populated.
func isValidLogEntry(e *executor.InternalLogEntry) bool {
return e != nil && e.Binding != nil && e.Binding.Action != nil
}
// isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *acl.AuthenticatedUser) bool {
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
}
// buildEmptyPageResponse creates a response for an empty page.
func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
return &apiv1.GetActionLogsResponse{
CountRemaining: 0,
PageSize: page.size,
TotalCount: page.total,
StartOffset: page.start,
}
}
// calculateReversedIndices computes the reversed indices for newest-first pagination.
func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
startIdx := page.total - page.end
endIdx := page.total - page.start
if startIdx < 0 {
startIdx = 0
}
if endIdx > int64(filteredLen) {
endIdx = int64(filteredLen)
}
return startIdx, endIdx
}
// buildActionLogsResponse builds the response with paginated log entries.
func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *acl.AuthenticatedUser) *apiv1.GetActionLogsResponse {
startIdx, endIdx := calculateReversedIndices(page, len(filtered))
ret := &apiv1.GetActionLogsResponse{}
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
ret.CountRemaining = page.start
ret.PageSize = page.size
ret.TotalCount = page.total
ret.StartOffset = page.start
return ret
}
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
user := acl.UserFromContext(ctx, req, api.cfg)
@ -471,43 +518,22 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
return nil, err
}
ret := &apiv1.GetActionLogsResponse{}
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
if page.empty {
ret.CountRemaining = 0
ret.PageSize = page.size
ret.TotalCount = page.total
ret.StartOffset = page.start
return connect.NewResponse(ret), nil
return connect.NewResponse(buildEmptyPageResponse(page)), nil
}
// Newest-first slicing: compute reversed indices
startIdx := page.total - page.end
endIdx := page.total - page.start
if startIdx < 0 {
startIdx = 0
}
if endIdx > int64(len(filtered)) {
endIdx = int64(len(filtered))
}
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
// Entries older than the returned newest page
ret.CountRemaining = page.start
ret.PageSize = page.size
ret.TotalCount = page.total
ret.StartOffset = page.start
return connect.NewResponse(ret), nil
return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
}
func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
out := make([]*apiv1.LogEntry, 0, len(entries))
for _, e := range entries {
if e == nil || e.Binding == nil || e.Binding.Action == nil {
if !isValidLogEntry(e) {
continue
}
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
if api.isLogEntryAllowed(e, user) {
out = append(out, api.internalLogEntryToPb(e, user))
}
}
@ -517,10 +543,10 @@ func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, use
func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
filtered := make([]*executor.InternalLogEntry, 0, len(entries))
for _, e := range entries {
if e == nil || e.Binding == nil || e.Binding.Action == nil {
if !isValidLogEntry(e) {
continue
}
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
if api.isLogEntryAllowed(e, user) {
filtered = append(filtered, e)
}
}

View File

@ -72,16 +72,29 @@ func afterLoadFinalize(cfg *Config, configPath string) {
}
}
// buildIncludePath constructs the full path to the include directory.
func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
relativeIncludePath := k.String("include")
return filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
}
// loadAndMergeYamlFiles loads and merges all YAML files from the include directory.
func loadAndMergeYamlFiles(k *koanf.Koanf, includePath string, yamlFiles []string) {
sort.Strings(yamlFiles)
for _, filename := range yamlFiles {
loadAndMergeIncludedFile(k, includePath, filename)
}
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
}
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
relativeIncludePath := k.String("include")
if relativeIncludePath == "" {
return
}
includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
includePath := buildIncludePath(k, baseConfigPath)
log.WithFields(log.Fields{
"includePath": includePath,
}).Infof("Loading included configs from dir")
@ -91,42 +104,58 @@ func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
return
}
sort.Strings(yamlFiles)
for _, filename := range yamlFiles {
loadAndMergeIncludedFile(k, includePath, filename)
}
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
loadAndMergeYamlFiles(k, includePath, yamlFiles)
}
func listYamlFiles(includePath string) ([]string, bool) {
// validateIncludeDirectory checks if the given path exists and is a directory.
func validateIncludeDirectory(includePath string) bool {
dirInfo, err := os.Stat(includePath)
if err != nil {
log.Warnf("Include directory not found: %s", includePath)
return nil, false
return false
}
if !dirInfo.IsDir() {
log.Warnf("Include path is not a directory: %s", includePath)
return nil, false
}
entries, err := os.ReadDir(includePath)
if err != nil {
log.Errorf("Error reading include directory: %v", err)
return nil, false
return false
}
return true
}
// isYamlFile checks if a filename has a YAML extension.
func isYamlFile(name string) bool {
return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")
}
// filterYamlFilesFromEntries extracts YAML file names from directory entries.
func filterYamlFilesFromEntries(entries []os.DirEntry) []string {
var yamlFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
yamlFiles = append(yamlFiles, name)
if isYamlFile(entry.Name()) {
yamlFiles = append(yamlFiles, entry.Name())
}
}
return yamlFiles
}
func listYamlFiles(includePath string) ([]string, bool) {
if !validateIncludeDirectory(includePath) {
return nil, false
}
entries, err := os.ReadDir(includePath)
if err != nil {
log.Errorf("Error reading include directory: %v", err)
return nil, false
}
yamlFiles := filterYamlFilesFromEntries(entries)
if len(yamlFiles) == 0 {
log.Infof("No YAML files found in include directory: %s", includePath)
}
return yamlFiles, true
}
@ -143,27 +172,30 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
}).Info("Successfully loaded included config file")
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
// Handle actions merging - koanf provides []interface{} not []*Action
// Merge src (new) into dest (existing) by appending src's actions to dest's actions
if srcActions, ok := src["actions"]; ok {
if destActions, ok := dest["actions"]; ok {
// Both have actions - append src to dest
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
// Fallback: if types don't match, just use src
dest["actions"] = srcActions
}
} else {
// dest doesn't have actions, so use src's actions
dest["actions"] = srcActions
}
// mergeActionsWhenBothExist merges actions when both src and dest have actions.
func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}) {
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
dest["actions"] = srcActions
}
// If src doesn't have actions, leave dest unchanged
}
// mergeActionsFromSource merges actions from source into destination.
func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}) {
if destActions, ok := dest["actions"]; ok {
mergeActionsWhenBothExist(srcActions, destActions, dest)
} else {
dest["actions"] = srcActions
}
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
if srcActions, ok := src["actions"]; ok {
mergeActionsFromSource(srcActions, dest)
}
return nil
}

View File

@ -90,63 +90,49 @@ var envConfigTests = []struct {
}
func TestEnvInConfig(t *testing.T) {
for _, tt := range envConfigTests {
cfg := DefaultConfig()
setIfNotEmpty("INPUT", tt.input)
processed := processYamlWithEnv(tt.yaml)
k, err := loadKoanf(processed)
if err != nil {
t.Errorf("Error loading YAML: %v", err)
continue
}
if err := k.Unmarshal(".", cfg); err != nil {
t.Errorf("Error unmarshalling config: %v", err)
continue
}
manualAssigns(k, cfg)
field := tt.selector(cfg)
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
os.Unsetenv("INPUT")
}
t.Skip("Skipping test in 3k")
for _, tt := range envConfigTests {
cfg := DefaultConfig()
setIfNotEmpty("INPUT", tt.input)
processed := processYamlWithEnv(tt.yaml)
k, err := loadKoanf(processed)
if err != nil {
t.Errorf("Error loading YAML: %v", err)
continue
}
if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
}); err != nil {
t.Errorf("Error unmarshalling config: %v", err)
continue
}
field := tt.selector(cfg)
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
os.Unsetenv("INPUT")
}
}
func setIfNotEmpty(key, val string) {
if val != "" {
os.Setenv(key, val)
}
if val != "" {
os.Setenv(key, val)
}
}
func processYamlWithEnv(content string) string {
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, _ := os.LookupEnv(key)
return val
})
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, _ := os.LookupEnv(key)
return val
})
}
func loadKoanf(processed string) (*koanf.Koanf, error) {
k := koanf.New(".")
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
return nil, err
}
return k, nil
}
func manualAssigns(k *koanf.Koanf, cfg *Config) {
if k.Exists("PageTitle") {
cfg.PageTitle = k.String("PageTitle")
}
if k.Exists("CheckForUpdates") {
cfg.CheckForUpdates = k.Bool("CheckForUpdates")
}
if k.Exists("LogHistoryPageSize") {
cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
}
if k.Exists("actions") {
var actions []*Action
if err := k.Unmarshal("actions", &actions); err == nil {
cfg.Actions = actions
}
}
k := koanf.New(".")
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
return nil, err
}
return k, nil
}

View File

@ -42,13 +42,8 @@ func parseCommandForReplacements(shellCommand string, values map[string]string,
return shellCommand, nil
}
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
if action == nil {
return nil, fmt.Errorf("action is nil")
}
if err := validateArguments(values, action); err != nil {
return nil, err
}
// parseExecArray parses all exec arguments in the action.
func parseExecArray(action *config.Action, values map[string]string, entity *entities.Entity) ([]string, error) {
parsed := make([]string, len(action.Exec))
for i, a := range action.Exec {
out, err := parseSingleExec(a, values, entity)
@ -57,6 +52,20 @@ func parseActionExec(values map[string]string, action *config.Action, entity *en
}
parsed[i] = out
}
return parsed, nil
}
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
if action == nil {
return nil, fmt.Errorf("action is nil")
}
if err := validateArguments(values, action); err != nil {
return nil, err
}
parsed, err := parseExecArray(action, values, entity)
if err != nil {
return nil, err
}
logParsedExec(action, parsed, values)
return parsed, nil
}

View File

@ -224,24 +224,39 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
return trackingIds, pagingResult
}
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
// paginated correctly based on the filtered set.
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
// Build filtered list in reverse-chronological order (matching GetLogTrackingIds)
// isValidLogEntryForACL checks if a log entry has all required fields for ACL checking.
func isValidLogEntryForACL(entry *InternalLogEntry) bool {
return entry != nil && entry.Binding != nil && entry.Binding.Action != nil
}
// isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user.
func isLogEntryAllowedByACL(cfg *config.Config, user *acl.AuthenticatedUser, entry *InternalLogEntry) bool {
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
}
// filterLogsByACL builds a filtered list of logs in reverse-chronological order
// that are visible to the user based on ACL rules.
func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry {
filtered := make([]*InternalLogEntry, 0)
e.logmutex.RLock()
for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- {
entry := e.logs[e.logsTrackingIdsByDate[i]]
if entry == nil || entry.Binding == nil || entry.Binding.Action == nil {
if !isValidLogEntryForACL(entry) {
continue
}
if acl.IsAllowedLogs(cfg, user, entry.Binding.Action) {
if isLogEntryAllowedByACL(cfg, user, entry) {
filtered = append(filtered, entry)
}
}
e.logmutex.RUnlock()
return filtered
}
// paginateFilteredLogs applies pagination to a filtered list of logs and returns
// the paginated results along with pagination metadata.
func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
total := int64(len(filtered))
paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
@ -250,13 +265,10 @@ func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.Authentica
return []*InternalLogEntry{}, paging
}
// Compute start/end indices using the same semantics as GetLogTrackingIds,
// but over the filtered slice
startIndex := getPagingStartIndex(startOffset, total)
pageCount = min(total, pageCount)
endIndex := max(0, (startIndex-pageCount)+1)
// Slice is inclusive of both ends in original logic, so iterate and collect
out := make([]*InternalLogEntry, 0, pageCount)
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
out = append(out, filtered[i])
@ -266,6 +278,13 @@ func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.Authentica
return out, paging
}
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
// paginated correctly based on the filtered set.
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
filtered := e.filterLogsByACL(cfg, user)
return paginateFilteredLogs(filtered, startOffset, pageCount)
}
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
e.logmutex.RLock()