-
Notifications
You must be signed in to change notification settings - Fork 6
Add i18n Support #126
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
base: main
Are you sure you want to change the base?
Add i18n Support #126
Changes from all commits
6d107c9
074a7e7
8bc21ce
076a625
7ec848a
f96bd0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,29 @@ | ||
| const cds = require('@sap/cds') | ||
|
|
||
| const { copy, exists, path } = cds.utils | ||
| const { path } = cds.utils | ||
|
|
||
| module.exports = class NotificationsBuildPlugin extends cds.build.Plugin { | ||
| static taskDefaults = { src: cds.env.folders.srv } | ||
|
|
||
| static hasTask() { | ||
| const notificationTypesFile = cds.env.requires?.notifications?.types; | ||
| return notificationTypesFile === undefined ? false : exists(notificationTypesFile); | ||
| return !!cds.env.requires?.notifications | ||
| } | ||
|
|
||
| async build() { | ||
| if (exists(cds.env.requires.notifications?.types)) { | ||
| const fileName = path.basename(cds.env.requires.notifications.types); | ||
| await copy(cds.env.requires.notifications.types).to(path.join(this.task.dest, fileName)); | ||
| const model = await this.model() | ||
| if (!model) return | ||
|
|
||
| const { notificationTypesFromModel } = require('./compile') | ||
| const { readFile } = require('./utils') | ||
|
|
||
| const typesPath = cds.env.requires.notifications?.types | ||
| const types = [ | ||
| ...notificationTypesFromModel(model, this.task.src), | ||
| ...(typesPath ? readFile(typesPath) : []) | ||
| ] | ||
|
|
||
| if (types.length) { | ||
| await this.write(JSON.stringify(types, null, 2)).to(path.join(this.task.dest, 'notification-types.json')) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,91 @@ | ||||||||||
| const cds = require('@sap/cds') | ||||||||||
| const fs = require('fs') | ||||||||||
| const { join } = require('path') | ||||||||||
|
|
||||||||||
| function resolveEnum(val) { | ||||||||||
| if (val && typeof val === 'object' && '=' in val) return val['='] | ||||||||||
| return val | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function notificationTypesFromModel(model, baseDir) { | ||||||||||
| if (!model) return [] | ||||||||||
| const types = [] | ||||||||||
|
|
||||||||||
| const i18nBundles = loadI18nBundles(baseDir ?? cds.root) | ||||||||||
| if (!i18nBundles) cds.log('notifications').warn('No i18n files found, falling back to English only') | ||||||||||
| const locales = i18nBundles ? Object.keys(i18nBundles) : ['en'] | ||||||||||
|
|
||||||||||
| for (const def of Object.values(cds.reflect(model).definitions)) { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic Error: In More importantly, the Consider aligning the implementation to directly iterate for (const def of Object.values(model.definitions)) {
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||||||
| if (def.kind !== 'event') continue | ||||||||||
| if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue | ||||||||||
|
|
||||||||||
| const templates = locales.map(locale => { | ||||||||||
| const texts = i18nBundles?.[locale] ?? {} | ||||||||||
| const t = { Language: locale, TemplateLanguage: 'mustache' } | ||||||||||
| if (def['@description']) t.Description = resolveI18n(def['@description'], texts) | ||||||||||
| if (def['@notification.template.title']) t.TemplateSensitive = resolveI18n(def['@notification.template.title'], texts) | ||||||||||
| if (def['@notification.template.publicTitle']) t.TemplatePublic = resolveI18n(def['@notification.template.publicTitle'], texts) | ||||||||||
| if (def['@notification.template.subtitle']) t.Subtitle = resolveI18n(def['@notification.template.subtitle'], texts) | ||||||||||
| if (def['@notification.template.groupedTitle']) t.TemplateGrouped = resolveI18n(def['@notification.template.groupedTitle'], texts) | ||||||||||
| if (def['@notification.template.email.subject']) t.EmailSubject = resolveI18n(def['@notification.template.email.subject'], texts) | ||||||||||
| if (def['@notification.template.email.html']) t.EmailHtml = resolveI18n(def['@notification.template.email.html'], texts) | ||||||||||
| return t | ||||||||||
| }) | ||||||||||
|
|
||||||||||
| const type = { | ||||||||||
| NotificationTypeKey: def.name.split('.').pop(), | ||||||||||
| NotificationTypeVersion: '1', | ||||||||||
| Templates: templates, | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (def['@Common.SemanticObject']) type.NavigationTargetObject = def['@Common.SemanticObject'] | ||||||||||
| if (def['@Common.SemanticObjectAction']) type.NavigationTargetAction = def['@Common.SemanticObjectAction'] | ||||||||||
|
|
||||||||||
| if (def['@notification.deliveryChannels']?.length) { | ||||||||||
| type.DeliveryChannels = def['@notification.deliveryChannels'].map(ch => { | ||||||||||
| if (!ch.channel) return null | ||||||||||
| const entry = { Type: resolveEnum(ch.channel).toUpperCase() } | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug:
The guard Consider adding a string check after resolving the enum value:
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||||||
| if (ch.enabled !== undefined) entry.Enabled = ch.enabled | ||||||||||
| if (ch.defaultPreference !== undefined) entry.DefaultPreference = ch.defaultPreference | ||||||||||
| if (ch.editablePreference !== undefined) entry.EditablePreference = ch.editablePreference | ||||||||||
| return entry | ||||||||||
| }).filter(Boolean) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| types.push(type) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return types | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function parseProperties(content) { | ||||||||||
| const result = {} | ||||||||||
| for (const line of content.split('\n')) { | ||||||||||
| const trimmed = line.trim() | ||||||||||
| if (!trimmed || trimmed.startsWith('#')) continue | ||||||||||
| const eq = trimmed.indexOf('=') | ||||||||||
| if (eq >= 0) result[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim() | ||||||||||
| } | ||||||||||
| return result | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function loadI18nBundles(baseDir) { | ||||||||||
| const i18nDir = join(baseDir, '_i18n') | ||||||||||
| if (!fs.existsSync(i18nDir)) return null | ||||||||||
| const bundles = {} | ||||||||||
| for (const file of fs.readdirSync(i18nDir)) { | ||||||||||
| const m = file.match(/^i18n(?:_(.+))?\.properties$/) | ||||||||||
| if (!m) continue | ||||||||||
| bundles[m[1] ?? 'en'] = parseProperties(fs.readFileSync(join(i18nDir, file), 'utf8')) | ||||||||||
| } | ||||||||||
| return Object.keys(bundles).length ? bundles : null | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function resolveI18n(value, texts) { | ||||||||||
| if (typeof value !== 'string') return value | ||||||||||
| const match = value.match(/^\{i18n>([^}]+)\}$/) | ||||||||||
| if (!match) return value | ||||||||||
| return texts[match[1]] ?? value | ||||||||||
| } | ||||||||||
|
|
||||||||||
| module.exports = { notificationTypesFromModel } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| BOOK_ORDERED_DESCRIPTION=Book Ordered | ||
| BOOK_ORDERED_TITLE=Book Ordered | ||
| BOOK_ORDERED_PUBLIC_TITLE=Book Ordered | ||
| BOOK_ORDERED_SUBTITLE={{buyer}} ordered {{title}} | ||
| BOOK_ORDERED_GROUPED_TITLE=Bookshop Updates |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| BOOK_ORDERED_DESCRIPTION=Buch bestellt | ||
| BOOK_ORDERED_TITLE=Buch bestellt | ||
| BOOK_ORDERED_PUBLIC_TITLE=Buch bestellt | ||
| BOOK_ORDERED_SUBTITLE={{buyer}} hat {{title}} bestellt | ||
| BOOK_ORDERED_GROUPED_TITLE=Buchhandlung-Aktualisierungen |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| @description: '{i18n>BOOK_ORDERED_DESCRIPTION}' | ||
| @notification: { | ||
| template: { | ||
| title : '{i18n>BOOK_ORDERED_TITLE}', | ||
| publicTitle : '{i18n>BOOK_ORDERED_PUBLIC_TITLE}', | ||
| subtitle : '{i18n>BOOK_ORDERED_SUBTITLE}', | ||
| groupedTitle : '{i18n>BOOK_ORDERED_GROUPED_TITLE}', | ||
| } | ||
| } | ||
| event BookOrdered { | ||
| title : String; | ||
| buyer : String; | ||
| recipients: array of String; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,62 +1,97 @@ | ||
| const cds = require("@sap/cds"); | ||
| const { join } = cds.utils.path; | ||
| const { messages } = require("../../lib/utils"); | ||
| const cds = require("@sap/cds") | ||
| const { join } = cds.utils.path | ||
| const { messages } = require("../../lib/utils") | ||
|
|
||
| cds.test(join(__dirname, "../bookshop")); | ||
| cds.test(join(__dirname, "../bookshop")) | ||
|
|
||
| describe("Notifications Integration", () => { | ||
| let log = cds.test.log() | ||
| let alert; | ||
| let alert | ||
|
|
||
| beforeAll(async () => { | ||
| alert = await cds.connect.to("notifications"); | ||
| }); | ||
| alert = await cds.connect.to("notifications") | ||
| }) | ||
|
|
||
| test("Notifications service resolves to console implementation in development", async () => { | ||
| expect(alert.constructor.name).toBe("NotifyToConsole"); | ||
| }); | ||
| expect(alert.constructor.name).toBe("NotifyToConsole") | ||
| }) | ||
|
|
||
| test("Notification types are loaded into cds.notifications on startup", () => { | ||
| expect(cds.notifications?.local?.types).toBeDefined(); | ||
| expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered"); | ||
| }); | ||
| expect(cds.notifications?.local?.types).toBeDefined() | ||
| expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered") | ||
| }) | ||
|
|
||
| test("Sending a notification with unknown type key gives a warning", async () => { | ||
| await alert.notify("UnknownType", { | ||
| recipients: ["reader@bookshop.com"], | ||
| data: { title: "test" } | ||
| }); | ||
| }) | ||
|
|
||
| expect(log.output).toContain("UnknownType is not in the notification types file"); | ||
| }); | ||
| expect(log.output).toContain("UnknownType is not in the notification types file") | ||
| }) | ||
|
|
||
| test("Sending a default notification logs to console", async () => { | ||
| await alert.notify({ | ||
| recipients: ["reader@bookshop.com"], | ||
| title: "New book arrived", | ||
| description: "A new book has been added to the catalogue" | ||
| }); | ||
| }) | ||
|
|
||
| expect(log.output).toContain("Notification:"); | ||
| expect(log.output).toContain("NotificationTypeKey: 'Default'"); | ||
| expect(log.output).toContain("RecipientId: 'reader@bookshop.com'"); | ||
| expect(log.output).toContain("Value: 'New book arrived'"); | ||
| }); | ||
| expect(log.output).toContain("Notification:") | ||
| expect(log.output).toContain("NotificationTypeKey: 'Default'") | ||
| expect(log.output).toContain("RecipientId: 'reader@bookshop.com'") | ||
| expect(log.output).toContain("Value: 'New book arrived'") | ||
| }) | ||
|
|
||
| test("Sending a notification with no arguments warns and does nothing", async () => { | ||
| await alert.notify(); | ||
| await alert.notify() | ||
|
|
||
| expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY); | ||
| expect(log.output).not.toContain("Notification:"); | ||
| }); | ||
| expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY) | ||
| expect(log.output).not.toContain("Notification:") | ||
| }) | ||
|
|
||
| test("Custom typed notification uses prefixed type key from types file", async () => { | ||
| await alert.notify("BookOrdered", { | ||
| recipients: ["reader@bookshop.com"], | ||
| data: { title: "Moby Dick", buyer: "reader@bookshop.com" } | ||
| }); | ||
| }) | ||
|
|
||
| expect(log.output).toContain("bookshop/BookOrdered"); | ||
| expect(log.output).not.toContain("is not in the notification types file"); | ||
| }); | ||
| }); | ||
| expect(log.output).toContain("bookshop/BookOrdered") | ||
| expect(log.output).not.toContain("is not in the notification types file") | ||
| }) | ||
|
|
||
| test("Notification types from CDS and JSON are merged", () => { | ||
| expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered") | ||
| expect(cds.notifications.local.types).toHaveProperty("bookshop/BookReturned") | ||
| }) | ||
|
|
||
| test("Notification type templates have resolved i18n values", () => { | ||
| const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"] | ||
| expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered") | ||
| expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}") | ||
| }) | ||
|
|
||
| test("Notification type for BookOrdered has templates for all 44 ANS languages", () => { | ||
| const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"] | ||
| expect(type.Templates).toHaveLength(2) | ||
| expect(type.Templates.map(t => t.Language)).toContain("en") | ||
| expect(type.Templates.map(t => t.Language)).toContain("de") | ||
| }) | ||
|
|
||
| test("German template for BookOrdered has German translation", () => { | ||
| const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"] | ||
| const de = type.Templates.find(t => t.Language === "de") | ||
| expect(de.TemplateSensitive).toBe("Buch bestellt") | ||
| expect(de.Subtitle).toBe("{{buyer}} hat {{title}} bestellt") | ||
| }) | ||
|
|
||
| test("Notification type templates have resolved i18n values", () => { | ||
| const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"] | ||
| const en = type.Templates.find(t => t.Language === 'en') | ||
| const de = type.Templates.find(t => t.Language === 'de') | ||
| expect(en.TemplateSensitive).toBe("Book Ordered") | ||
| expect(en.Subtitle).toBe("{{buyer}} ordered {{title}}") | ||
| expect(de.TemplateSensitive).toBe("Buch bestellt") | ||
| expect(de.Subtitle).toBe("{{buyer}} hat {{title}} bestellt") | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
cds.load(srvPath)in the"served"handler loads the service model a second time, which is redundant and potentially inconsistentBy the time
"served"fires, CDS has already fully loaded and linked the application model (accessible viacds.model). Callingcds.load(srvPath)again performs a fresh, separate load that bypasses any model extensions or modifications applied during the normal boot sequence (including the"loaded"hook registered earlier in this same file). This means the freshly-loaded model may not reflect therecipientselements that the"loaded"hook injects.Instead, use the already-loaded and fully-resolved model:
Double-check suggestion before committing. Edit this comment for amendments.
Please provide feedback on the review comment by checking the appropriate box: