Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blueprint contents localisation #287

Merged
merged 68 commits into from
Feb 4, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
1e6312f
feat: extended i18n capabilities to include translations bundles from…
gundelsby Jul 29, 2020
cab7f79
feat: correctly parse translations from blueprints and store in database
gundelsby Jul 31, 2020
6ea0e20
chore: update blueprints-integration snapshot
gundelsby Jul 31, 2020
16471ca
feat: use parent blueprint as namespaces for translation bundles comi…
gundelsby Aug 4, 2020
c8c52df
fix: typo in translationsbundles collection name
gundelsby Aug 5, 2020
3d2dbea
feat: replace plain text messages with TranslatableMessages for Notes
gundelsby Aug 10, 2020
cb4cb45
feat!: Notes now support parametrized strings (changes NotesContext#e…
gundelsby Aug 12, 2020
ecf69e4
feat: support namespaces in NotesContext to utilize translations from…
gundelsby Aug 18, 2020
be78220
Merge branch 'release24' into feat/blueprint-contents-localisation
gundelsby Sep 3, 2020
9fe9c54
chore: rewrite test to fit changed NotesContext error and warning int…
gundelsby Sep 3, 2020
006b591
feat: don't add empty translations bundles to the client side translator
gundelsby Sep 4, 2020
b9c0b10
fix: parametrized strings would sometimes cause an exception in the i…
gundelsby Sep 4, 2020
923a4cb
chore: use API function for unwrapping blueprint id in translation bu…
gundelsby Sep 7, 2020
e24cd81
Merge remote-tracking branch 'origin/release25' into feat/blueprint-c…
gundelsby Sep 7, 2020
c80988e
Merge remote-tracking branch 'origin/release24' into feat/blueprint-c…
gundelsby Sep 8, 2020
8868a0c
fix: ITranslatableMessage typechecker predicate had multiple bugs
gundelsby Sep 9, 2020
4d47369
feat: add support for TranslatableMessages in notification center
gundelsby Sep 9, 2020
4c979e4
fix: namespaces not applied when creating notes from blueprint proces…
gundelsby Sep 10, 2020
effb84d
Merge branch 'release25' into feat/blueprint-contents-localisation
Julusian Sep 21, 2020
d992aa7
chore: update blueprints-integration
Julusian Sep 21, 2020
4691566
chore: update blueprints-integration
Julusian Sep 25, 2020
54c6e19
Merge branch 'release25' into feat/blueprint-contents-localisation
Julusian Sep 25, 2020
b419f4a
chore: update blueprints-integration
Julusian Sep 25, 2020
50c4a0b
feat: blueprint logging api changes
Julusian Sep 21, 2020
4397486
fix: tests
Julusian Sep 21, 2020
fc80552
chore: update blueprints-integration
Julusian Sep 25, 2020
ecae880
chore: remove old test
Julusian Sep 25, 2020
ef2ca49
fix: improve log messages
Julusian Sep 25, 2020
81db465
chore: review comments
Julusian Sep 28, 2020
07de3bd
chore: review comments
Julusian Oct 5, 2020
9637d25
Merge branch 'release29' into feat/blueprint-contents-localisation
gundelsby Jan 4, 2021
5cdf1e5
fix: settle inconsistencies between outdated translation code and new…
gundelsby Jan 12, 2021
0dfcea9
Merge remote-tracking branch 'origin/release29' into feat/blueprint-c…
gundelsby Jan 12, 2021
5908754
fix: more context usage inconsistencies
gundelsby Jan 13, 2021
008f579
Merge remote-tracking branch 'origin/release30' into feat/blueprint-c…
gundelsby Jan 20, 2021
153a322
chore: fix failing test
gundelsby Jan 20, 2021
088dd4c
chore: [publish]
gundelsby Jan 20, 2021
948622c
chore: ignore .vscode/tasks.json
gundelsby Jan 21, 2021
a3284a8
feat: add interface IAsRunEventUserContext
gundelsby Jan 22, 2021
baf15ee
chore: [publish]
gundelsby Jan 22, 2021
5738cf9
feat: ShowStyleBlueprintManifest.onAsRunEvent context changed to user…
gundelsby Jan 25, 2021
f4c5883
chore: [publish]
gundelsby Jan 25, 2021
ef97859
feat: ShowStyleBlueprintManifest.onTimelineGenerate changed to have u…
gundelsby Jan 25, 2021
6666546
chore: publish
gundelsby Jan 25, 2021
970c8ea
fix: remove unused import (linting error)
gundelsby Jan 25, 2021
42b571f
chore: [publish]
gundelsby Jan 25, 2021
0e30711
fix: remove unused context argument from StudioBlueprintManifest.getR…
gundelsby Jan 25, 2021
97bc802
chore: [publish]
gundelsby Jan 25, 2021
3aecc51
feat: remove impossible interfaces and add type predicate functions f…
gundelsby Jan 26, 2021
2ec3496
chore: [publish]
gundelsby Jan 26, 2021
cfc0a1b
fix: change back to non user contexts for ShowStyleBlueprintManifest.…
gundelsby Jan 26, 2021
52fdfb1
chore: [publish]
gundelsby Jan 26, 2021
903c6ab
fix: bring back context argument for ShowStyleBlueprintManifest.getEn…
gundelsby Jan 27, 2021
6935412
chore: [publish]
gundelsby Jan 27, 2021
6a8ac28
fix: update blueprints-integration dependency and re-add context wher…
gundelsby Feb 1, 2021
c957ec8
feat: working import and translations from blueprint content
gundelsby Feb 1, 2021
17f4c42
Merge remote-tracking branch 'origin/release30' into feat/blueprint-c…
gundelsby Feb 1, 2021
aeb100d
chore: [publish]
gundelsby Feb 1, 2021
5a636a7
chore: removed unused imports
gundelsby Feb 1, 2021
835b14c
feat: implment a method to fetch individual translation bundles
jstarpl Feb 2, 2021
79ccef2
chore: remove unused reject function
jstarpl Feb 2, 2021
498d4c0
chore: sonarlint fix
gundelsby Feb 2, 2021
92e7e3d
feat: explicitly tie origin blueprint to translation bundle in data m…
gundelsby Feb 2, 2021
181b31c
fix: remove debug log statements used under developement, commented p…
gundelsby Feb 2, 2021
cbf0506
chore: fix typo in user facing error message
gundelsby Feb 2, 2021
558c4b3
fix: minor improvements after code review
gundelsby Feb 3, 2021
96a8d75
fix: move getTranslationBundle method from userActions to systems api
gundelsby Feb 3, 2021
e220715
Merge remote-tracking branch 'origin/release30' into feat/blueprint-c…
gundelsby Feb 3, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[*]
indent_style = tab
end_of_line = lf

[*.{cs,js,ts,json}]
indent_size = 4
Expand Down
27 changes: 17 additions & 10 deletions meteor/client/lib/notifications/NotificationCenterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sofieWarningIcon as WarningIcon } from './warningIcon'
import { ContextMenuTrigger, ContextMenu, MenuItem } from 'react-contextmenu'
import * as _ from 'underscore'
import { SegmentId } from '../../../lib/collections/Segments'
import { translateMessage, isTranslatableMessage } from '../../../lib/api/TranslatableMessage'

