diff --git a/admin/.env.example b/admin/.env.example index 05a03fd..252cc3a 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -15,4 +15,6 @@ REDIS_PORT=6379 # Storage path for NOMAD content (ZIM files, maps, etc.) # On Windows dev, use an absolute path like: C:/nomad-storage # On Linux production, use: /opt/project-nomad/storage -NOMAD_STORAGE_PATH=/opt/project-nomad/storage \ No newline at end of file +NOMAD_STORAGE_PATH=/opt/project-nomad/storage +# Upstream GitHub repository URL (override for forks) +# NOMAD_REPO_URL=https://github.com/Crosstalk-Solutions/project-nomad \ No newline at end of file diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index 4bb00e3..1944edd 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -77,12 +77,14 @@ export default class ZimController { // Wikipedia selector endpoints - async getWikipediaState({}: HttpContext) { - return this.zimService.getWikipediaState() + async getWikipediaState({ request }: HttpContext) { + const locale = request.input('locale', 'en') + return this.zimService.getWikipediaState(locale) } async selectWikipedia({ request }: HttpContext) { const payload = await request.validateUsing(selectWikipediaValidator) - return this.zimService.selectWikipedia(payload.optionId) + const locale = request.input('locale', 'en') + return this.zimService.selectWikipedia(payload.optionId, locale) } } diff --git a/admin/app/services/collection_manifest_service.ts b/admin/app/services/collection_manifest_service.ts index bc69368..b2ecc91 100644 --- a/admin/app/services/collection_manifest_service.ts +++ b/admin/app/services/collection_manifest_service.ts @@ -12,6 +12,7 @@ import { getFileStatsIfExists, ZIM_STORAGE_PATH, } from '../utils/fs.js' +import { getRepoRawUrl } from '../utils/misc.js' import type { ManifestType, ZimCategoriesSpec, @@ -22,10 +23,13 @@ import type { SpecTier, } from '../../types/collections.js' -const SPEC_URLS: Record = { - zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json', - maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json', - wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json', +function getSpecUrls(): Record { + const rawUrl = getRepoRawUrl() + return { + zim_categories: `${rawUrl}/collections/kiwix-categories.json`, + maps: `${rawUrl}/collections/maps.json`, + wikipedia: `${rawUrl}/collections/wikipedia.json`, + } } const VALIDATORS: Record = { @@ -41,7 +45,7 @@ export class CollectionManifestService { async fetchAndCacheSpec(type: ManifestType): Promise { try { - const response = await axios.get(SPEC_URLS[type], { timeout: 15000 }) + const response = await axios.get(getSpecUrls()[type], { timeout: 15000 }) const validated = await vine.validate({ schema: VALIDATORS[type], diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 5d94f54..b6dbe43 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -12,6 +12,7 @@ import { promisify } from 'util' // import { readdir } from 'fs/promises' import KVStore from '#models/kv_store' import { BROADCAST_CHANNELS } from '../../constants/broadcast.js' +import { getRepoUrl } from '../utils/misc.js' @inject() export class DockerService { @@ -615,7 +616,7 @@ export class DockerService { * We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose. **/ const WIKIPEDIA_ZIM_URL = - 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim' + `${getRepoUrl()}/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim` const filename = 'wikipedia_en_100_mini_2025-06.zim' const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename) logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 84157af..7133de5 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -14,6 +14,7 @@ import env from '#start/env' import KVStore from '#models/kv_store' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { isNewerVersion } from '../utils/version.js' +import { getRepoUrl } from '../utils/misc.js' @inject() @@ -339,15 +340,17 @@ export class SystemService { let latestVersion: string if (earlyAccess) { + const repoPath = getRepoUrl().replace('https://github.com/', '') const response = await axios.get( - 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases', + `https://api.github.com/repos/${repoPath}/releases`, { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 } ) if (!response?.data?.length) throw new Error('No releases found') latestVersion = response.data[0].tag_name.replace(/^v/, '').trim() } else { + const repoPath = getRepoUrl().replace('https://github.com/', '') const response = await axios.get( - 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest', + `https://api.github.com/repos/${repoPath}/releases/latest`, { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 } ) if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API') diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index 3eee1cb..b6bea5b 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -28,7 +28,13 @@ import { CollectionManifestService } from './collection_manifest_service.js' import type { CategoryWithStatus } from '../../types/collections.js' const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream'] -const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json' +import { getRepoRawUrl } from '../utils/misc.js' + +const WIKIPEDIA_DEFAULT_LOCALE = 'en' + +function getWikipediaOptionsUrl(locale: string): string { + return `${getRepoRawUrl()}/collections/wikipedia.${locale}.json` +} @inject() export class ZimService { @@ -252,7 +258,7 @@ export class ZimService { async downloadRemoteSuccessCallback(urls: string[], restart = true) { // Check if any URL is a Wikipedia download and handle it for (const url of urls) { - if (url.includes('wikipedia_en_')) { + if (url.includes('/wikipedia_')) { await this.onWikipediaDownloadComplete(url, true) } } @@ -296,7 +302,7 @@ export class ZimService { // Create InstalledResource entries for downloaded files for (const url of urls) { // Skip Wikipedia files (managed separately) - if (url.includes('wikipedia_en_')) continue + if (url.includes('/wikipedia_')) continue const filename = url.split('/').pop() if (!filename) continue @@ -360,9 +366,10 @@ export class ZimService { // Wikipedia selector methods - async getWikipediaOptions(): Promise { + async getWikipediaOptions(locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise { try { - const response = await axios.get(WIKIPEDIA_OPTIONS_URL) + const url = getWikipediaOptionsUrl(locale) + const response = await axios.get(url) const data = response.data const validated = await vine.validate({ @@ -372,6 +379,11 @@ export class ZimService { return validated.options } catch (error) { + // Fall back to default locale if the requested locale file doesn't exist + if (locale !== WIKIPEDIA_DEFAULT_LOCALE) { + logger.warn(`[ZimService] Wikipedia options not found for locale '${locale}', falling back to '${WIKIPEDIA_DEFAULT_LOCALE}'`) + return this.getWikipediaOptions(WIKIPEDIA_DEFAULT_LOCALE) + } logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error) throw new Error('Failed to fetch Wikipedia options') } @@ -382,8 +394,8 @@ export class ZimService { return WikipediaSelection.query().first() } - async getWikipediaState(): Promise { - const options = await this.getWikipediaOptions() + async getWikipediaState(locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise { + const options = await this.getWikipediaOptions(locale) const selection = await this.getWikipediaSelection() return { @@ -399,8 +411,8 @@ export class ZimService { } } - async selectWikipedia(optionId: string): Promise<{ success: boolean; jobId?: string; message?: string }> { - const options = await this.getWikipediaOptions() + async selectWikipedia(optionId: string, locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise<{ success: boolean; jobId?: string; message?: string }> { + const options = await this.getWikipediaOptions(locale) const selectedOption = options.find((opt) => opt.id === optionId) if (!selectedOption) { @@ -537,7 +549,7 @@ export class ZimService { // We need to find what was previously installed const existingFiles = await this.list() const wikipediaFiles = existingFiles.files.filter((f) => - f.name.startsWith('wikipedia_en_') && f.name !== selection.filename + f.name.startsWith('wikipedia_') && f.name !== selection.filename ) for (const oldFile of wikipediaFiles) { diff --git a/admin/app/utils/misc.ts b/admin/app/utils/misc.ts index a81f33d..34436e4 100644 --- a/admin/app/utils/misc.ts +++ b/admin/app/utils/misc.ts @@ -1,3 +1,18 @@ +const DEFAULT_REPO_URL = 'https://github.com/Crosstalk-Solutions/project-nomad' + +export function getRepoUrl(): string { + return (process.env.NOMAD_REPO_URL || DEFAULT_REPO_URL).replace(/\/+$/, '') +} + +export function getRepoRawUrl(branch: string = 'main'): string { + const repoUrl = getRepoUrl() + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/) + if (match) { + return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/refs/heads/${branch}` + } + return `${repoUrl}/raw/refs/heads/${branch}` +} + export function formatSpeed(bytesPerSecond: number): string { if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index b71ab64..804cc39 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -2,6 +2,7 @@ /// import '../css/app.css' +import '../lib/i18n' import { createRoot } from 'react-dom/client' import { createInertiaApp } from '@inertiajs/react' import { resolvePageComponent } from '@adonisjs/inertia/helpers' diff --git a/admin/inertia/components/ActiveDownloads.tsx b/admin/inertia/components/ActiveDownloads.tsx index 9661f22..11dfca2 100644 --- a/admin/inertia/components/ActiveDownloads.tsx +++ b/admin/inertia/components/ActiveDownloads.tsx @@ -4,6 +4,7 @@ import { extractFileName } from '~/lib/util' import StyledSectionHeader from './StyledSectionHeader' import { IconAlertTriangle, IconX } from '@tabler/icons-react' import api from '~/lib/api' +import { useTranslation } from 'react-i18next' interface ActiveDownloadProps { filetype?: useDownloadsProps['filetype'] @@ -11,6 +12,7 @@ interface ActiveDownloadProps { } const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => { + const { t } = useTranslation() const { data: downloads, invalidate } = useDownloads({ filetype }) const handleDismiss = async (jobId: string) => { @@ -20,7 +22,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) return ( <> - {withHeader && } + {withHeader && }
{downloads && downloads.length > 0 ? ( downloads.map((download) => ( @@ -67,7 +69,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
)) ) : ( -

No active downloads

+

{t('common.noActiveDownloads')}

)} diff --git a/admin/inertia/components/ActiveEmbedJobs.tsx b/admin/inertia/components/ActiveEmbedJobs.tsx index 9da78bc..e8f5349 100644 --- a/admin/inertia/components/ActiveEmbedJobs.tsx +++ b/admin/inertia/components/ActiveEmbedJobs.tsx @@ -1,18 +1,20 @@ import useEmbedJobs from '~/hooks/useEmbedJobs' import HorizontalBarChart from './HorizontalBarChart' import StyledSectionHeader from './StyledSectionHeader' +import { useTranslation } from 'react-i18next' interface ActiveEmbedJobsProps { withHeader?: boolean } const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => { + const { t } = useTranslation() const { data: jobs } = useEmbedJobs() return ( <> {withHeader && ( - + )}
{jobs && jobs.length > 0 ? ( @@ -35,7 +37,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
)) ) : ( -

No files are currently being processed

+

{t('common.noFilesProcessing')}

)} diff --git a/admin/inertia/components/ActiveModelDownloads.tsx b/admin/inertia/components/ActiveModelDownloads.tsx index d1d0b85..e38155c 100644 --- a/admin/inertia/components/ActiveModelDownloads.tsx +++ b/admin/inertia/components/ActiveModelDownloads.tsx @@ -1,17 +1,19 @@ import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads' import HorizontalBarChart from './HorizontalBarChart' import StyledSectionHeader from './StyledSectionHeader' +import { useTranslation } from 'react-i18next' interface ActiveModelDownloadsProps { withHeader?: boolean } const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) => { + const { t } = useTranslation() const { downloads } = useOllamaModelDownloads() return ( <> - {withHeader && } + {withHeader && }
{downloads && downloads.length > 0 ? ( downloads.map((download) => ( @@ -33,7 +35,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
)) ) : ( -

No active model downloads

+

{t('common.noActiveModelDownloads')}

)} diff --git a/admin/inertia/components/CategoryCard.tsx b/admin/inertia/components/CategoryCard.tsx index ae60b4c..87e13fb 100644 --- a/admin/inertia/components/CategoryCard.tsx +++ b/admin/inertia/components/CategoryCard.tsx @@ -1,4 +1,5 @@ import { formatBytes } from '~/lib/util' +import { useTranslation } from 'react-i18next' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import type { CategoryWithStatus, SpecTier } from '../../types/collections' import classNames from 'classnames' @@ -11,6 +12,7 @@ export interface CategoryCardProps { } const CategoryCard: React.FC = ({ category, selectedTier, onClick }) => { + const { t } = useTranslation() // Calculate total size range across all tiers const getTierTotalSize = (tier: SpecTier, allTiers: SpecTier[]): number => { let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) @@ -61,9 +63,9 @@ const CategoryCard: React.FC = ({ category, selectedTier, onC

- {category.tiers.length} tiers available + {t('contentExplorer.categoryCard.tiersAvailable', { count: category.tiers.length })} {!highlightedTierSlug && ( - - Click to choose + - {t('contentExplorer.categoryCard.clickToChoose')} )}

@@ -86,7 +88,7 @@ const CategoryCard: React.FC = ({ category, selectedTier, onC })}

- Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)} + {t('contentExplorer.categoryCard.size', { min: formatBytes(minSize, 1), max: formatBytes(maxSize, 1) })}

diff --git a/admin/inertia/components/CuratedCollectionCard.tsx b/admin/inertia/components/CuratedCollectionCard.tsx index dc1c587..cfd3f2d 100644 --- a/admin/inertia/components/CuratedCollectionCard.tsx +++ b/admin/inertia/components/CuratedCollectionCard.tsx @@ -1,4 +1,5 @@ import { formatBytes } from '~/lib/util' +import { useTranslation } from 'react-i18next' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import type { CollectionWithStatus } from '../../types/collections' import classNames from 'classnames' @@ -11,6 +12,7 @@ export interface CuratedCollectionCardProps { } const CuratedCollectionCard: React.FC = ({ collection, onClick, size = 'small' }) => { + const { t } = useTranslation() const totalSizeBytes = collection.resources?.reduce( (acc, resource) => acc + resource.size_mb * 1024 * 1024, 0 @@ -41,16 +43,16 @@ const CuratedCollectionCard: React.FC = ({ collectio
-

All items downloaded

+

{t('common.allItemsDownloaded')}

)}

{collection.description}

- Items: {collection.resources?.length} | Size: {formatBytes(totalSizeBytes, 0)} + {t('common.items', { count: collection.resources?.length, size: formatBytes(totalSizeBytes, 0) })}

) diff --git a/admin/inertia/components/DebugInfoModal.tsx b/admin/inertia/components/DebugInfoModal.tsx index 63029cb..19de7e2 100644 --- a/admin/inertia/components/DebugInfoModal.tsx +++ b/admin/inertia/components/DebugInfoModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react' import StyledModal from './StyledModal' import api from '~/lib/api' @@ -9,6 +10,7 @@ interface DebugInfoModalProps { } export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) { + const { t } = useTranslation() const [debugText, setDebugText] = useState('') const [loading, setLoading] = useState(false) const [copied, setCopied] = useState(false) @@ -24,11 +26,11 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) { const browserLine = `Browser: ${navigator.userAgent}` setDebugText(text + '\n' + browserLine) } else { - setDebugText('Failed to load debug info. Please try again.') + setDebugText(t('debugInfo.loadFailed')) } setLoading(false) }).catch(() => { - setDebugText('Failed to load debug info. Please try again.') + setDebugText(t('debugInfo.loadFailed')) setLoading(false) }) }, [open]) @@ -52,20 +54,19 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) { } - cancelText="Close" + cancelText={t('debugInfo.close')} onCancel={onClose} >

- This is non-sensitive system info you can share when reporting issues. - No passwords, IPs, or API keys are included. + {t('debugInfo.description')}