From 77baffb85cb6789993ad0903269c4dcf61d3bf62 Mon Sep 17 00:00:00 2001 From: Francis Lachapelle Date: Mon, 11 Jan 2016 16:32:12 -0500 Subject: [PATCH] Localize datepicker and respect user's defaults --- UI/Common/English.lproj/Localizable.strings | 2 + UI/PreferencesUI/UIxJSONPreferences.m | 24 ++- .../js/Common/sgTimepicker.directive.js | 95 ++++++++---- UI/WebServerResources/js/Common/utils.js | 141 +++++++++++++++++- .../js/Preferences/Preferences.service.js | 22 ++- 5 files changed, 238 insertions(+), 46 deletions(-) diff --git a/UI/Common/English.lproj/Localizable.strings b/UI/Common/English.lproj/Localizable.strings index 54f38f4a2..386fcfbb3 100644 --- a/UI/Common/English.lproj/Localizable.strings +++ b/UI/Common/English.lproj/Localizable.strings @@ -104,3 +104,5 @@ "Loading" = "Loading"; "No such user." = "No such user."; "You cannot (un)subscribe to a folder that you own!" = "You cannot (un)subscribe to a folder that you own!"; +/* Aria label for datepicker button */ +"Open Calendar" = "Open Calendar"; \ No newline at end of file diff --git a/UI/PreferencesUI/UIxJSONPreferences.m b/UI/PreferencesUI/UIxJSONPreferences.m index 8a7dd9418..67d405219 100644 --- a/UI/PreferencesUI/UIxJSONPreferences.m +++ b/UI/PreferencesUI/UIxJSONPreferences.m @@ -44,18 +44,6 @@ static SoProduct *preferencesProduct = nil; @implementation UIxJSONPreferences -- (WOResponse *) _makeResponse: (NSDictionary *) values -{ - WOResponse *response; - - response = [context response]; - [response setHeader: @"text/plain; charset=utf-8" - forKey: @"content-type"]; - [response appendContentString: [values jsonRepresentation]]; - - return response; -} - - (WOResponse *) jsonDefaultsAction { NSMutableDictionary *values, *account; @@ -134,6 +122,8 @@ static SoProduct *preferencesProduct = nil; sortedArrayUsingSelector: @selector (localizedCaseInsensitiveCompare:)]; [defaults setCalendarCategories: categoryLabels]; + + // TODO: build categories colors dictionary with localized keys } if (![defaults calendarCategoriesColors]) { @@ -222,6 +212,12 @@ static SoProduct *preferencesProduct = nil; // Add locale code (used by CK Editor) locale = [[preferencesProduct resourceManager] localeForLanguageNamed: [defaults language]]; [values setObject: [locale objectForKey: @"NSLocaleCode"] forKey: @"LocaleCode"]; + [values setObject: [NSDictionary dictionaryWithObjectsAndKeys: + [locale objectForKey: @"NSMonthNameArray"], @"months", + [locale objectForKey: @"NSShortMonthNameArray"], @"shortMonths", + [locale objectForKey: @"NSWeekDayNameArray"], @"days", + [locale objectForKey: @"NSShortWeekDayNameArray"], @"shortDays", + nil] forKey: @"locale"]; accounts = [NSMutableArray arrayWithArray: [values objectForKey: @"AuxiliaryMailAccounts"]]; account = [[[context activeUser] mailAccounts] objectAtIndex: 0]; @@ -238,7 +234,7 @@ static SoProduct *preferencesProduct = nil; [values setObject: accounts forKey: @"AuxiliaryMailAccounts"]; - return [self _makeResponse: values]; + return [self responseWithStatus: 200 andJSONRepresentation: values]; } - (WOResponse *) jsonSettingsAction @@ -269,7 +265,7 @@ static SoProduct *preferencesProduct = nil; if (![settings objectForKey: @"Mail"]) [settings setObject: [NSMutableDictionary dictionary] forKey: @"Mail"]; - return [self _makeResponse: [[settings source] values]]; + return [self responseWithStatus: 200 andJSONRepresentation: [[settings source] values]]; } @end diff --git a/UI/WebServerResources/js/Common/sgTimepicker.directive.js b/UI/WebServerResources/js/Common/sgTimepicker.directive.js index 414731d2c..ee6248605 100644 --- a/UI/WebServerResources/js/Common/sgTimepicker.directive.js +++ b/UI/WebServerResources/js/Common/sgTimepicker.directive.js @@ -436,14 +436,19 @@ * * ngInject @constructor */ - function TimePickerCtrl($scope, $element, $attrs, $compile, $timeout, $mdConstant, $mdMedia, $mdTheming, - $mdUtil, $mdDateLocale, $$mdDateUtil, $$rAF) { + TimePickerCtrl.$inject = ["$scope", "$element", "$attrs", "$compile", "$timeout", "$window", + "$mdConstant", "$mdMedia", "$mdTheming", "$mdUtil", "$mdDateLocale", "$$mdDateUtil", "$$rAF"]; + function TimePickerCtrl($scope, $element, $attrs, $compile, $timeout, $window, + $mdConstant, $mdMedia, $mdTheming, $mdUtil, $mdDateLocale, $$mdDateUtil, $$rAF) { /** @final */ this.$compile = $compile; /** @final */ this.$timeout = $timeout; + /** @final */ + this.$window = $window; + /** @final */ this.dateLocale = $mdDateLocale; @@ -542,32 +547,29 @@ }); } - TimePickerCtrl.$inject = ["$scope", "$element", "$attrs", "$compile", "$timeout", "$mdConstant", "$mdMedia", "$mdTheming", - "$mdUtil", "$mdDateLocale", "$$mdDateUtil", "$$rAF"]; - /** * Sets up the controller's reference to ngModelController. * @param {!angular.NgModelController} ngModelCtrl */ TimePickerCtrl.prototype.configureNgModel = function(ngModelCtrl) { this.ngModelCtrl = ngModelCtrl; + var self = this; ngModelCtrl.$render = function() { - self.time = self.ngModelCtrl.$viewValue; - self.inputElement.value = self.formatTime(self.time); + var value = self.ngModelCtrl.$viewValue; + + if (value && !(value instanceof Date)) { + throw Error('The ng-model for sg-timepicker must be a Date instance. ' + + 'Currently the model is a: ' + (typeof value)); + } + + self.time = value; + self.inputElement.value = self.dateLocale.formatTime(value); self.resizeInputElement(); + self.updateErrorState(); }; }; - TimePickerCtrl.prototype.formatTime = function(time) { - var t = new Date(time); - if (t) { - var h = t.getHours(); - var m = t.getMinutes(); - return (h < 10? ('0' + h) : h) + ':' + (m < 10? ('0' + m) : m); - } - else return ''; - }; /** * Attach event listeners for both the text input and the md-time. * Events are used instead of ng-model so that updates don't infinitely update the other @@ -580,7 +582,7 @@ var time = new Date(data.date); self.ngModelCtrl.$setViewValue(time); self.time = time; - self.inputElement.value = self.formatTime(self.time); + self.inputElement.value = self.dateLocale.formatTime(time); if (data.changed == 'minutes') { self.closeTimePane(); } @@ -646,6 +648,45 @@ this.timeButton.disabled = isDisabled; }; + /** + * Sets the custom ngModel.$error flags to be consumed by ngMessages. Flags are: + * - mindate: whether the selected date is before the minimum date. + * - maxdate: whether the selected flag is after the maximum date. + * - filtered: whether the selected date is allowed by the custom filtering function. + * - valid: whether the entered text input is a valid date + * + * The 'required' flag is handled automatically by ngModel. + * + * @param {Date=} opt_date Date to check. If not given, defaults to the datepicker's model value. + */ + TimePickerCtrl.prototype.updateErrorState = function(opt_date) { + var date = opt_date || this.date; + + // Clear any existing errors to get rid of anything that's no longer relevant. + this.clearErrorState(); + + if (!this.dateUtil.isValidDate(date)) { + // The date is seen as "not a valid date" if there is *something* set + // (i.e.., not null or undefined), but that something isn't a valid date. + this.ngModelCtrl.$setValidity('valid', date === null); + } + + // TODO(jelbourn): Change this to classList.toggle when we stop using PhantomJS in unit tests + // because it doesn't conform to the DOMTokenList spec. + // See https://github.com/ariya/phantomjs/issues/12782. + if (!this.ngModelCtrl.$valid) { + this.inputContainer.classList.add(INVALID_CLASS); + } + }; + + /** Clears any error flags set by `updateErrorState`. */ + TimePickerCtrl.prototype.clearErrorState = function() { + this.inputContainer.classList.remove(INVALID_CLASS); + ['valid'].forEach(function(field) { + this.ngModelCtrl.$setValidity(field, true); + }, this); + }; + /** * Resizes the input element based on the size of its content. */ @@ -659,7 +700,7 @@ */ TimePickerCtrl.prototype.handleInputEvent = function(self) { var inputString = this.inputElement.value; - var arr = inputString.split(':'); + var arr = inputString.split(/[\.:]/); if (inputString === '') { this.ngModelCtrl.$setViewValue(null); @@ -721,7 +762,7 @@ } timePane.style.top = paneTop + 'px'; - document.body.appendChild(this.timePane); + document.body.appendChild(timePane); // The top of the calendar pane is a transparent box that shows the text input underneath. // Since the pane is floating, though, the page underneath the pane *adjacent* to the input is @@ -780,14 +821,16 @@ /** Close the floating time pane. */ TimePickerCtrl.prototype.closeTimePane = function() { - this.isTimeOpen = false; - this.detachTimePane(); - this.timePaneOpenedFrom.focus(); - this.timePaneOpenedFrom = null; - this.$mdUtil.enableScrolling(); + if (this.isTimeOpen) { + this.isTimeOpen = false; + this.detachTimePane(); + this.timePaneOpenedFrom.focus(); + this.timePaneOpenedFrom = null; + this.$mdUtil.enableScrolling(); - document.body.removeEventListener('click', this.bodyClickHandler); - window.removeEventListener('resize', this.windowResizeHandler); + document.body.removeEventListener('click', this.bodyClickHandler); + window.removeEventListener('resize', this.windowResizeHandler); + } }; /** Gets the controller instance for the time in the floating pane. */ diff --git a/UI/WebServerResources/js/Common/utils.js b/UI/WebServerResources/js/Common/utils.js index 20711634d..4f4e822ea 100644 --- a/UI/WebServerResources/js/Common/utils.js +++ b/UI/WebServerResources/js/Common/utils.js @@ -173,6 +173,75 @@ String.prototype.timeInterval = function () { return interval; }; +String.prototype.parseDate = function(localeProvider, format) { + var string, formattingTokens, tokens, token, now, date, regexes, i, parsedInput, matchesCount; + + string = '' + this; + formattingTokens = /%[dembByY]/g; + now = new Date(); + date = { + year: -1, + month: -1, + day: -1 + }; + regexes = { + '%d': [/\d\d/, function(input) { + date.day = parseInt(input); + return (date.day < 32); + }], + '%e': [/ ?\d?\d/, function(input) { + date.day = parseInt(input); + return (date.day < 32); + }], + '%m': [/\d\d/, function(input) { + date.month = parseInt(input) - 1; + return (date.month < 12); + }], + '%b': [/[^\d\s\.\/\-]{2,}/, function(input) { + var i = _.indexOf(localeProvider.shortMonths, input); + if (i >= 0) + date.month = i; + return (i >= 0); + }], + '%B': [/[^\d\s\.\/\-]{2,}/, function(input) { + var i = _.indexOf(localeProvider.months, input); + if (i >= 0) + date.month = i; + return (i >= 0); + }], + '%y': [/\d\d/, function(input) { + var nearFuture = parseInt(now.getFullYear().toString().substring(2)) + 5; + date.year = parseInt(input); + if (date.year < nearFuture) date.year += 2000; + else date.year += 1900; + return true; + }], + '%Y': [/[12]\d\d\d/, function(input) { + date.year = parseInt(input); + return true; + }] + }; + tokens = format.match(formattingTokens) || []; + matchesCount = 0; + + for (i = 0; i < tokens.length; i++) { + token = tokens[i]; + parsedInput = (string.match(regexes[token][0]) || [])[0]; + if (parsedInput) { + string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + if (regexes[token][1](parsedInput)) + matchesCount++; + } + } + + if (tokens.length === matchesCount) { + // console.debug(this + ' + ' + format + ' = ' + JSON.stringify(date)); + return new Date(date.year, date.month, date.day); + } + else + return new Date(NaN); +}; + Date.prototype.daysUpTo = function(otherDate) { var days = []; @@ -308,19 +377,83 @@ Date.prototype.getHourString = function() { return newString; }; +Date.prototype.format = function(localeProvider, format) { + var separators, parts, i, max, + date = [], + validParts = /%[daAmbByYHIM]/g, + val = { + '%d': this.getUTCDate(), // day of month (e.g., 01) + '%e': this.getUTCDate(), // day of month, space padded + '%a': localeProvider.shortDays[this.getUTCDay()], // locale's abbreviated weekday name (e.g., Sun) + '%A': localeProvider.days[this.getUTCDay()], // locale's full weekday name (e.g., Sunday) + '%m': this.getUTCMonth() + 1, // month (01..12) + '%b': localeProvider.shortMonths[this.getUTCMonth()], // locale's abbreviated month name (e.g., Jan) + '%B': localeProvider.months[this.getUTCMonth()], // locale's full month name (e.g., January) + '%y': this.getUTCFullYear().toString().substring(2), // last two digits of year (00..99) + '%Y': this.getUTCFullYear(), // year + '%H': this.getHours(), // hour (00..23) + '%M': this.getMinutes() }; // minute (00..59) + val['%I'] = val['%H'] > 12 ? val['%H'] % 12 : val['%H']; // hour (01..12) + + val['%d'] = (val['%d'] < 10 ? '0' : '') + val['%d']; + val['%e'] = (val['%e'] < 10 ? ' ' : '') + val['%e']; + val['%m'] = (val['%m'] < 10 ? '0' : '') + val['%m']; + val['%H'] = (val['%H'] < 10 ? '0' : '') + val['%H']; + val['%I'] = (val['%I'] < 10 ? '0' : '') + val['%I']; + val['%M'] = (val['%M'] < 10 ? '0' : '') + val['%M']; + + separators = format.replace(validParts, '\0').split('\0'); + parts = format.match(validParts); + for (i = 0, max = parts.length; i <= max; i++){ + if (separators.length) + date.push(separators.shift()); + date.push(val[parts[i]]); + } + + return date.join(''); +}; + /* Functions */ function l() { - var key = arguments[0]; - var value = key; + var key = arguments[0], value = key, args = arguments, i, j; + + // Retrieve translation if (labels[key]) { value = labels[key]; } else if (clabels[key]) { value = clabels[key]; } - for (var i = 1, j = 0; i < arguments.length; i++, j++) { - value = value.replace('%{' + j + '}', arguments[i]); + + // Format placeholders %{0}, %{1], %{2}, ... + for (i = 1, j = 0; i < args.length; i++, j++) { + value = value.replace('%{' + j + '}', args[i]); + } + + // Format placeholders %d and %s + i = 1; + if (args.length > 1) { + value = value.replace(/%((%)|s|d)/g, function(m) { + // m is the matched format, e.g. %s, %d + var val = null; + if (m[2]) { + val = m[2]; + } + else { + val = args[i]; + // A switch statement so that the formatter can be extended. Default is %s + switch (m) { + case '%d': + val = parseFloat(val); + if (isNaN(val)) + val = 0; + break; + } + i++; + } + return val; + }); } return value; diff --git a/UI/WebServerResources/js/Preferences/Preferences.service.js b/UI/WebServerResources/js/Preferences/Preferences.service.js index 6e3baaa52..9cb0a4802 100644 --- a/UI/WebServerResources/js/Preferences/Preferences.service.js +++ b/UI/WebServerResources/js/Preferences/Preferences.service.js @@ -65,11 +65,28 @@ angular.extend(_this.defaults, data); + angular.extend(Preferences.$mdDateLocaleProvider, data.locale); + Preferences.$mdDateLocaleProvider.firstDayOfWeek = parseInt(data.SOGoFirstDayOfWeek); + Preferences.$mdDateLocaleProvider.weekNumberFormatter = function(weekNumber) { + return l('Week %d', weekNumber); + }; + Preferences.$mdDateLocaleProvider.msgCalendar = l('Calender'); + Preferences.$mdDateLocaleProvider.msgOpenCalendar = l('Open Calendar'); + Preferences.$mdDateLocaleProvider.parseDate = function(dateString) { + return dateString? dateString.parseDate(Preferences.$mdDateLocaleProvider, data.SOGoShortDateFormat) : new Date(NaN); + }; + Preferences.$mdDateLocaleProvider.formatDate = function(date) { + return date? date.format(Preferences.$mdDateLocaleProvider, data.SOGoShortDateFormat) : ''; + }; + Preferences.$mdDateLocaleProvider.formatTime = function(date) { + return date? date.format(Preferences.$mdDateLocaleProvider, data.SOGoTimeFormat) : ''; + }; + return _this.defaults; }); this.settingsPromise = Preferences.$$resource.fetch("jsonSettings").then(function(data) { - // We convert our PreventInvitationsWhitelist hash into a array of user + // We convert our PreventInvitationsWhitelist hash into a array of user if (data.Calendar) { if (data.Calendar.PreventInvitationsWhitelist) data.Calendar.PreventInvitationsWhitelist = _.map(data.Calendar.PreventInvitationsWhitelist, function(value, key) { @@ -91,11 +108,12 @@ * @desc The factory we'll use to register with Angular * @returns the Preferences constructor */ - Preferences.$factory = ['$q', '$timeout', '$log', 'sgSettings', 'Resource', 'User', function($q, $timeout, $log, Settings, Resource, User) { + Preferences.$factory = ['$q', '$timeout', '$log', '$mdDateLocale', 'sgSettings', 'Resource', 'User', function($q, $timeout, $log, $mdDateLocaleProvider, Settings, Resource, User) { angular.extend(Preferences, { $q: $q, $timeout: $timeout, $log: $log, + $mdDateLocaleProvider: $mdDateLocaleProvider, $$resource: new Resource(Settings.activeUser('folderURL'), Settings.activeUser()), activeUser: Settings.activeUser(), $User: User