interface IPopUpProps {
item: Notification
Expand Down Expand Up @@ -43,6 +44,8 @@ class NotificationPopUp extends React.Component<IPopUpProps> {
const defaultAction: NotificationAction | undefined =
defaultActions.length === 1 && allActions.length === 1 ? defaultActions[0] : undefined

const message = isTranslatableMessage(item.message) ? translateMessage(item.message) : item.message

return (
<div
className={ClassNames('notification-pop-up', {
Expand All @@ -62,7 +65,7 @@ class NotificationPopUp extends React.Component<IPopUpProps> {
<WarningIcon />
</div>
<div className="notification-pop-up__contents">
{item.message}
{message}
{!defaultAction && allActions.length ? (
<div className="notification-pop-up__actions">
{_.map(allActions, (action: NotificationAction, i: number) => {
Expand Down Expand Up @@ -210,15 +213,19 @@ export const NotificationCenterPopUps = translateWithTracker<IProps, IState, ITr
.sort((a, b) => Notification.compare(a, b))
}

const displayList = notifications.map((item) => (
<NotificationPopUp
key={item.created + (item.message || 'undefined').toString() + (item.id || '')}
item={item}
onDismiss={() => this.dismissNotification(item)}
showDismiss={!item.persistent || !this.props.showSnoozed}
isHighlighted={item.source === highlightedSource && item.status === highlightedLevel}
/>
))
const displayList = notifications.map((item) => {
const message = isTranslatableMessage(item.message) ? translateMessage(item.message) : item.message

return (
<NotificationPopUp
key={item.created + (message || 'undefined').toString() + (item.id || '')}
item={item}
onDismiss={() => this.dismissNotification(item)}
showDismiss={!item.persistent || !this.props.showSnoozed}
isHighlighted={item.source === highlightedSource && item.status === highlightedLevel}
/>
)
})

return (
(this.props.showEmptyListLabel || displayList.length > 0) && (
Expand Down
5 changes: 3 additions & 2 deletions meteor/client/lib/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventEmitter } from 'events'
import { Time, ProtectedString, unprotectString, isProtectedString, protectString } from '../../../lib/lib'
import { HTMLAttributes } from 'react'
import { SegmentId } from '../../../lib/collections/Segments'
import { ITranslatableMessage } from '../../../lib/api/TranslatableMessage'

/**
* Priority level for Notifications.
Expand Down Expand Up @@ -305,7 +306,7 @@ export const NotificationCenter = new NotificationCenter0()
export class Notification extends EventEmitter {
id: string | undefined
status: NoticeLevel
message: string | React.ReactElement<HTMLElement> | null
message: string | React.ReactElement<HTMLElement> | ITranslatableMessage | null
source: NotificationsSource
persistent?: boolean
timeout?: number
Expand All @@ -317,7 +318,7 @@ export class Notification extends EventEmitter {
constructor(
id: string | ProtectedString<any> | undefined,
status: NoticeLevel,
message: string | React.ReactElement<HTMLElement> | null,
message: string | React.ReactElement<HTMLElement> | ITranslatableMessage | null,
source: NotificationsSource,
created?: Time,
persistent?: boolean,
Expand Down
13 changes: 1 addition & 12 deletions meteor/client/ui/RundownView/RundownNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { Rundowns, RundownId, Rundown } from '../../../lib/collections/Rundowns'
import { doModalDialog } from '../../lib/ModalDialog'
import { doUserAction, UserAction } from '../../lib/userAction'
// import { withTranslation, getI18n, getDefaults } from 'react-i18next'
import { i18nTranslator } from '../i18n'
import { i18nTranslator as t } from '../i18n'
import { PartNote, NoteType, TrackedNote } from '../../../lib/api/notes'
import { Pieces, PieceId } from '../../../lib/collections/Pieces'
import { PeripheralDevicesAPI } from '../../lib/clientAPI'
Expand Down Expand Up @@ -158,7 +158,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private reactiveRundownStatus(playlistId: RundownPlaylistId) {
const t = i18nTranslator
let oldNoteIds: Array<string> = []

const rRundowns = reactiveData.getRRundowns(playlistId, {
Expand Down Expand Up @@ -271,8 +270,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private reactivePeripheralDeviceStatus(studioId: StudioId | undefined) {
const t = i18nTranslator

let oldDevItemIds: PeripheralDeviceId[] = []
let reactivePeripheralDevices: ReactiveVar<PeripheralDevice[]> | undefined
if (studioId) {
Expand Down Expand Up @@ -381,7 +378,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private reactivePartNotes(playlistId: RundownPlaylistId) {
const t = i18nTranslator
let allNotesPollInterval: number
let allNotesPollLock: boolean = false
const NOTES_POLL_INTERVAL = BACKEND_POLL_INTERVAL
Expand Down Expand Up @@ -474,8 +470,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private reactiveMediaStatus(playlistId: RundownPlaylistId, showStyleBase: ShowStyleBase, studio: Studio) {
const t = i18nTranslator

let mediaObjectsPollInterval: number
let mediaObjectsPollLock: boolean = false
const MEDIAOBJECTS_POLL_INTERVAL = BACKEND_POLL_INTERVAL
Expand Down Expand Up @@ -649,7 +643,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private reactiveQueueStatus(studioId: StudioId, playlistId: RundownPlaylistId) {
const t = i18nTranslator
let reactiveUnsentMessageCount: ReactiveVar<number>
meteorSubscribe(PubSub.externalMessageQueue, { studioId: studioId, playlistId })
reactiveUnsentMessageCount = reactiveData.getUnsentExternalMessageCount(studioId, playlistId)
Expand All @@ -675,8 +668,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private updateVersionAndConfigStatus(playlistId: RundownPlaylistId) {
const t = i18nTranslator

// Doing the check server side, to avoid needing to subscribe to the blueprint and showStyleVariant
MeteorCall.rundown
.rundownPlaylistNeedsResync(playlistId)
Expand Down Expand Up @@ -875,8 +866,6 @@ class RundownViewNotifier extends WithManagedTracker {
}

private makeDeviceMessage(device: PeripheralDevice): string {
const t = i18nTranslator

if (!device.connected) {
return t('Device {{deviceName}} is disconnected', { deviceName: device.name })
}
Expand Down
153 changes: 113 additions & 40 deletions meteor/client/ui/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,125 @@ import i18n, { TFunction } from 'i18next'
import Backend from 'i18next-xhr-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { WithManagedTracker } from '../lib/reactiveData/reactiveDataHelper'
import { PubSub } from '../../lib/api/pubsub'
import { TranslationsBundles } from '../../lib/collections/TranslationsBundles'

let i18nTranslator: TFunction
const i18nInstancePromise = i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init(
{
fallbackLng: {
nn: ['nb', 'en'],
default: ['en'],
},

// have a common namespace used around the full app
ns: ['translations'],
defaultNS: 'translations',

debug: false,
joinArrays: '\n',

whitelist: ['en', 'nb', 'nn', 'sv'],

keySeparator: '→',
nsSeparator: '⇒',
pluralSeparator: '⥤',
contextSeparator: '⥤',

interpolation: {
escapeValue: false, // not needed for react!!
},

react: {
wait: true,
useSuspense: false,
},
},
(err, t) => {
const i18nOptions = {
fallbackLng: {
nn: ['nb', 'en'],
default: ['en'],
},

// have a common namespace used around the full app
ns: ['translations'],
defaultNS: 'translations',

debug: false,
joinArrays: '\n',

whitelist: ['en', 'nb', 'nn', 'sv'],

keySeparator: '→',
nsSeparator: '⇒',
pluralSeparator: '⥤',
contextSeparator: '⥤',

interpolation: {
escapeValue: false, // not needed for react!!
},

react: {
wait: true,
useSuspense: false,
},
}

class I18nContainer extends WithManagedTracker {
i18nInstance: typeof i18n

constructor() {
super()

this.i18nInstance = i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)

this.i18nInstance.init(i18nOptions, (err: Error, t: TFunction) => {
if (err) {
console.error('Error initializing i18Next', err)
console.error('Error initializing i18Next:', err)
} else {
i18nTranslator = t
this.i18nTranslator = t
console.debug('i18nTranslator init complete')
}
})

this.subscribe(PubSub.translationsBundles, {})
gundelsby marked this conversation as resolved.
Show resolved Hide resolved
this.autorun(() => {
console.debug('ManagedTracker autorun...')
const bundles = TranslationsBundles.find().fetch()
console.debug(`Got ${bundles.length} bundles from database`)
for (const bundle of bundles) {
this.i18nInstance.addResourceBundle(
bundle.language,
bundle.namespace || i18nOptions.defaultNS,
bundle.data,
true,
true
)
console.debug('i18instance updated', { bundle: { lang: bundle.language, ns: bundle.namespace } })
}
})
}
// return key until real translator comes online
i18nTranslator(key, ...args) {
console.debug('i18nTranslator placeholder called', { key, args })

if (!args[0]) {
return key
}
)

export { i18nInstancePromise, i18nTranslator }
if (typeof args[0] === 'string') {
return key || args[0]
}

if (args[0].defaultValue) {
return args[0].defaultValue
}

if (typeof key !== 'string') {
return key
}

const options = args[0]
if (options?.replace) {
Object.assign(options, { ...options.replace })
}

const interpolated = String(key)
for (const placeholder of key.match(/[^{\}]+(?=})/g) || []) {
const value = options[placeholder] || placeholder
interpolated.replace(`{{${placeholder}}}`, value)
}

return interpolated
}
}

const container = new I18nContainer()
const i18nTranslator: TFunction = (...args) => {
console.debug('i18nTranslator call', args)
// if (args[0] === `Device {{deviceName}} is disconnected`) {
// console.debug(`Got ${args[0]}, aborting`, args)
// return args[0]
// }
const result = container.i18nTranslator(args)
console.debug(`=> ${result}`)
return result
}

export { i18nTranslator }

/*
Notes:
Expand Down
64 changes: 64 additions & 0 deletions meteor/lib/api/TranslatableMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { i18nTranslator } from '../../client/ui/i18n'

/**
* @enum - A translatable message (i18next)
*/
export interface ITranslatableMessage {
/** the message key (we use literal strings in English as keys for now) */
key: string
/** arguments for the message template */
args?: { [key: string]: any }
/** namespace used */
namespaces?: Array<string>
}

/**
* Translates a message with arguments applied. Uses the application's
* translation service (see {@link '../../client/ui/i18n'}).
*
* @param {ITranslatableMessage} translatable - the translatable to translate
* @returns the translation with arguments applied
*/
export function translateMessage(translatable: ITranslatableMessage): string {
const { key: message, args, namespaces } = translatable

return i18nTranslator(message, { ns: namespaces, replace: { ...args } })
}

/**
* Type check predicate for the ITranslatableMessage interface
*
* @param obj the value to typecheck
*
* @returns {boolean} true if the value implements the interface, false if not
*/
export function isTranslatableMessage(obj: any): obj is ITranslatableMessage {
if (!obj) {
return false
}

const { message, args, namespace } = obj

if (!message || typeof message !== 'string') {
return false
}

if (args && !checkArgs(args)) {
return false
}

if (namespace && typeof namespace !== 'string') {
return false
}

return true
}

function checkArgs(args: any): args is { [key: string]: any } {
if (args === undefined || args === null) {
return false
}

// this is good enough for object literals, which is what args essentially is
return args.constructor === Object
}
Loading