diff --git a/client/modules/i18n/main.js b/client/modules/i18n/main.js index 7a821d0b29f..4c743558c50 100644 --- a/client/modules/i18n/main.js +++ b/client/modules/i18n/main.js @@ -1,16 +1,9 @@ -import _ from "lodash"; import i18next from "i18next"; -import i18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; -import i18nextLocalStorageCache from "i18next-localstorage-cache"; -import i18nextSprintfPostProcessor from "i18next-sprintf-postprocessor"; -import i18nextJquery from "jquery-i18next"; import { Meteor } from "meteor/meteor"; import { Tracker } from "meteor/tracker"; -import { Session } from "meteor/session"; import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { Reaction } from "/client/api"; -import { Packages, Shops, Translations } from "/lib/collections"; -import * as Schemas from "/lib/collections/schemas"; +import { Packages, Translations } from "/lib/collections"; // // Reaction i18n Translations, RTL and Currency Exchange Support @@ -40,7 +33,7 @@ export function getBrowserLanguage() { * @param {String} name - name * @return {Object} return schema label object */ -function getLabelsFor(schema, name) { +export function getLabelsFor(schema, name) { const labels = {}; // loop through all the rendered form fields and generate i18n keys for (const fieldName of schema._schemaKeys) { @@ -68,7 +61,7 @@ function getLabelsFor(schema, name) { * @todo implement messaging hierarchy from simple-schema * @return {Object} returns i18n translated message for schema labels */ -function getMessagesFor() { +export function getMessagesFor() { const messages = {}; for (const message in SimpleSchema._globalMessages) { if ({}.hasOwnProperty.call(SimpleSchema._globalMessages, message)) { @@ -89,19 +82,14 @@ function getMessagesFor() { */ export const i18nextDep = new Tracker.Dependency(); export const localeDep = new Tracker.Dependency(); -const packageNamespaces = []; +export const packageNamespaces = []; Meteor.startup(() => { Tracker.autorun(function (c) { + // setting local and active packageNamespaces + // packageNamespaces are used to determine i18n namespace if (Reaction.Subscriptions.Shops.ready()) { - // TODO: i18nextBrowserLanguageDetector - // const defaultLanguage = lng.detect() || shop.language; - - // set default session language - Session.setDefault("language", getBrowserLanguage()); - - // every package gets a namespace, fetch them - // const packageNamespaces = []; + // every package gets a namespace, fetch them and export const packages = Packages.find({}, { fields: { name: 1 @@ -128,97 +116,4 @@ Meteor.startup(() => { }); }); -// use tracker autorun to detect language changes -Tracker.autorun(function () { - return Meteor.subscribe("Translations", Session.get("language"), () => { - // fetch reaction translations - const translations = Translations.find({}, { - fields: { - _id: 0 - } - }).fetch(); - - // map reduce translations into i18next formatting - const resources = translations.reduce(function (x, y) { - const ns = Object.keys(y.translation)[0]; - // first creating the structure, when add additional namespaces - if (x[y.i18n]) { - x[y.i18n][ns] = y.translation[ns]; - } else { - x[y.i18n] = y.translation; - } - return x; - }, {}); - - const shop = Shops.findOne(Reaction.getShopId()); - - // - // initialize i18next - // - i18next - .use(i18nextBrowserLanguageDetector) - .use(i18nextLocalStorageCache) - .use(i18nextSprintfPostProcessor) - .use(i18nextJquery) - .init({ - debug: false, - ns: packageNamespaces, // translation namespace for every package - defaultNS: "core", // reaction "core" is the default namespace - lng: Session.get("language"), // user session language - fallbackLng: shop ? shop.language : null, // Shop language - resources: resources - // saveMissing: true, - // missingKeyHandler: function (lng, ns, key, fallbackValue) { - // Meteor.call("i18n/addTranslation", lng, ns, key, fallbackValue); - // } - }, (err, t) => { - // someday this should work - // see: https://github.com/aldeed/meteor-simple-schema/issues/494 - for (const schema in _.omit(Schemas, "__esModule")) { - if ({}.hasOwnProperty.call(Schemas, schema)) { - const ss = Schemas[schema]; - ss.labels(getLabelsFor(ss, schema)); - ss.messages(getMessagesFor(ss, schema)); - } - } - - i18nextDep.changed(); - - // global first time init event finds and replaces - // data-i18n attributes in html/template source. - $elements = $("[data-i18n]").localize(); - - // apply language direction to html - if (t("languageDirection") === "rtl") { - return $("html").addClass("rtl"); - } - return $("html").removeClass("rtl"); - }); - }); -}); - -// -// init i18nextJquery -// -i18nextJquery.init(i18next, $, { - tName: "t", // --> appends $.t = i18next.t - i18nName: "i18n", // --> appends $.i18n = i18next - handleName: "localize", // --> appends $(selector).localize(opts); - selectorAttr: "data-i18n", // selector for translating elements - targetAttr: "data-i18n-target", // element attribute to grab target element to translate (if diffrent then itself) - parseDefaultValueFromContent: true // parses default values from content ele.val or ele.text -}); - -// global onRendered event finds and replaces -// data-i18n attributes in html/template source. -// uses methods from i18nextJquery -Template.onRendered(function () { - this.autorun((function () { - return function () { - i18nextDep.depend(); - $elements = $("[data-i18n]").localize(); - }; - })(this)); -}); - export default i18next; diff --git a/client/modules/i18n/startup.js b/client/modules/i18n/startup.js new file mode 100644 index 00000000000..26f01511883 --- /dev/null +++ b/client/modules/i18n/startup.js @@ -0,0 +1,140 @@ +import _ from "lodash"; +import i18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; +import i18nextLocalStorageCache from "i18next-localstorage-cache"; +import i18nextSprintfPostProcessor from "i18next-sprintf-postprocessor"; +import i18nextJquery from "jquery-i18next"; +import { Meteor } from "meteor/meteor"; +import { Tracker } from "meteor/tracker"; +import { Reaction } from "/client/api"; +import { Shops, Translations } from "/lib/collections"; +import * as Schemas from "/lib/collections/schemas"; +import i18next, { packageNamespaces, getLabelsFor, getMessagesFor, i18nextDep } from "./main"; + +// +// setup options for i18nextBrowserLanguageDetector +// note: this isn't fully operational yet +// language is set by user currently +// progress toward detecting language +// should focus around i18nextBrowserLanguageDetector +// +const options = { + // order and from where user language should be detected + order: ["querystring", "cookie", "localStorage", "navigator", "htmlTag"], + + // keys or params to lookup language from + lookupQuerystring: "lng", + lookupCookie: "i18next", + lookupLocalStorage: "i18nextLng", + + // cache user language on + caches: ["localStorage", "cookie"], + // optional htmlTag with lang attribute, the default is: + htmlTag: document.documentElement +}; + + +Meteor.startup(() => { + // use tracker autorun to detect language changes + // this only runs on initial page loaded + // and when user.profile.lang updates + Tracker.autorun(function () { + if (Reaction.Subscriptions.Shops.ready() && Meteor.user()) { + const shop = Shops.findOne(Reaction.getShopId()); + let language = shop.language; + if (Meteor.user() && Meteor.user().profile && Meteor.user().profile.lang) { + language = Meteor.user().profile.lang; + } + // + // subscribe to user + shop Translations + // + return Meteor.subscribe("Translations", language, () => { + // fetch reaction translations + const translations = Translations.find({}, { + fields: { + _id: 0 + } + }).fetch(); + + // map reduce translations into i18next formatting + const resources = translations.reduce(function (x, y) { + const ns = Object.keys(y.translation)[0]; + // first creating the structure, when add additional namespaces + if (x[y.i18n]) { + x[y.i18n][ns] = y.translation[ns]; + } else { + x[y.i18n] = y.translation; + } + return x; + }, {}); + + // + // initialize i18next + // + i18next + .use(i18nextBrowserLanguageDetector) + .use(i18nextLocalStorageCache) + .use(i18nextSprintfPostProcessor) + .use(i18nextJquery) + .init({ + detection: options, + debug: false, + ns: packageNamespaces, // translation namespace for every package + defaultNS: "core", // reaction "core" is the default namespace + lng: language, // user session language + fallbackLng: shop ? shop.language : null, // Shop language + resources: resources + // saveMissing: true, + // missingKeyHandler: function (lng, ns, key, fallbackValue) { + // Meteor.call("i18n/addTranslation", lng, ns, key, fallbackValue); + // } + }, (err, t) => { + // someday this should work + // see: https://github.com/aldeed/meteor-simple-schema/issues/494 + for (const schema in _.omit(Schemas, "__esModule")) { + if ({}.hasOwnProperty.call(Schemas, schema)) { + const ss = Schemas[schema]; + ss.labels(getLabelsFor(ss, schema)); + ss.messages(getMessagesFor(ss, schema)); + } + } + + i18nextDep.changed(); + + // global first time init event finds and replaces + // data-i18n attributes in html/template source. + $elements = $("[data-i18n]").localize(); + + // apply language direction to html + if (t("languageDirection") === "rtl") { + return $("html").addClass("rtl"); + } + return $("html").removeClass("rtl"); + }); + }); // return + } + }); + + // + // init i18nextJquery + // + i18nextJquery.init(i18next, $, { + tName: "t", // --> appends $.t = i18next.t + i18nName: "i18n", // --> appends $.i18n = i18next + handleName: "localize", // --> appends $(selector).localize(opts); + selectorAttr: "data-i18n", // selector for translating elements + targetAttr: "data-i18n-target", // element attribute to grab target element to translate (if diffrent then itself) + parseDefaultValueFromContent: true // parses default values from content ele.val or ele.text + }); + + // global onRendered event finds and replaces + // data-i18n attributes in html/template source. + // uses methods from i18nextJquery + Template.onRendered(function () { + this.autorun((function () { + return function () { + i18nextDep.depend(); + $elements = $("[data-i18n]").localize(); + }; + })(this)); + }); +}); diff --git a/client/modules/i18n/templates/header/i18n.html b/client/modules/i18n/templates/header/i18n.html index 10daac5c9d3..da9f628ace1 100644 --- a/client/modules/i18n/templates/header/i18n.html +++ b/client/modules/i18n/templates/header/i18n.html @@ -9,7 +9,7 @@
  • {{#each languages}} -
  • +
  • {{label}}
  • {{/each}} diff --git a/client/modules/i18n/templates/header/i18n.js b/client/modules/i18n/templates/header/i18n.js index 26af4cca63a..3edb065bfbf 100644 --- a/client/modules/i18n/templates/header/i18n.js +++ b/client/modules/i18n/templates/header/i18n.js @@ -1,7 +1,5 @@ import { Reaction } from "/client/api"; import { Shops } from "/lib/collections"; -import { Session } from "meteor/session"; - /** * i18nChooser helpers */ @@ -9,12 +7,24 @@ import { Session } from "meteor/session"; Template.i18nChooser.helpers({ languages() { const languages = []; - if (Reaction.Subscriptions.Shops.ready()) { + if (Reaction.Subscriptions.Shops.ready() && Meteor.user()) { const shop = Shops.findOne(); if (typeof shop === "object" && shop.languages) { for (const language of shop.languages) { if (language.enabled === true) { language.translation = "languages." + language.label.toLowerCase(); + // appending a helper to let us know this + // language is currently selected + const profile = Meteor.user().profile; + if (profile && profile.lang) { + if (profile.lang === language.i18n) { + language.class = "active"; + } + } else if (shop.language === language.i18n) { + // we don't have a profile language + // use the shop default + language.class = "active"; + } languages.push(language); } } @@ -23,11 +33,7 @@ Template.i18nChooser.helpers({ } } } - }, - active() { - if (Session.equals("language", this.i18n)) { - return "active"; - } + return languages; } }); @@ -38,6 +44,11 @@ Template.i18nChooser.helpers({ Template.i18nChooser.events({ "click .i18n-language"(event) { event.preventDefault(); - return Session.set("language", this.i18n); + // + // this is a sanctioned use of Meteor.user.update + // and only possible because we allow it in the + // UserProfile and ShopMembers publications. + // + Meteor.users.update(Meteor.userId(), {$set: {"profile.lang": this.i18n}}); } }); diff --git a/server/publications/collections/accounts.js b/server/publications/collections/accounts.js index 4ff94545491..74ff8c1eac8 100644 --- a/server/publications/collections/accounts.js +++ b/server/publications/collections/accounts.js @@ -55,6 +55,7 @@ Meteor.publish("UserProfile", function (profileUserId) { // no need to normal user so see his password hash const fields = { "emails": 1, + "profile.lang": 1, "profile.firstName": 1, "profile.lastName": 1, "profile.familyName": 1, diff --git a/server/publications/collections/members.js b/server/publications/collections/members.js index acc4c79e86a..6808a4bf112 100644 --- a/server/publications/collections/members.js +++ b/server/publications/collections/members.js @@ -34,6 +34,7 @@ Meteor.publish("ShopMembers", function () { emails: 1, username: 1, roles: 1, + "profile.lang": 1, "services.google.name": 1, "services.google.email": 1, "services.google.picture": 1, diff --git a/server/publications/collections/translations.js b/server/publications/collections/translations.js index d9bba5feca7..7aa4f573287 100644 --- a/server/publications/collections/translations.js +++ b/server/publications/collections/translations.js @@ -3,24 +3,34 @@ import { Reaction } from "/server/api"; /** * Translations publication - * @param {String} sessionLanguage - current sessionLanguage default to 'en' + * @param {String, Array} sessionLanguages - current sessionLanguage default to 'en' + * @returns { Object } returns Translations + * @todo like to see the langages validated more with a schema */ -Meteor.publish("Translations", function (sessionLanguage) { - check(sessionLanguage, Match.OneOf(null, String)); - // the language is prone to initialize empty at first, but - // we're reactive and will re-subscribe once we have the langauge - // on the client - if (sessionLanguage) { - const shopId = Reaction.getShopId(); - const shopLanguage = Shops.findOne(Reaction.getShopId()).language || "en"; - return Translations.find({ - $or: [{ - i18n: shopLanguage, - shopId: shopId - }, { - i18n: sessionLanguage, - shopId: shopId - }] +Meteor.publish("Translations", function (languages) { + check(languages, Match.OneOf(String, Array)); + const shopId = Reaction.getShopId(); + const shopLanguage = Shops.findOne(shopId).language; + const sessionLanguages = []; + const langTranQuery = []; + + // set shop default + sessionLanguages.push(shopLanguage); + // lets get all these langauges + if (typeof languages === "array") { + sessionLanguages.concat(languages); + } else { + sessionLanguages.push(languages); + } + // add in the shop filter + for (const sessionLanguage of sessionLanguages) { + langTranQuery.push({ + i18n: sessionLanguage, + shopId: shopId }); } + + return Translations.find({ + $or: langTranQuery + }); }); diff --git a/server/startup/accounts.js b/server/startup/accounts.js index 2567e7592a4..7382dcfe530 100644 --- a/server/startup/accounts.js +++ b/server/startup/accounts.js @@ -51,7 +51,6 @@ export default function () { if (!options.anonymous) { return {}; } - let loginHandler; const stampedToken = Accounts._generateStampedLoginToken(); const userId = Accounts.insertUserDoc({ services: { @@ -59,7 +58,7 @@ export default function () { }, token: stampedToken.token }); - loginHandler = { + const loginHandler = { type: "anonymous", userId: userId }; @@ -72,6 +71,9 @@ export default function () { * adds Accounts record for reaction user profiles * we clone the user into accounts, as the user collection is * only to be used for authentication. + * - defaultVisitorRole + * - defaultRoles + * can be overriden from Shops * * @see: http://docs.meteor.com/#/full/accounts_oncreateuser */ @@ -88,6 +90,17 @@ export default function () { // init default user roles // we won't create users unless we have a shop. if (shop) { + // retain language when user has defined a language + // perhaps should be treated as additionals + // or in onLogin below, or in the anonymous method options + if (!(Meteor.users.find().count() === 0)) { // dont set on inital admin + if (!user.profile) user.profile = {}; + const currentUser = Meteor.user(user); + if (currentUser && currentUser.profile && currentUser.profile.lang && !user.profile.lang) { + user.profile.lang = currentUser.profile.lang; + } + } + // if we don't have user.services we're an anonymous user if (!user.services) { roles[shopId] = shop.defaultVisitorRole || defaultVisitorRole; @@ -137,7 +150,6 @@ export default function () { // run onCreateUser hooks // (the user object must be returned by all callbacks) const userDoc = Hooks.Events.run("onCreateUser", user, options); - return userDoc; } }); @@ -180,6 +192,7 @@ export default function () { const cart = Collections.Cart.findOne({ userId: options.user._id }); + // for a rare use cases if (typeof cart !== "object") return false; // in current version currentSessionId will be available for anonymous