diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 08:00:07 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 08:00:07 +0000 |
commit | 71b1940231738aac70ede9ecb211614eae8873aa (patch) | |
tree | d42a8e658862d9e2c3d2ec144394170bc321f5a0 /content | |
parent | Initial commit. (diff) | |
download | eas4tbsync-upstream/4.7.tar.xz eas4tbsync-upstream/4.7.zip |
Adding upstream version 4.7.upstream/4.7
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
33 files changed, 8741 insertions, 0 deletions
diff --git a/content/api/BootstrapLoader/CHANGELOG.md b/content/api/BootstrapLoader/CHANGELOG.md new file mode 100644 index 0000000..5006ecf --- /dev/null +++ b/content/api/BootstrapLoader/CHANGELOG.md @@ -0,0 +1,75 @@ +Version: 1.21 +------------- +- Explicitly set hasAddonManagerEventListeners flag to false on uninstall + +Version: 1.20 +------------- +- hard fork BootstrapLoader v1.19 implementation and continue to serve it for + Thunderbird 111 and older +- BootstrapLoader v1.20 has removed a lot of unnecessary code used for backward + compatibility + +Version: 1.19 +------------- +- fix race condition which could prevent the AOM tab to be monkey patched correctly + +Version: 1.18 +------------- +- be precise on which revision the wrench symbol should be displayed, instead of + the options button + +Version: 1.17 +------------- +- fix "ownerDoc.getElementById() is undefined" bug + +Version: 1.16 +------------- +- fix "tab.browser is undefined" bug + +Version 1.15 +------------ +- clear cache only if add-on is uninstalled/updated, not on app shutdown + +Version 1.14 +------------ +- fix for TB90 ("view-loaded" event) and TB78.10 (wrench icon for options) + +Version 1.13 +------------ +- removed notifyTools and move it into its own NotifyTools API + +Version 1.12 +------------ +- add support for notifyExperiment and onNotifyBackground + +Version 1.11 +------------ +- add openOptionsDialog() + +Version 1.10 +------------ +- fix for 68 + +Version 1.7 +----------- +- fix for beta 87 + +Version 1.6 +----------- +- add support for options button/menu in add-on manager and fix 68 double menu entry + +Version 1.5 +----------- +- fix for e10s + +Version 1.4 +----------- +- add registerOptionsPage + +Version 1.3 +----------- +- flush cache + +Version 1.2 +----------- +- add support for resource urls diff --git a/content/api/BootstrapLoader/README.md b/content/api/BootstrapLoader/README.md new file mode 100644 index 0000000..7e8fe2a --- /dev/null +++ b/content/api/BootstrapLoader/README.md @@ -0,0 +1 @@ +Usage description can be found in the [wiki](https://github.com/thundernest/addon-developer-support/wiki/Using-the-BootstrapLoader-API-to-convert-a-Legacy-Bootstrap-WebExtension-into-a-MailExtension-for-Thunderbird-78). diff --git a/content/api/BootstrapLoader/implementation.js b/content/api/BootstrapLoader/implementation.js new file mode 100644 index 0000000..03e6b76 --- /dev/null +++ b/content/api/BootstrapLoader/implementation.js @@ -0,0 +1,917 @@ +/* + * This file is provided by the addon-developer-support repository at + * https://github.com/thundernest/addon-developer-support + * + * Version: 1.21 + * + * Author: John Bieling (john@thunderbird.net) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Get various parts of the WebExtension framework that we need. +var { ExtensionCommon } = ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm"); +var { ExtensionSupport } = ChromeUtils.import("resource:///modules/ExtensionSupport.jsm"); +var { AddonManager } = ChromeUtils.import("resource://gre/modules/AddonManager.jsm"); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +function getThunderbirdVersion() { + let parts = Services.appinfo.version.split("."); + return { + major: parseInt(parts[0]), + minor: parseInt(parts[1]), + revision: parts.length > 2 ? parseInt(parts[2]) : 0, + } +} + +function getMessenger(context) { + let apis = ["storage", "runtime", "extension", "i18n"]; + + function getStorage() { + let localstorage = null; + try { + localstorage = context.apiCan.findAPIPath("storage"); + localstorage.local.get = (...args) => + localstorage.local.callMethodInParentProcess("get", args); + localstorage.local.set = (...args) => + localstorage.local.callMethodInParentProcess("set", args); + localstorage.local.remove = (...args) => + localstorage.local.callMethodInParentProcess("remove", args); + localstorage.local.clear = (...args) => + localstorage.local.callMethodInParentProcess("clear", args); + } catch (e) { + console.info("Storage permission is missing"); + } + return localstorage; + } + + let messenger = {}; + for (let api of apis) { + switch (api) { + case "storage": + XPCOMUtils.defineLazyGetter(messenger, "storage", () => + getStorage() + ); + break; + + default: + XPCOMUtils.defineLazyGetter(messenger, api, () => + context.apiCan.findAPIPath(api) + ); + } + } + return messenger; +} + +var BootstrapLoader_102 = class extends ExtensionCommon.ExtensionAPI { + getCards(e) { + // This gets triggered by real events but also manually by providing the outer window. + // The event is attached to the outer browser, get the inner one. + let doc; + + // 78,86, and 87+ need special handholding. *Yeah*. + if (getThunderbirdVersion().major < 86) { + let ownerDoc = e.document || e.target.ownerDocument; + doc = ownerDoc.getElementById("html-view-browser").contentDocument; + } else if (getThunderbirdVersion().major < 87) { + let ownerDoc = e.document || e.target; + doc = ownerDoc.getElementById("html-view-browser").contentDocument; + } else { + doc = e.document || e.target; + } + return doc.querySelectorAll("addon-card"); + } + + // Add pref entry to 68 + add68PrefsEntry(event) { + let id = this.menu_addonPrefs_id + "_" + this.uniqueRandomID; + + // Get the best size of the icon (16px or bigger) + let iconSizes = this.extension.manifest.icons + ? Object.keys(this.extension.manifest.icons) + : []; + iconSizes.sort((a, b) => a - b); + let bestSize = iconSizes.filter(e => parseInt(e) >= 16).shift(); + let icon = bestSize ? this.extension.manifest.icons[bestSize] : ""; + + let name = this.extension.manifest.name; + let entry = icon + ? event.target.ownerGlobal.MozXULElement.parseXULToFragment( + `<menuitem class="menuitem-iconic" id="${id}" image="${icon}" label="${name}" />`) + : event.target.ownerGlobal.MozXULElement.parseXULToFragment( + `<menuitem id="${id}" label="${name}" />`); + + event.target.appendChild(entry); + let noPrefsElem = event.target.querySelector('[disabled="true"]'); + // using collapse could be undone by core, so we use display none + // noPrefsElem.setAttribute("collapsed", "true"); + noPrefsElem.style.display = "none"; + event.target.ownerGlobal.document.getElementById(id).addEventListener("command", this); + } + + // Event handler for the addon manager, to update the state of the options button. + handleEvent(e) { + switch (e.type) { + // 68 add-on options menu showing + case "popupshowing": { + this.add68PrefsEntry(e); + } + break; + + // 78/88 add-on options menu/button click + case "click": { + e.preventDefault(); + e.stopPropagation(); + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + let w = Services.wm.getMostRecentWindow("mail:3pane"); + w.openDialog(this.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + } + break; + + // 68 add-on options menu command + case "command": { + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + e.target.ownerGlobal.openDialog(this.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + } + break; + + // update, ViewChanged and manual call for add-on manager options overlay + default: { + let cards = this.getCards(e); + for (let card of cards) { + // Setup either the options entry in the menu or the button + if (card.addon.id == this.extension.id) { + let optionsMenu = + (getThunderbirdVersion().major > 78 && getThunderbirdVersion().major < 88) || + (getThunderbirdVersion().major == 78 && getThunderbirdVersion().minor < 10) || + (getThunderbirdVersion().major == 78 && getThunderbirdVersion().minor == 10 && getThunderbirdVersion().revision < 2); + if (optionsMenu) { + // Options menu in 78.0-78.10 and 79-87 + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (card.addon.isActive && !addonOptionsLegacyEntry) { + let addonOptionsEntry = card.querySelector("addon-options panel-list panel-item[action='preferences']"); + addonOptionsLegacyEntry = card.ownerDocument.createElement("panel-item"); + addonOptionsLegacyEntry.setAttribute("data-l10n-id", "preferences-addon-button"); + addonOptionsLegacyEntry.classList.add("extension-options-legacy"); + addonOptionsEntry.parentNode.insertBefore( + addonOptionsLegacyEntry, + addonOptionsEntry + ); + card.querySelector(".extension-options-legacy").addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsLegacyEntry) { + addonOptionsLegacyEntry.remove(); + } + } else { + // Add-on button in 88 + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (card.addon.isActive && !addonOptionsButton) { + addonOptionsButton = card.ownerDocument.createElement("button"); + addonOptionsButton.classList.add("extension-options-button2"); + addonOptionsButton.style["min-width"] = "auto"; + addonOptionsButton.style["min-height"] = "auto"; + addonOptionsButton.style["width"] = "24px"; + addonOptionsButton.style["height"] = "24px"; + addonOptionsButton.style["margin"] = "0"; + addonOptionsButton.style["margin-inline-start"] = "8px"; + addonOptionsButton.style["-moz-context-properties"] = "fill"; + addonOptionsButton.style["fill"] = "currentColor"; + addonOptionsButton.style["background-image"] = "url('chrome://messenger/skin/icons/developer.svg')"; + addonOptionsButton.style["background-repeat"] = "no-repeat"; + addonOptionsButton.style["background-position"] = "center center"; + addonOptionsButton.style["padding"] = "1px"; + addonOptionsButton.style["display"] = "flex"; + addonOptionsButton.style["justify-content"] = "flex-end"; + card.optionsButton.parentNode.insertBefore( + addonOptionsButton, + card.optionsButton + ); + card.querySelector(".extension-options-button2").addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsButton) { + addonOptionsButton.remove(); + } + } + } + } + } + } + } + + // Some tab/add-on-manager related functions + getTabMail(window) { + return window.document.getElementById("tabmail"); + } + + // returns the outer browser, not the nested browser of the add-on manager + // events must be attached to the outer browser + getAddonManagerFromTab(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let win = tab.browser.contentWindow; + if (win && win.location.href == "about:addons") { + return win; + } + } + } + + getAddonManagerFromWindow(window) { + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + + async getAddonManagerFromWindowWaitForLoad(window) { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + if (tab.browser && tab.mode.name == "contentTab") { + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + } + + setupAddonManager(managerWindow, forceLoad = false) { + if (!managerWindow) { + return; + } + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + return; + } + managerWindow.document.addEventListener("ViewChanged", this); + managerWindow.document.addEventListener("update", this); + managerWindow.document.addEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID] = {}; + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = true; + if (forceLoad) { + this.handleEvent(managerWindow); + } + } + + getAPI(context) { + this.uniqueRandomID = "AddOnNS" + context.extension.instanceId; + this.menu_addonPrefs_id = "addonPrefs"; + + + this.pathToBootstrapScript = null; + this.pathToOptionsPage = null; + this.chromeHandle = null; + this.chromeData = null; + this.resourceData = null; + this.bootstrappedObj = {}; + + // make the extension object and the messenger object available inside + // the bootstrapped scope + this.bootstrappedObj.extension = context.extension; + this.bootstrappedObj.messenger = getMessenger(this.context); + + this.BOOTSTRAP_REASONS = { + APP_STARTUP: 1, + APP_SHUTDOWN: 2, + ADDON_ENABLE: 3, + ADDON_DISABLE: 4, + ADDON_INSTALL: 5, + ADDON_UNINSTALL: 6, // not supported + ADDON_UPGRADE: 7, + ADDON_DOWNGRADE: 8, + }; + + const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(Ci.amIAddonManagerStartup); + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + + let self = this; + + // TabMonitor to detect opening of tabs, to setup the options button in the add-on manager. + this.tabMonitor = { + onTabTitleChanged(tab) { }, + onTabClosing(tab) { }, + onTabPersist(tab) { }, + onTabRestored(tab) { }, + onTabSwitched(aNewTab, aOldTab) { }, + async onTabOpened(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + self.setupAddonManager(self.getAddonManagerFromTab(tab)); + } + }, + }; + + return { + BootstrapLoader: { + + registerOptionsPage(optionsUrl) { + self.pathToOptionsPage = optionsUrl.startsWith("chrome://") + ? optionsUrl + : context.extension.rootURI.resolve(optionsUrl); + }, + + openOptionsDialog(windowId) { + let window = context.extension.windowManager.get(windowId, context).window + let BL = {} + BL.extension = self.extension; + BL.messenger = getMessenger(self.context); + window.openDialog(self.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + }, + + registerChromeUrl(data) { + let chromeData = []; + let resourceData = []; + for (let entry of data) { + if (entry[0] == "resource") resourceData.push(entry); + else chromeData.push(entry) + } + + if (chromeData.length > 0) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + context.extension.rootURI + ); + self.chromeHandle = aomStartup.registerChrome(manifestURI, chromeData); + } + + for (let res of resourceData) { + // [ "resource", "shortname" , "path" ] + let uri = Services.io.newURI( + res[2], + null, + context.extension.rootURI + ); + resProto.setSubstitutionWithFlags( + res[1], + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + self.chromeData = chromeData; + self.resourceData = resourceData; + }, + + registerBootstrapScript: async function (aPath) { + self.pathToBootstrapScript = aPath.startsWith("chrome://") + ? aPath + : context.extension.rootURI.resolve(aPath); + + // Get the addon object belonging to this extension. + let addon = await AddonManager.getAddonByID(context.extension.id); + //make the addon globally available in the bootstrapped scope + self.bootstrappedObj.addon = addon; + + // add BOOTSTRAP_REASONS to scope + for (let reason of Object.keys(self.BOOTSTRAP_REASONS)) { + self.bootstrappedObj[reason] = self.BOOTSTRAP_REASONS[reason]; + } + + // Load registered bootstrap scripts and execute its startup() function. + try { + if (self.pathToBootstrapScript) Services.scriptloader.loadSubScript(self.pathToBootstrapScript, self.bootstrappedObj, "UTF-8"); + if (self.bootstrappedObj.startup) self.bootstrappedObj.startup.call(self.bootstrappedObj, self.extension.addonData, self.BOOTSTRAP_REASONS[self.extension.startupReason]); + } catch (e) { + Components.utils.reportError(e) + } + + // Register window listener for main TB window + if (self.pathToOptionsPage) { + ExtensionSupport.registerWindowListener("injectListener_" + self.uniqueRandomID, { + chromeURLs: [ + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messenger.xhtml", + ], + async onLoadWindow(window) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(self.menu_addonPrefs_id); + element_addonPrefs.addEventListener("popupshowing", self); + } else { + // Add a tabmonitor, to be able to setup the options button/menu in the add-on manager. + self.getTabMail(window).registerTabMonitor(self.tabMonitor); + window[self.uniqueRandomID] = {}; + window[self.uniqueRandomID].hasTabMonitor = true; + // Setup the options button/menu in the add-on manager, if it is already open. + let managerWindow = await self.getAddonManagerFromWindowWaitForLoad(window); + self.setupAddonManager(managerWindow, true); + } + }, + + onUnloadWindow(window) { + } + }); + } + } + } + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; // the application gets unloaded anyway + } + + //remove our entry in the add-on options menu + if (this.pathToOptionsPage) { + for (let window of Services.wm.getEnumerator("mail:3pane")) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(this.menu_addonPrefs_id); + element_addonPrefs.removeEventListener("popupshowing", this); + // Remove our entry. + let entry = window.document.getElementById(this.menu_addonPrefs_id + "_" + this.uniqueRandomID); + if (entry) entry.remove(); + // Do we have to unhide the noPrefsElement? + if (element_addonPrefs.children.length == 1) { + let noPrefsElem = element_addonPrefs.querySelector('[disabled="true"]'); + noPrefsElem.style.display = "inline"; + } + } else { + // Remove event listener for addon manager view changes + let managerWindow = this.getAddonManagerFromWindow(window); + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + managerWindow.document.removeEventListener("ViewChanged", this); + managerWindow.document.removeEventListener("update", this); + managerWindow.document.removeEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = false; + + let cards = this.getCards(managerWindow); + if (getThunderbirdVersion().major < 88) { + // Remove options menu in 78-87 + for (let card of cards) { + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (addonOptionsLegacyEntry) addonOptionsLegacyEntry.remove(); + } + } else { + // Remove options button in 88 + for (let card of cards) { + if (card.addon.id == this.extension.id) { + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (addonOptionsButton) addonOptionsButton.remove(); + break; + } + } + } + } + + // Remove tabmonitor + if (window[this.uniqueRandomID].hasTabMonitor) { + this.getTabMail(window).unregisterTabMonitor(this.tabMonitor); + window[this.uniqueRandomID].hasTabMonitor = false; + } + + } + } + // Stop listening for new windows. + ExtensionSupport.unregisterWindowListener("injectListener_" + this.uniqueRandomID); + } + + // Execute registered shutdown() + try { + if (this.bootstrappedObj.shutdown) { + this.bootstrappedObj.shutdown( + this.extension.addonData, + isAppShutdown + ? this.BOOTSTRAP_REASONS.APP_SHUTDOWN + : this.BOOTSTRAP_REASONS.ADDON_DISABLE); + } + } catch (e) { + Components.utils.reportError(e) + } + + if (this.resourceData) { + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + for (let res of this.resourceData) { + // [ "resource", "shortname" , "path" ] + resProto.setSubstitution( + res[1], + null, + ); + } + } + + if (this.chromeHandle) { + this.chromeHandle.destruct(); + this.chromeHandle = null; + } + // Flush all caches + Services.obs.notifyObservers(null, "startupcache-invalidate"); + console.log("BootstrapLoader for " + this.extension.id + " unloaded!"); + } +}; + +// Removed all extra code for backward compatibility for better maintainability. +var BootstrapLoader_115 = class extends ExtensionCommon.ExtensionAPI { + getCards(e) { + // This gets triggered by real events but also manually by providing the outer window. + // The event is attached to the outer browser, get the inner one. + let doc = e.document || e.target; + return doc.querySelectorAll("addon-card"); + } + + // Event handler for the addon manager, to update the state of the options button. + handleEvent(e) { + switch (e.type) { + case "click": { + e.preventDefault(); + e.stopPropagation(); + let BL = {} + BL.extension = this.extension; + BL.messenger = getMessenger(this.context); + let w = Services.wm.getMostRecentWindow("mail:3pane"); + w.openDialog( + this.pathToOptionsPage, + "AddonOptions", + "chrome,resizable,centerscreen", + BL + ); + } + break; + + + // update, ViewChanged and manual call for add-on manager options overlay + default: { + let cards = this.getCards(e); + for (let card of cards) { + // Setup either the options entry in the menu or the button + if (card.addon.id == this.extension.id) { + // Add-on button + let addonOptionsButton = card.querySelector( + ".windowlistener-options-button" + ); + if (card.addon.isActive && !addonOptionsButton) { + let origAddonOptionsButton = card.querySelector(".extension-options-button") + origAddonOptionsButton.setAttribute("hidden", "true"); + + addonOptionsButton = card.ownerDocument.createElement("button"); + addonOptionsButton.classList.add("windowlistener-options-button"); + addonOptionsButton.classList.add("extension-options-button"); + card.optionsButton.parentNode.insertBefore( + addonOptionsButton, + card.optionsButton + ); + card + .querySelector(".windowlistener-options-button") + .addEventListener("click", this); + } else if (!card.addon.isActive && addonOptionsButton) { + addonOptionsButton.remove(); + } + } + } + } + } + } + + // Some tab/add-on-manager related functions + getTabMail(window) { + return window.document.getElementById("tabmail"); + } + + // returns the outer browser, not the nested browser of the add-on manager + // events must be attached to the outer browser + getAddonManagerFromTab(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let win = tab.browser.contentWindow; + if (win && win.location.href == "about:addons") { + return win; + } + } + } + + getAddonManagerFromWindow(window) { + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + + async getAddonManagerFromWindowWaitForLoad(window) { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + + let tabMail = this.getTabMail(window); + for (let tab of tabMail.tabInfo) { + if (tab.browser && tab.mode.name == "contentTab") { + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + let managerWindow = this.getAddonManagerFromTab(tab); + if (managerWindow) { + return managerWindow; + } + } + } + } + + setupAddonManager(managerWindow, forceLoad = false) { + if (!managerWindow) { + return; + } + if (!this.pathToOptionsPage) { + return; + } + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + return; + } + + managerWindow.document.addEventListener("ViewChanged", this); + managerWindow.document.addEventListener("update", this); + managerWindow.document.addEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID] = {}; + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = true; + if (forceLoad) { + this.handleEvent(managerWindow); + } + } + + getAPI(context) { + this.uniqueRandomID = "AddOnNS" + context.extension.instanceId; + this.menu_addonPrefs_id = "addonPrefs"; + + + this.pathToBootstrapScript = null; + this.pathToOptionsPage = null; + this.chromeHandle = null; + this.chromeData = null; + this.resourceData = null; + this.bootstrappedObj = {}; + + // make the extension object and the messenger object available inside + // the bootstrapped scope + this.bootstrappedObj.extension = context.extension; + this.bootstrappedObj.messenger = getMessenger(this.context); + + this.BOOTSTRAP_REASONS = { + APP_STARTUP: 1, + APP_SHUTDOWN: 2, + ADDON_ENABLE: 3, + ADDON_DISABLE: 4, + ADDON_INSTALL: 5, + ADDON_UNINSTALL: 6, // not supported + ADDON_UPGRADE: 7, + ADDON_DOWNGRADE: 8, + }; + + const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(Ci.amIAddonManagerStartup); + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + + let self = this; + + // TabMonitor to detect opening of tabs, to setup the options button in the add-on manager. + this.tabMonitor = { + onTabTitleChanged(tab) { }, + onTabClosing(tab) { }, + onTabPersist(tab) { }, + onTabRestored(tab) { }, + onTabSwitched(aNewTab, aOldTab) { }, + async onTabOpened(tab) { + if (tab.browser && tab.mode.name == "contentTab") { + let { setTimeout } = Services.wm.getMostRecentWindow("mail:3pane"); + // Instead of registering a load observer, wait until its loaded. Not nice, + // but gets aroud a lot of edge cases. + while (!tab.pageLoaded) { + await new Promise(r => setTimeout(r, 150)); + } + self.setupAddonManager(self.getAddonManagerFromTab(tab)); + } + }, + }; + + return { + BootstrapLoader: { + + registerOptionsPage(optionsUrl) { + self.pathToOptionsPage = optionsUrl.startsWith("chrome://") + ? optionsUrl + : context.extension.rootURI.resolve(optionsUrl); + }, + + openOptionsDialog(windowId) { + let window = context.extension.windowManager.get(windowId, context).window + let BL = {} + BL.extension = self.extension; + BL.messenger = getMessenger(self.context); + window.openDialog(self.pathToOptionsPage, "AddonOptions", "chrome,resizable,centerscreen", BL); + }, + + registerChromeUrl(data) { + let chromeData = []; + let resourceData = []; + for (let entry of data) { + if (entry[0] == "resource") resourceData.push(entry); + else chromeData.push(entry) + } + + if (chromeData.length > 0) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + context.extension.rootURI + ); + self.chromeHandle = aomStartup.registerChrome(manifestURI, chromeData); + } + + for (let res of resourceData) { + // [ "resource", "shortname" , "path" ] + let uri = Services.io.newURI( + res[2], + null, + context.extension.rootURI + ); + resProto.setSubstitutionWithFlags( + res[1], + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + self.chromeData = chromeData; + self.resourceData = resourceData; + }, + + registerBootstrapScript: async function (aPath) { + self.pathToBootstrapScript = aPath.startsWith("chrome://") + ? aPath + : context.extension.rootURI.resolve(aPath); + + // Get the addon object belonging to this extension. + let addon = await AddonManager.getAddonByID(context.extension.id); + //make the addon globally available in the bootstrapped scope + self.bootstrappedObj.addon = addon; + + // add BOOTSTRAP_REASONS to scope + for (let reason of Object.keys(self.BOOTSTRAP_REASONS)) { + self.bootstrappedObj[reason] = self.BOOTSTRAP_REASONS[reason]; + } + + // Load registered bootstrap scripts and execute its startup() function. + try { + if (self.pathToBootstrapScript) Services.scriptloader.loadSubScript(self.pathToBootstrapScript, self.bootstrappedObj, "UTF-8"); + if (self.bootstrappedObj.startup) self.bootstrappedObj.startup.call(self.bootstrappedObj, self.extension.addonData, self.BOOTSTRAP_REASONS[self.extension.startupReason]); + } catch (e) { + Components.utils.reportError(e) + } + + // Register window listener for main TB window + if (self.pathToOptionsPage) { + ExtensionSupport.registerWindowListener("injectListener_" + self.uniqueRandomID, { + chromeURLs: [ + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messenger.xhtml", + ], + async onLoadWindow(window) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(self.menu_addonPrefs_id); + element_addonPrefs.addEventListener("popupshowing", self); + } else { + // Add a tabmonitor, to be able to setup the options button/menu in the add-on manager. + self.getTabMail(window).registerTabMonitor(self.tabMonitor); + window[self.uniqueRandomID] = {}; + window[self.uniqueRandomID].hasTabMonitor = true; + // Setup the options button/menu in the add-on manager, if it is already open. + let managerWindow = await self.getAddonManagerFromWindowWaitForLoad(window); + self.setupAddonManager(managerWindow, true); + } + }, + + onUnloadWindow(window) { + } + }); + } + } + } + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; // the application gets unloaded anyway + } + + //remove our entry in the add-on options menu + if (this.pathToOptionsPage) { + for (let window of Services.wm.getEnumerator("mail:3pane")) { + if (getThunderbirdVersion().major < 78) { + let element_addonPrefs = window.document.getElementById(this.menu_addonPrefs_id); + element_addonPrefs.removeEventListener("popupshowing", this); + // Remove our entry. + let entry = window.document.getElementById(this.menu_addonPrefs_id + "_" + this.uniqueRandomID); + if (entry) entry.remove(); + // Do we have to unhide the noPrefsElement? + if (element_addonPrefs.children.length == 1) { + let noPrefsElem = element_addonPrefs.querySelector('[disabled="true"]'); + noPrefsElem.style.display = "inline"; + } + } else { + // Remove event listener for addon manager view changes + let managerWindow = this.getAddonManagerFromWindow(window); + if ( + managerWindow && + managerWindow[this.uniqueRandomID] && + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners + ) { + managerWindow.document.removeEventListener("ViewChanged", this); + managerWindow.document.removeEventListener("update", this); + managerWindow.document.removeEventListener("view-loaded", this); + managerWindow[this.uniqueRandomID].hasAddonManagerEventListeners = false; + + let cards = this.getCards(managerWindow); + if (getThunderbirdVersion().major < 88) { + // Remove options menu in 78-87 + for (let card of cards) { + let addonOptionsLegacyEntry = card.querySelector(".extension-options-legacy"); + if (addonOptionsLegacyEntry) addonOptionsLegacyEntry.remove(); + } + } else { + // Remove options button in 88 + for (let card of cards) { + if (card.addon.id == this.extension.id) { + let addonOptionsButton = card.querySelector(".extension-options-button2"); + if (addonOptionsButton) addonOptionsButton.remove(); + break; + } + } + } + } + + // Remove tabmonitor + if (window[this.uniqueRandomID].hasTabMonitor) { + this.getTabMail(window).unregisterTabMonitor(this.tabMonitor); + window[this.uniqueRandomID].hasTabMonitor = false; + } + + } + } + // Stop listening for new windows. + ExtensionSupport.unregisterWindowListener("injectListener_" + this.uniqueRandomID); + } + + // Execute registered shutdown() + try { + if (this.bootstrappedObj.shutdown) { + this.bootstrappedObj.shutdown( + this.extension.addonData, + isAppShutdown + ? this.BOOTSTRAP_REASONS.APP_SHUTDOWN + : this.BOOTSTRAP_REASONS.ADDON_DISABLE); + } + } catch (e) { + Components.utils.reportError(e) + } + + if (this.resourceData) { + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); + for (let res of this.resourceData) { + // [ "resource", "shortname" , "path" ] + resProto.setSubstitution( + res[1], + null, + ); + } + } + + if (this.chromeHandle) { + this.chromeHandle.destruct(); + this.chromeHandle = null; + } + // Flush all caches + Services.obs.notifyObservers(null, "startupcache-invalidate"); + console.log("BootstrapLoader for " + this.extension.id + " unloaded!"); + } +}; + +var BootstrapLoader = getThunderbirdVersion().major < 111 + ? BootstrapLoader_102 + : BootstrapLoader_115; diff --git a/content/api/BootstrapLoader/schema.json b/content/api/BootstrapLoader/schema.json new file mode 100644 index 0000000..fe48fb6 --- /dev/null +++ b/content/api/BootstrapLoader/schema.json @@ -0,0 +1,61 @@ +[ + { + "namespace": "BootstrapLoader", + "functions": [ + { + "name": "registerOptionsPage", + "type": "function", + "parameters": [ + { + "name": "aPath", + "type": "string", + "description": "Path to the options page, which should be made accessible in the (legacy) Add-On Options menu." + } + ] + }, + { + "name": "openOptionsDialog", + "type": "function", + "parameters": [ + { + "name": "windowId", + "type": "integer", + "description": "Id of the window the dialog should be opened from." + } + ] + }, + { + "name": "registerChromeUrl", + "type": "function", + "description": "Register folders which should be available as chrome:// urls (as defined in the legacy chrome.manifest)", + "async": true, + "parameters": [ + { + "name": "chromeData", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of ChromeData Arrays." + } + ] + }, + { + "name": "registerBootstrapScript", + "type": "function", + "description": "Register a bootstrap.js style script", + "async": true, + "parameters": [ + { + "name": "aPath", + "type": "string", + "description": "Either the chrome:// path to the script or its relative location from the root of the extension," + } + ] + } + ] + } +]
\ No newline at end of file diff --git a/content/bootstrap.js b/content/bootstrap.js new file mode 100644 index 0000000..5fb33c8 --- /dev/null +++ b/content/bootstrap.js @@ -0,0 +1,55 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// no need to create namespace, we are in a sandbox + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +let onInitDoneObserver = { + observe: async function (aSubject, aTopic, aData) { + let valid = false; + try { + var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + valid = TbSync.enabled; + } catch (e) { + // If this fails, TbSync is not loaded yet and we will get the notification later again. + } + + //load this provider add-on into TbSync + if (valid) { + await TbSync.providers.loadProvider(extension, "eas", "chrome://eas4tbsync/content/provider.js"); + } + } +} + +function startup(data, reason) { + // Possible reasons: APP_STARTUP, ADDON_ENABLE, ADDON_INSTALL, ADDON_UPGRADE, or ADDON_DOWNGRADE. + + Services.obs.addObserver(onInitDoneObserver, "tbsync.observer.initialized", false); + + // Did we miss the observer? + onInitDoneObserver.observe(); +} + +function shutdown(data, reason) { + // Possible reasons: APP_STARTUP, ADDON_ENABLE, ADDON_INSTALL, ADDON_UPGRADE, or ADDON_DOWNGRADE. + + // When the application is shutting down we normally don't have to clean up. + if (reason == APP_SHUTDOWN) { + return; + } + + Services.obs.removeObserver(onInitDoneObserver, "tbsync.observer.initialized"); + //unload this provider add-on from TbSync + try { + var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + TbSync.providers.unloadProvider("eas"); + } catch (e) { + //if this fails, TbSync has been unloaded already and has unloaded this addon as well + } +} diff --git a/content/includes/calendarsync.js b/content/includes/calendarsync.js new file mode 100644 index 0000000..7592060 --- /dev/null +++ b/content/includes/calendarsync.js @@ -0,0 +1,421 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +const cal = TbSync.lightning.cal; +const ICAL = TbSync.lightning.ICAL; + +var Calendar = { + + // --------------------------------------------------------------------------- // + // Read WBXML and set Thunderbird item + // --------------------------------------------------------------------------- // + setThunderbirdItemFromWbxml: function (tbItem, data, id, syncdata, mode = "standard") { + + let item = tbItem instanceof TbSync.lightning.TbItem ? tbItem.nativeItem : tbItem; + + let asversion = syncdata.accountData.getAccountProperty("asversion"); + item.id = id; + eas.sync.setItemSubject(item, syncdata, data); + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Processing " + mode + " calendar item", item.title + " (" + id + ")"); + + eas.sync.setItemLocation(item, syncdata, data); + eas.sync.setItemCategories(item, syncdata, data); + eas.sync.setItemBody(item, syncdata, data); + + //timezone + let stdOffset = eas.defaultTimezoneInfo.std.offset; + let dstOffset = eas.defaultTimezoneInfo.dst.offset; + let easTZ = new eas.tools.TimeZoneDataStructure(); + if (data.TimeZone) { + if (data.TimeZone == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==") { + TbSync.dump("Recieve TZ", "No timezone data received, using local default timezone."); + } else { + //load timezone struct into EAS TimeZone object + easTZ.easTimeZone64 = data.TimeZone; + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Recieve TZ", item.title + easTZ.toString()); + stdOffset = easTZ.utcOffset; + dstOffset = easTZ.daylightBias + easTZ.utcOffset; + } + } + let timezone = eas.tools.guessTimezoneByStdDstOffset(stdOffset, dstOffset, easTZ.standardName); + + if (data.StartTime) { + let utc = cal.createDateTime(data.StartTime); //format "19800101T000000Z" - UTC + item.startDate = utc.getInTimezone(timezone); + if (data.AllDayEvent && data.AllDayEvent == "1") { + item.startDate.timezone = (cal.dtz && cal.dtz.floating) ? cal.dtz.floating : cal.floating(); + item.startDate.isDate = true; + } + } + + if (data.EndTime) { + let utc = cal.createDateTime(data.EndTime); + item.endDate = utc.getInTimezone(timezone); + if (data.AllDayEvent && data.AllDayEvent == "1") { + item.endDate.timezone = (cal.dtz && cal.dtz.floating) ? cal.dtz.floating : cal.floating(); + item.endDate.isDate = true; + } + } + + //stamp time cannot be set and it is not needed, an updated version is only send to the server, if there was a change, so stamp will be updated + + + //EAS Reminder + item.clearAlarms(); + if (data.Reminder && data.StartTime) { + let alarm = new CalAlarm(); + alarm.related = Components.interfaces.calIAlarm.ALARM_RELATED_START; + alarm.offset = cal.createDuration(); + alarm.offset.inSeconds = (0-parseInt(data.Reminder)*60); + alarm.action ="DISPLAY"; + item.addAlarm(alarm); + + let alarmData = cal.alarms.calculateAlarmDate(item, alarm); + let startDate = cal.createDateTime(data.StartTime); + let nowDate = eas.tools.getNowUTC(); + if (startDate.compare(nowDate) < 0) { + // Mark alarm as ACK if in the past. + item.alarmLastAck = nowDate; + } + } + + eas.sync.mapEasPropertyToThunderbird ("BusyStatus", "TRANSP", data, item); + eas.sync.mapEasPropertyToThunderbird ("Sensitivity", "CLASS", data, item); + + if (data.ResponseType) { + //store original EAS value + item.setProperty("X-EAS-ResponseType", eas.xmltools.checkString(data.ResponseType, "0")); //some server send empty ResponseType ??? + } + + //Attendees - remove all Attendees and re-add the ones from XML + item.removeAllAttendees(); + if (data.Attendees && data.Attendees.Attendee) { + let att = []; + if (Array.isArray(data.Attendees.Attendee)) att = data.Attendees.Attendee; + else att.push(data.Attendees.Attendee); + for (let i = 0; i < att.length; i++) { + if (att[i].Email && eas.tools.isString(att[i].Email) && att[i].Name) { //req. + + let attendee = new CalAttendee(); + + //is this attendee the local EAS user? + let isSelf = (att[i].Email == syncdata.accountData.getAccountProperty("user")); + + attendee["id"] = cal.email.prependMailTo(att[i].Email); + attendee["commonName"] = att[i].Name; + //default is "FALSE", only if THIS attendee isSelf, use ResponseRequested (we cannot respond for other attendee) - ResponseType is not send back to the server, it is just a local information + attendee["rsvp"] = (isSelf && data.ResponseRequested) ? "TRUE" : "FALSE"; + + //not supported in 2.5 + switch (att[i].AttendeeType) { + case "1": //required + attendee["role"] = "REQ-PARTICIPANT"; + attendee["userType"] = "INDIVIDUAL"; + break; + case "2": //optional + attendee["role"] = "OPT-PARTICIPANT"; + attendee["userType"] = "INDIVIDUAL"; + break; + default : //resource or unknown + attendee["role"] = "NON-PARTICIPANT"; + attendee["userType"] = "RESOURCE"; + break; + } + + //not supported in 2.5 - if attendeeStatus is missing, check if this isSelf and there is a ResponseType + if (att[i].AttendeeStatus) + attendee["participationStatus"] = eas.sync.MAP_EAS2TB.ATTENDEESTATUS[att[i].AttendeeStatus]; + else if (isSelf && data.ResponseType) + attendee["participationStatus"] = eas.sync.MAP_EAS2TB.ATTENDEESTATUS[data.ResponseType]; + else + attendee["participationStatus"] = "NEEDS-ACTION"; + + // status : [NEEDS-ACTION, ACCEPTED, DECLINED, TENTATIVE, DELEGATED, COMPLETED, IN-PROCESS] + // rolemap : [REQ-PARTICIPANT, OPT-PARTICIPANT, NON-PARTICIPANT, CHAIR] + // typemap : [INDIVIDUAL, GROUP, RESOURCE, ROOM] + + // Add attendee to event + item.addAttendee(attendee); + } else { + TbSync.eventlog.add("info", syncdata, "Attendee without required name and/or email found. Skipped."); + } + } + } + + if (data.OrganizerName && data.OrganizerEmail && eas.tools.isString(data.OrganizerEmail)) { + //Organizer + let organizer = new CalAttendee(); + organizer.id = cal.email.prependMailTo(data.OrganizerEmail); + organizer.commonName = data.OrganizerName; + organizer.rsvp = "FALSE"; + organizer.role = "CHAIR"; + organizer.userType = null; + organizer.participationStatus = "ACCEPTED"; + organizer.isOrganizer = true; + item.organizer = organizer; + } + + eas.sync.setItemRecurrence(item, syncdata, data, timezone); + + // BusyStatus is always representing the status of the current user in terms of availability. + // It has nothing to do with the status of a meeting. The user could be just the organizer, but does not need to attend, so he would be free. + // The correct map is between BusyStatus and TRANSP (show time as avail, busy, unset) + // A new event always sets TRANSP to busy, so unset is indeed a good way to store Tentiative + // However: + // - EAS Meetingstatus only knows ACTIVE or CANCELLED, but not CONFIRMED or TENTATIVE + // - TB STATUS has UNSET, CONFIRMED, TENTATIVE, CANCELLED + // -> Special case: User sets BusyStatus to TENTIATIVE -> TRANSP is unset and also set STATUS to TENTATIVE + // The TB STATUS is the correct map for EAS Meetingstatus and should be unset, if it is not a meeting EXCEPT if set to TENTATIVE + let tbStatus = (data.BusyStatus && data.BusyStatus == "1" ? "TENTATIVE" : null); + + if (data.MeetingStatus) { + //store original EAS value + item.setProperty("X-EAS-MeetingStatus", data.MeetingStatus); + //bitwise representation for Meeting, Received, Cancelled: + let M = data.MeetingStatus & 0x1; + let R = data.MeetingStatus & 0x2; + let C = data.MeetingStatus & 0x4; + + // We can map M+C to TB STATUS (TENTATIVE, CONFIRMED, CANCELLED, unset). + if (M) { + if (C) tbStatus = "CANCELLED"; + else if (!tbStatus) tbStatus = "CONFIRMED"; // do not override "TENTIATIVE" + } + + //we can also use the R information, to update our fallbackOrganizerName + if (!R && data.OrganizerName) syncdata.target.calendar.setProperty("fallbackOrganizerName", data.OrganizerName); + } + + if (tbStatus) item.setProperty("STATUS", tbStatus) + else item.deleteProperty("STATUS"); + + //TODO: attachements (needs EAS 16.0!) + }, + + + + + + + + + + // --------------------------------------------------------------------------- // + //read TB event and return its data as WBXML + // --------------------------------------------------------------------------- // + getWbxmlFromThunderbirdItem: async function (tbItem, syncdata, isException = false) { + let item = tbItem instanceof TbSync.lightning.TbItem ? tbItem.nativeItem : tbItem; + + let asversion = syncdata.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncdata.type); //init wbxml with "" and not with precodes, and set initial codepage + let nowDate = new Date(); + + /* + * We do not use ghosting, that means, if we do not include a value in CHANGE, it is removed from the server. + * However, this does not seem to work on all fields. Furthermore, we need to include any (empty) container to blank its childs. + */ + + //Order of tags taken from https://msdn.microsoft.com/en-us/library/dn338917(v=exchg.80).aspx + + //timezone + if (!isException) { + let easTZ = new eas.tools.TimeZoneDataStructure(); + + //if there is no end and no start (or both are floating) use default timezone info + let tzInfo = null; + if (item.startDate && item.startDate.timezone.tzid != "floating") tzInfo = eas.tools.getTimezoneInfo(item.startDate.timezone); + else if (item.endDate && item.endDate.timezone.tzid != "floating") tzInfo = eas.tools.getTimezoneInfo(item.endDate.timezone); + if (!tzInfo) tzInfo = eas.defaultTimezoneInfo; + + easTZ.utcOffset = tzInfo.std.offset; + easTZ.standardBias = 0; + easTZ.daylightBias = tzInfo.dst.offset - tzInfo.std.offset; + + easTZ.standardName = eas.ianaToWindowsTimezoneMap.hasOwnProperty(tzInfo.std.displayname) ? eas.ianaToWindowsTimezoneMap[tzInfo.std.displayname] : tzInfo.std.displayname; + easTZ.daylightName = eas.ianaToWindowsTimezoneMap.hasOwnProperty(tzInfo.dst.displayname) ? eas.ianaToWindowsTimezoneMap[tzInfo.dst.displayname] : tzInfo.dst.displayname; + + if (tzInfo.std.switchdate && tzInfo.dst.switchdate) { + easTZ.standardDate.wMonth = tzInfo.std.switchdate.month; + easTZ.standardDate.wDay = tzInfo.std.switchdate.weekOfMonth; + easTZ.standardDate.wDayOfWeek = tzInfo.std.switchdate.dayOfWeek; + easTZ.standardDate.wHour = tzInfo.std.switchdate.hour; + easTZ.standardDate.wMinute = tzInfo.std.switchdate.minute; + easTZ.standardDate.wSecond = tzInfo.std.switchdate.second; + + easTZ.daylightDate.wMonth = tzInfo.dst.switchdate.month; + easTZ.daylightDate.wDay = tzInfo.dst.switchdate.weekOfMonth; + easTZ.daylightDate.wDayOfWeek = tzInfo.dst.switchdate.dayOfWeek; + easTZ.daylightDate.wHour = tzInfo.dst.switchdate.hour; + easTZ.daylightDate.wMinute = tzInfo.dst.switchdate.minute; + easTZ.daylightDate.wSecond = tzInfo.dst.switchdate.second; + } + + wbxml.atag("TimeZone", easTZ.easTimeZone64); + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Send TZ", item.title + easTZ.toString()); + } + + //AllDayEvent (for simplicity, we always send a value) + wbxml.atag("AllDayEvent", (item.startDate && item.startDate.isDate && item.endDate && item.endDate.isDate) ? "1" : "0"); + + //Body + wbxml.append(eas.sync.getItemBody(item, syncdata)); + + //BusyStatus (Free, Tentative, Busy) is taken from TRANSP (busy, free, unset=tentative) + //However if STATUS is set to TENTATIVE, overide TRANSP and set BusyStatus to TENTATIVE + if (item.hasProperty("STATUS") && item.getProperty("STATUS") == "TENTATIVE") { + wbxml.atag("BusyStatus","1"); + } else { + wbxml.atag("BusyStatus", eas.sync.mapThunderbirdPropertyToEas("TRANSP", "BusyStatus", item)); + } + + //Organizer + if (!isException) { + if (item.organizer && item.organizer.commonName) wbxml.atag("OrganizerName", item.organizer.commonName); + if (item.organizer && item.organizer.id) wbxml.atag("OrganizerEmail", cal.email.removeMailTo(item.organizer.id)); + } + + //DtStamp in UTC + wbxml.atag("DtStamp", item.stampTime ? eas.tools.getIsoUtcString(item.stampTime) : eas.tools.dateToBasicISOString(nowDate)); + + //EndTime in UTC + wbxml.atag("EndTime", item.endDate ? eas.tools.getIsoUtcString(item.endDate) : eas.tools.dateToBasicISOString(nowDate)); + + //Location + wbxml.atag("Location", (item.hasProperty("location")) ? item.getProperty("location") : ""); + + //EAS Reminder (TB getAlarms) - at least with zpush blanking by omitting works, horde does not work + let alarms = item.getAlarms({}); + if (alarms.length>0) { + + let reminder = -1; + if (alarms[0].offset !== null) { + reminder = 0 - alarms[0].offset.inSeconds/60; + } else if (item.startDate) { + let timeDiff =item.startDate.getInTimezone(eas.utcTimezone).subtractDate(alarms[0].alarmDate.getInTimezone(eas.utcTimezone)); + reminder = timeDiff.inSeconds/60; + TbSync.eventlog.add("info", syncdata, "Converting absolute alarm to relative alarm (not supported).", item.icalString); + } + if (reminder >= 0) wbxml.atag("Reminder", reminder.toString()); + else TbSync.eventlog.add("info", syncdata, "Droping alarm after start date (not supported).", item.icalString); + + } + + //Sensitivity (CLASS) + wbxml.atag("Sensitivity", eas.sync.mapThunderbirdPropertyToEas("CLASS", "Sensitivity", item)); + + //Subject (obmitting these, should remove them from the server - that does not work reliably, so we send blanks) + wbxml.atag("Subject", (item.title) ? item.title : ""); + + //StartTime in UTC + wbxml.atag("StartTime", item.startDate ? eas.tools.getIsoUtcString(item.startDate) : eas.tools.dateToBasicISOString(nowDate)); + + //UID (limit to 300) + //each TB event has an ID, which is used as EAS serverId - however there is a second UID in the ApplicationData + //since we do not have two different IDs to use, we use the same ID + if (!isException) { //docs say it would be allowed in exception in 2.5, but it does not work, if present + wbxml.atag("UID", item.id); + } + //IMPORTANT in EAS v16 it is no longer allowed to send a UID + //Only allowed in exceptions in v2.5 + + + //EAS MeetingStatus + // 0 (000) The event is an appointment, which has no attendees. + // 1 (001) The event is a meeting and the user is the meeting organizer. + // 3 (011) This event is a meeting, and the user is not the meeting organizer; the meeting was received from someone else. + // 5 (101) The meeting has been canceled and the user was the meeting organizer. + // 7 (111) The meeting has been canceled. The user was not the meeting organizer; the meeting was received from someone else + + //there are 3 fields; Meeting, Owner, Cancelled + //M can be reconstructed from #of attendees (looking at the old value is not wise, since it could have been changed) + //C can be reconstucted from TB STATUS + //O can be reconstructed by looking at the original value, or (if not present) by comparing EAS ownerID with TB ownerID + + let attendees = item.getAttendees(); + //if (!(isException && asversion == "2.5")) { //MeetingStatus is not supported in exceptions in EAS 2.5 + if (!isException) { //Exchange 2010 does not seem to support MeetingStatus at all in exceptions + if (attendees.length == 0) wbxml.atag("MeetingStatus", "0"); + else { + //get owner information + let isReceived = false; + if (item.hasProperty("X-EAS-MEETINGSTATUS")) isReceived = item.getProperty("X-EAS-MEETINGSTATUS") & 0x2; + else isReceived = (item.organizer && item.organizer.id && cal.email.removeMailTo(item.organizer.id) != syncdata.accountData.getAccountProperty("user")); + + //either 1,3,5 or 7 + if (item.hasProperty("STATUS") && item.getProperty("STATUS") == "CANCELLED") { + //either 5 or 7 + wbxml.atag("MeetingStatus", (isReceived ? "7" : "5")); + } else { + //either 1 or 3 + wbxml.atag("MeetingStatus", (isReceived ? "3" : "1")); + } + } + } + + //Attendees + let TB_responseType = null; + if (!(isException && asversion == "2.5")) { //attendees are not supported in exceptions in EAS 2.5 + if (attendees.length > 0) { //We should use it instead of countAttendees.value + wbxml.otag("Attendees"); + for (let attendee of attendees) { + wbxml.otag("Attendee"); + wbxml.atag("Email", cal.email.removeMailTo(attendee.id)); + wbxml.atag("Name", (attendee.commonName ? attendee.commonName : cal.email.removeMailTo(attendee.id).split("@")[0])); + if (asversion != "2.5") { + //it's pointless to send AttendeeStatus, + // - if we are the owner of a meeting, TB does not have an option to actually set the attendee status (on behalf of an attendee) in the UI + // - if we are an attendee (of an invite) we cannot and should not set status of other attendees and or own status must be send through a MeetingResponse + // -> all changes of attendee status are send from the server to us, either via ResponseType or via AttendeeStatus + //wbxml.atag("AttendeeStatus", eas.sync.MAP_TB2EAS.ATTENDEESTATUS[attendee.participationStatus]); + + if (attendee.userType == "RESOURCE" || attendee.userType == "ROOM" || attendee.role == "NON-PARTICIPANT") wbxml.atag("AttendeeType","3"); + else if (attendee.role == "REQ-PARTICIPANT" || attendee.role == "CHAIR") wbxml.atag("AttendeeType","1"); + else wbxml.atag("AttendeeType","2"); //leftovers are optional + } + wbxml.ctag(); + } + wbxml.ctag(); + } else { + wbxml.atag("Attendees"); + } + } + + //Categories (see https://github.com/jobisoft/TbSync/pull/35#issuecomment-359286374) + if (!isException) { + wbxml.append(eas.sync.getItemCategories(item, syncdata)); + } + + //recurrent events (implemented by Chris Allan) + if (!isException) { + wbxml.append(await eas.sync.getItemRecurrence(item, syncdata)); + } + + + //--------------------------- + + //TP PRIORITY (9=LOW, 5=NORMAL, 1=HIGH) not mapable to EAS Event + //TODO: attachements (needs EAS 16.0!) + + //https://dxr.mozilla.org/comm-central/source/calendar/base/public/calIAlarm.idl + //TbSync.dump("ALARM ("+i+")", [, alarms[i].related, alarms[i].repeat, alarms[i].repeatOffset, alarms[i].repeatDate, alarms[i].action].join("|")); + + return wbxml.getBytes(); + } +} diff --git a/content/includes/contactsync.js b/content/includes/contactsync.js new file mode 100644 index 0000000..23d6535 --- /dev/null +++ b/content/includes/contactsync.js @@ -0,0 +1,542 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + + var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + newUID: "resource:///modules/AddrBookUtils.jsm", + AddrBookCard: "resource:///modules/AddrBookCard.jsm", + BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardPropertyEntry: "resource:///modules/VCardUtils.jsm", + VCardUtils: "resource:///modules/VCardUtils.jsm", +}); + +const eas = TbSync.providers.eas; + +var Contacts = { + + // Remove if migration code is removed. + arrayFromString: function (stringValue) { + let arrayValue = []; + if (stringValue.trim().length>0) arrayValue = stringValue.trim().split("\u001A").filter(String); + return arrayValue; + }, + + /* The following TB properties are not synced to the server: + - only one WebPage + - more than 3 emails + - more than one fax, pager, mobile, work, home + - position (in org) + */ + + vcard_array_fields : { + n : 5, + adr : 7, + org : 2 + }, + + map_EAS_properties_to_vCard : { + FileAs: {item: "fn", type: "text", params: {}}, /* DisplayName */ + + Birthday: {item: "bday", type: "date", params: {}}, + Anniversary: {item: "anniversary", type: "date", params: {}}, + + LastName: {item: "n", type: "text", params: {}, index: 0}, + FirstName: {item: "n", type: "text", params: {}, index: 1}, + MiddleName: {item: "n", type: "text", params: {}, index: 2}, + Title: {item: "n", type: "text", params: {}, index: 3}, + Suffix: {item: "n", type: "text", params: {}, index: 4}, + + Notes: {item: "note", type: "text", params: {}}, + + // What should we do with Email 4+? + // EAS does not have the concept of home/work for emails. Define matchAll + // to not use params for finding the correct entry. They will come back as + // "other". + Email1Address: {item: "email", type: "text", entry: 0, matchAll: true, params: {}}, + Email2Address: {item: "email", type: "text", entry: 1, matchAll: true, params: {}}, + Email3Address: {item: "email", type: "text", entry: 2, matchAll: true, params: {}}, + + // EAS does not have the concept of home/work for WebPage. Define matchAll + // to not use params for finding the correct entry. It will come back as + // "other". + WebPage: {item: "url", type: "text", matchAll: true, params: {}}, + + CompanyName: {item: "org", type: "text", params: {}, index: 1}, /* Company */ + Department: {item: "org", type: "text", params: {}, index: 0}, /* Department */ + JobTitle: { item: "title", type: "text", params: {} }, /* JobTitle */ + + MobilePhoneNumber: { item: "tel", type: "text", params: {type: "cell" }}, + PagerNumber: { item: "tel", type: "text", params: {type: "pager" }}, + HomeFaxNumber: { item: "tel", type: "text", params: {type: "fax" }}, + // If home phone is defined, use that, otherwise use unspecified phone + // Note: This must be exclusive (no other field may use home/unspecified) + // except if entry is specified. + HomePhoneNumber: { item: "tel", type: "text", params: {type: "home"}, fallbackParams: [{}]}, + BusinessPhoneNumber: { item: "tel", type: "text", params: {type: "work"}}, + Home2PhoneNumber: { item: "tel", type: "text", params: {type: "home"}, entry: 1 }, + Business2PhoneNumber: { item: "tel", type: "text", params: {type: "work"}, entry: 1 }, + + HomeAddressStreet: {item: "adr", type: "text", params: {type: "home"}, index: 2}, // needs special handling + HomeAddressCity: {item: "adr", type: "text", params: {type: "home"}, index: 3}, + HomeAddressState: {item: "adr", type: "text", params: {type: "home"}, index: 4}, + HomeAddressPostalCode: {item: "adr", type: "text", params: {type: "home"}, index: 5}, + HomeAddressCountry: {item: "adr", type: "text", params: {type: "home"}, index: 6}, + + BusinessAddressStreet: {item: "adr", type: "text", params: {type: "work"}, index: 2}, // needs special handling + BusinessAddressCity: {item: "adr", type: "text", params: {type: "work"}, index: 3}, + BusinessAddressState: {item: "adr", type: "text", params: {type: "work"}, index: 4}, + BusinessAddressPostalCode: {item: "adr", type: "text", params: {type: "work"}, index: 5}, + BusinessAddressCountry: {item: "adr", type: "text", params: {type: "work"}, index: 6}, + + OtherAddressStreet: {item: "adr", type: "text", params: {}, index: 2}, // needs special handling + OtherAddressCity: {item: "adr", type: "text", params: {}, index: 3}, + OtherAddressState: {item: "adr", type: "text", params: {}, index: 4}, + OtherAddressPostalCode: {item: "adr", type: "text", params: {}, index: 5}, + OtherAddressCountry: {item: "adr", type: "text", params: {}, index: 6}, + + // Misusing this EAS field, so that "Custom1" is saved to the server. + OfficeLocation: {item: "x-custom1", type: "text", params: {}}, + + Picture: {item: "photo", params: {}, type: "uri"}, + + // TB shows them as undefined, but showing them might be better, than not. Use a prefix. + AssistantPhoneNumber: { item: "tel", type: "text", params: {type: "Assistant"}, prefix: true}, + CarPhoneNumber: { item: "tel", type: "text", params: {type: "Car"}, prefix: true}, + RadioPhoneNumber: { item: "tel", type: "text", params: {type: "Radio"}, prefix: true}, + BusinessFaxNumber: { item: "tel", type: "text", params: {type: "WorkFax"}, prefix: true}, + }, + + map_EAS_properties_to_vCard_set2 : { + NickName: {item: "nickname", type: "text", params: {} }, + // Misusing these EAS fields, so that "Custom2,3,4" is saved to the server. + CustomerId: {item: "x-custom2", type: "text", params: {}}, + GovernmentId: {item: "x-custom3", type: "text", params: {}}, + AccountName: {item: "x-custom4", type: "text", params: {}}, + + IMAddress: {item: "impp", type: "text", params: {} }, + IMAddress2: {item: "impp", type: "text", params: {}, entry: 1 }, + IMAddress3: {item: "impp", type: "text", params: {}, entry: 2 }, + + CompanyMainPhone: { item: "tel", type: "text", params: {type: "Company"}, prefix: true}, + }, + + // There are currently no TB fields for these values, TbSync will store (and + // resend) them, but will not allow to view/edit. + unused_EAS_properties: [ + "Alias", //pseudo field + "WeightedRank", //pseudo field + "YomiCompanyName", //japanese phonetic equivalent + "YomiFirstName", //japanese phonetic equivalent + "YomiLastName", //japanese phonetic equivalent + "CompressedRTF", + "MMS", + // Former custom EAS fields, no longer added to UI after 102. + "ManagerName", + "AssistantName", + "Spouse", + ], + + // Normalize a parameters entry, to be able to find matching existing + // entries. If we want to be less restrictive, we need to check if all + // the requested values exist. But we should be the only one who sets + // the vCard props, so it should be safe. Except someone moves a contact. + // Should we prevent that via a vendor id in the vcard? + normalizeParameters: function (unordered) { + return JSON.stringify( + Object.keys(unordered).map(e => `${e}`.toLowerCase()).sort().reduce( + (obj, key) => { + obj[key] = `${unordered[key]}`.toLowerCase(); + return obj; + }, + {} + ) + ); + }, + + getValue: function (vCardProperties, vCard_property) { + let parameters = [vCard_property.params]; + if (vCard_property.fallbackParams) { + parameters.push(...vCard_property.fallbackParams); + } + let entries; + for (let normalizedParams of parameters.map(this.normalizeParameters)) { + // If no params set, do not filter, otherwise filter for exact match. + entries = vCardProperties.getAllEntries(vCard_property.item) + .filter(e => vCard_property.matchAll || normalizedParams == this.normalizeParameters(e.params)); + if (entries.length > 0) { + break; + } + } + + // Which entry should we take? + let entryNr = vCard_property.entry || 0; + if (entries[entryNr]) { + let value; + if (vCard_property.item == "org" && !Array.isArray(entries[entryNr].value)) { + // The org field sometimes comes back as a string (then it is Company), + // even though it should be an array [Department,Company] + value = vCard_property.index == 1 ? entries[entryNr].value : ""; + } else if (this.vcard_array_fields[vCard_property.item]) { + if (!Array.isArray(entries[entryNr].value)) { + // If the returned value is a single string, return it only + // when index 0 is requested, otherwise return nothing. + value = vCard_property.index == 0 ? entries[entryNr].value : ""; + } else { + value = entries[entryNr].value[vCard_property.index]; + } + } else { + value = entries[entryNr].value; + } + + if (value) { + if (vCard_property.prefix && value.startsWith(`${vCard_property.params.type}: `)) { + return value.substring(`${vCard_property.params.type}: `.length); + } + return value; + } + } + return ""; + }, + + /** + * Reads a DOM File and returns a Promise for its dataUrl. + * + * @param {File} file + * @returns {string} + */ + getDataUrl(file) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = function() { + resolve(reader.result); + }; + reader.onerror = function(error) { + resolve(""); + }; + }); + }, + + + + // --------------------------------------------------------------------------- // + // Read WBXML and set Thunderbird item + // --------------------------------------------------------------------------- // + setThunderbirdItemFromWbxml: function (abItem, data, id, syncdata, mode = "standard") { + let asversion = syncdata.accountData.getAccountProperty("asversion"); + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Processing " + mode + " contact item", id); + + // Make sure we are dealing with a vCard, so we can update the card just + // by updating its vCardProperties. + if (!abItem._card.supportsVCard) { + // This is an older card?? + throw new Error("It looks like you are trying to sync a TB91 sync state. Does not work."); + } + let vCardProperties = abItem._card.vCardProperties + abItem.primaryKey = id; + + // Loop over all known EAS properties (two EAS sets Contacts and Contacts2). + for (let set=0; set < 2; set++) { + let properties = (set == 0) ? this.EAS_properties : this.EAS_properties2; + + for (let EAS_property of properties) { + let vCard_property = (set == 0) ? this.map_EAS_properties_to_vCard[EAS_property] : this.map_EAS_properties_to_vCard_set2[EAS_property]; + let value; + switch (EAS_property) { + case "Notes": + if (asversion == "2.5") { + value = eas.xmltools.checkString(data.Body); + } else if (data.Body && data.Body.Data) { + value = eas.xmltools.checkString(data.Body.Data); + } + break; + + default: + value = eas.xmltools.checkString(data[EAS_property]); + } + + let normalizedParams = this.normalizeParameters(vCard_property.params) + let entries = vCardProperties.getAllEntries(vCard_property.item) + .filter(e => vCard_property.matchAll || normalizedParams == this.normalizeParameters(e.params)); + // Which entry should we update? Add empty entries, if the requested entry number + // does not yet exist. + let entryNr = vCard_property.entry || 0; + while (entries.length <= entryNr) { + let newEntry = new VCardPropertyEntry( + vCard_property.item, + vCard_property.params, + vCard_property.type, + this.vcard_array_fields[vCard_property.item] + ? new Array(this.vcard_array_fields[vCard_property.item]).fill("") + : "" + ); + vCardProperties.addEntry(newEntry); + entries = vCardProperties.getAllEntries(vCard_property.item); + entryNr = entries.length - 1; + } + + // Is this property part of the send data? + if (value) { + // Do we need to manipulate the value? + switch (EAS_property) { + case "Picture": + value = `data:image/jpeg;base64,${eas.xmltools.nodeAsArray(data.Picture)[0]}`; //Kerio sends Picture as container + break; + + case "Birthday": + case "Anniversary": + let dateObj = new Date(value); + value = dateObj.toISOString().substr(0, 10); + break; + + case "Email1Address": + case "Email2Address": + case "Email3Address": + let parsedInput = MailServices.headerParser.makeFromDisplayAddress(value); + let fixedValue = (parsedInput && parsedInput[0] && parsedInput[0].email) ? parsedInput[0].email : value; + if (fixedValue != value) { + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Parsing email display string via RFC 2231 and RFC 2047 ("+EAS_property+")", value + " -> " + fixedValue); + value = fixedValue; + } + break; + + case "HomeAddressStreet": + case "BusinessAddressStreet": + case "OtherAddressStreet": + // Thunderbird accepts an array in the vCardProperty of the 2nd index of the adr field. + let seperator = String.fromCharCode(syncdata.accountData.getAccountProperty("seperator")); // options are 44 (,) or 10 (\n) + value = value.split(seperator); + break; + } + + // Add a typePrefix for fields unknown to TB (better: TB should use the type itself). + if (vCard_property.prefix && !value.startsWith(`${vCard_property.params.type}: `)) { + value = `${vCard_property.params.type}: ${value}`; + } + + // Is this an array value? + if (this.vcard_array_fields[vCard_property.item]) { + // Make sure this is an array. + if (!Array.isArray(entries[entryNr].value)) { + let arr = new Array(this.vcard_array_fields[vCard_property.item]).fill(""); + arr[0] = entries[entryNr].value; + entries[entryNr].value = arr; + } + entries[entryNr].value[vCard_property.index] = value; + } else { + entries[entryNr].value = value; + } + } else { + if (this.vcard_array_fields[vCard_property.item]) { + // Make sure this is an array. + if (!Array.isArray(entries[entryNr].value)) { + let arr = new Array(this.vcard_array_fields[vCard_property.item]).fill(""); + arr[0] = entries[entryNr].value; + entries[entryNr].value = arr; + } + entries[entryNr].value[vCard_property.index] = ""; + } else { + entries[entryNr].value = ""; + } + } + } + } + + // Take care of categories. + if (data["Categories"] && data["Categories"]["Category"]) { + let categories = Array.isArray(data["Categories"]["Category"]) + ? data["Categories"]["Category"] + : [data["Categories"]["Category"]]; + vCardProperties.clearValues("categories"); + vCardProperties.addValue("categories", categories); + // Migration code, remove once no longer needed. + abItem.setProperty("Categories", ""); + } + + // Take care of children, stored in contacts property bag. + if (data["Children"] && data["Children"]["Child"]) { + let children = Array.isArray(data["Children"]["Child"]) + ? data["Children"]["Child"] + : [data["Children"]["Child"]]; + abItem.setProperty("Children", JSON.stringify(children)); + } + + // Take care of un-mappable EAS options, which are stored in the contacts + // property bag. + for (let i=0; i < this.unused_EAS_properties.length; i++) { + if (data[this.unused_EAS_properties[i]]) abItem.setProperty("EAS-" + this.unused_EAS_properties[i], data[this.unused_EAS_properties[i]]); + } + + // Remove all entries, which are marked for deletion. + vCardProperties.entries = vCardProperties.entries.filter(e => Array.isArray(e.value) ? e.value.some(a => a != "") : e.value != ""); + + // Further manipulations (a few getters are still usable \o/). + if (syncdata.accountData.getAccountProperty("displayoverride")) { + abItem._card.displayName = abItem._card.firstName + " " + abItem._card.lastName; + if (abItem._card.displayName == " " ) { + let company = (vCardProperties.getFirstValue("org") || [""])[0]; + abItem._card.displayName = company || abItem._card.primaryEmail + } + } + }, + + + + + // --------------------------------------------------------------------------- // + //read TB event and return its data as WBXML + // --------------------------------------------------------------------------- // + getWbxmlFromThunderbirdItem: async function (abItem, syncdata, isException = false) { + let asversion = syncdata.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncdata.type); //init wbxml with "" and not with precodes, and set initial codepage + let nowDate = new Date(); + + // Make sure we are dealing with a vCard, so we can access its vCardProperties. + if (!abItem._card.supportsVCard) { + throw new Error("It looks like you are trying to sync a TB91 sync state. Does not work."); + } + let vCardProperties = abItem._card.vCardProperties + + // Loop over all known EAS properties (send empty value if not set). + for (let EAS_property of this.EAS_properties) { + // Some props need special handling. + let vCard_property = this.map_EAS_properties_to_vCard[EAS_property]; + let value; + switch (EAS_property) { + case "Notes": + // Needs to be done later, because we have to switch the code page. + continue; + + case "Picture": { + let photoUrl = abItem._card.photoURL; + if (!photoUrl) { + continue; + } + if (photoUrl.startsWith("file://")) { + let realPhotoFile = Services.io.newURI(photoUrl).QueryInterface(Ci.nsIFileURL).file; + let photoFile = await File.createFromNsIFile(realPhotoFile); + photoUrl = await this.getDataUrl(photoFile); + } + if (photoUrl.startsWith("data:image/")) { + let parts = photoUrl.split(","); + parts.shift(); + value = parts.join(","); + } + } + break; + + case "Birthday": + case "Anniversary": { + let raw = this.getValue(vCardProperties, vCard_property); + if (raw) { + let dateObj = new Date(raw); + value = dateObj.toISOString(); + } + } + break; + + case "HomeAddressStreet": + case "BusinessAddressStreet": + case "OtherAddressStreet": { + let raw = this.getValue(vCardProperties, vCard_property); + try { + if (raw) { + // We either get a single string or an array for the + // street adr field from Thunderbird. + if (!Array.isArray(raw)) { + raw = [raw]; + } + let seperator = String.fromCharCode(syncdata.accountData.getAccountProperty("seperator")); // options are 44 (,) or 10 (\n) + value = raw.join(seperator); + } + } catch (ex) { + throw new Error(`Failed to eval value: <${JSON.stringify(raw)}> @ ${JSON.stringify(vCard_property)}`); + } + } + break; + + default: { + value = this.getValue(vCardProperties, vCard_property); + } + } + + if (value) { + wbxml.atag(EAS_property, value); + } + } + + // Take care of un-mappable EAS option. + for (let i=0; i < this.unused_EAS_properties.length; i++) { + let value = abItem.getProperty("EAS-" + this.unused_EAS_properties[i], ""); + if (value) wbxml.atag(this.unused_EAS_properties[i], value); + } + + // Take care of categories. + let categories = vCardProperties.getFirstValue("categories"); + let categoriesProperty = abItem.getProperty("Categories", ""); + if (categoriesProperty) { + // Migration code, remove once no longer needed. + abItem.setProperty("Categories", ""); + categories = this.arrayFromString(categoriesProperty); + } + if (categories) { + wbxml.otag("Categories"); + for (let category of categories) wbxml.atag("Category", category); + wbxml.ctag(); + } + + // Take care of children, stored in contacts property bag. + let childrenProperty = abItem.getProperty("Children", ""); + if (childrenProperty) { + let children = []; + try { + children = JSON.parse(childrenProperty); + } catch(ex) { + // Migration code, remove once no longer needed. + children = this.arrayFromString(childrenProperty); + } + wbxml.otag("Children"); + for (let child of children) wbxml.atag("Child", child); + wbxml.ctag(); + } + + // Take care of notes - SWITCHING TO AirSyncBase (if 2.5, we still need Contact group here!) + let description = this.getValue(vCardProperties, this.map_EAS_properties_to_vCard["Notes"]); + if (asversion == "2.5") { + wbxml.atag("Body", description); + } else { + wbxml.switchpage("AirSyncBase"); + wbxml.otag("Body"); + wbxml.atag("Type", "1"); + wbxml.atag("EstimatedDataSize", "" + description.length); + wbxml.atag("Data", description); + wbxml.ctag(); + } + + // Take care of Contacts2 group - SWITCHING TO CONTACTS2 + wbxml.switchpage("Contacts2"); + + // Loop over all known TB properties of EAS group Contacts2 (send empty value if not set). + for (let EAS_property of this.EAS_properties2) { + let vCard_property = this.map_EAS_properties_to_vCard_set2[EAS_property]; + let value = this.getValue(vCardProperties, vCard_property); + if (value) wbxml.atag(EAS_property, value); + } + + return wbxml.getBytes(); + } +} + +Contacts.EAS_properties = Object.keys(Contacts.map_EAS_properties_to_vCard); +Contacts.EAS_properties2 = Object.keys(Contacts.map_EAS_properties_to_vCard_set2); diff --git a/content/includes/network.js b/content/includes/network.js new file mode 100644 index 0000000..ba2abcd --- /dev/null +++ b/content/includes/network.js @@ -0,0 +1,1459 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + +var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm"); + +var network = { + + getEasURL: function(accountData) { + let protocol = (accountData.getAccountProperty("https")) ? "https://" : "http://"; + let h = protocol + accountData.getAccountProperty("host"); + while (h.endsWith("/")) { h = h.slice(0,-1); } + + if (h.endsWith("Microsoft-Server-ActiveSync")) return h; + return h + "/Microsoft-Server-ActiveSync"; + }, + + getAuthData: function(accountData) { + let authData = { + // This is the host for the password manager, which could be different from + // the actual host property of the account. For EAS we want to couple the password + // with the ACCOUNT and not any sort of url, which could change via autodiscover + // at any time. + get host() { + return "TbSync#" + accountData.accountID; + }, + + get user() { + return accountData.getAccountProperty("user"); + }, + + get password() { + return TbSync.passwordManager.getLoginInfo(this.host, "TbSync/EAS", this.user); + }, + + updateLoginData: function(newUsername, newPassword) { + let oldUsername = this.user; + TbSync.passwordManager.updateLoginInfo(this.host, "TbSync/EAS", oldUsername, newUsername, newPassword); + // Also update the username of this account. Add dedicated username setter? + accountData.setAccountProperty("user", newUsername); + }, + + removeLoginData: function() { + TbSync.passwordManager.removeLoginInfos(this.host, "TbSync/EAS"); + } + }; + return authData; + }, + + // prepare and patch OAuth2 object + getOAuthObj: function(configObject = null) { + let accountname, user, host, accountID, servertype; + + let accountData = (configObject && configObject.hasOwnProperty("accountData")) ? configObject.accountData : null; + if (accountData) { + accountname = accountData.getAccountProperty("accountname"); + user = accountData.getAccountProperty("user"); + host = accountData.getAccountProperty("host"); + servertype = accountData.getAccountProperty("servertype"); + accountID = accountData.accountID; + } else { + accountname = (configObject && configObject.hasOwnProperty("accountname")) ? configObject.accountname : ""; + user = (configObject && configObject.hasOwnProperty("user")) ? configObject.user : ""; + host = (configObject && configObject.hasOwnProperty("host")) ? configObject.host : ""; + servertype = (configObject && configObject.hasOwnProperty("servertype")) ? configObject.servertype : ""; + accountID = ""; + } + + if (!["office365"].includes(servertype)) + return null; + + let config = {}; + let customID = eas.Base.getCustomeOauthClientID(); + switch (host) { + case "outlook.office365.com": + case "eas.outlook.com": + config = { + auth_uri : "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_uri : "https://login.microsoftonline.com/common/oauth2/v2.0/token", + redirect_uri : "https://login.microsoftonline.com/common/oauth2/nativeclient", + client_id : customID != "" ? customID : "2980deeb-7460-4723-864a-f9b0f10cd992", + } + break; + + default: + return null; + } + + switch (host) { + case "outlook.office365.com": + config.scope = "offline_access https://outlook.office.com/.default"; + break; + case "eas.outlook.com": + config.scope = "offline_access https://outlook.office.com/EAS.AccessAsUser.All"; + break; + } + + let oauth = new OAuth2(config.scope, { + authorizationEndpoint: config.auth_uri, + tokenEndpoint: config.token_uri, + clientId: config.client_id, + clientSecret: config.client_secret + }); + oauth.requestWindowFeatures = "chrome,private,centerscreen,width=500,height=750"; + + // The v2 redirection endpoint differs from the default and needs manual override + oauth.redirectionEndpoint = config.redirect_uri; + + oauth.extraAuthParams = [ + // removed in beta 1.14.1, according to + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#default-and-consent + // prompt = consent will always ask for admin consent, even if it was granted + //["prompt", "consent"], + ["login_hint", user], + ]; + + if (accountname) { + oauth.requestWindowTitle = "TbSync account <" + accountname + "> requests authorization."; + } else { + oauth.requestWindowTitle = "A TbSync account requests authorization."; + } + + + + + /* Adding custom methods to the oauth object */ + + oauth.asyncConnect = async function(rv) { + let self = this; + rv.error = ""; + rv.tokens = ""; + + // If multiple resources need to authenticate they will all end here, even though they + // might share the same token. Due to the async nature, each process will refresh + // "its own" token again, which is not needed. We force clear the token here and each + // final connect process will actually check the acccessToken and abort the refresh, + // if it is already there, generated by some other process. + if (self.accessToken) self.accessToken = ""; + + try { + await new Promise(function(resolve, reject) { + // refresh = false will do nothing and resolve immediately, if an accessToken + // exists already, which must have been generated by another process, as + // we cleared it beforehand. + self.connect(resolve, reject, /* with UI */ true, /* refresh */ false); + }); + rv.tokens = self.tokens; + return true; + } catch (e) { + rv.error = eas.tools.isString(e) ? e : JSON.stringify(e); + } + + try { + switch (JSON.parse(rv.error).error) { + case "invalid_grant": + self.accessToken = ""; + self.refreshToken = ""; + return true; + + case "cancelled": + rv.error = "OAuthAbortError"; + break; + + default: + rv.error = "OAuthServerError::"+rv.error; + break; + } + } catch (e) { + rv.error = "OAuthServerError::"+rv.error; + Components.utils.reportError(e); + } + return false; + }; + + oauth.isExpired = function() { + const OAUTH_GRACE_TIME = 30 * 1000; + return (this.tokenExpires - OAUTH_GRACE_TIME < new Date().getTime()); + }; + + const OAUTHVALUES = [ + ["access", "", "accessToken"], + ["refresh", "", "refreshToken"], + ["expires", Number.MAX_VALUE, "tokenExpires"], + ]; + + // returns a JSON string containing all the oauth values + Object.defineProperty(oauth, "tokens", { + get: function() { + let tokensObj = {}; + for (let oauthValue of OAUTHVALUES) { + // use the system value or if not defined the default + tokensObj[oauthValue[0]] = this[oauthValue[2]] || oauthValue[1]; + } + return JSON.stringify(tokensObj); + }, + enumerable: true, + }); + + if (accountData) { + // authData allows us to access the password manager values belonging to this account/calendar + // simply by authdata.username and authdata.password + oauth.authData = TbSync.providers.eas.network.getAuthData(accountData); + + oauth.parseAndSanitizeTokenString = function(tokenString) { + let _tokensObj = {}; + try { + _tokensObj = JSON.parse(tokenString); + } catch (e) {} + + let tokensObj = {}; + for (let oauthValue of OAUTHVALUES) { + // use the provided value or if not defined the default + tokensObj[oauthValue[0]] = (_tokensObj && _tokensObj.hasOwnProperty(oauthValue[0])) + ? _tokensObj[oauthValue[0]] + : oauthValue[1]; + } + return tokensObj; + }; + + // Define getter/setter to act on the password manager password value belonging to this account/calendar + for (let oauthValue of OAUTHVALUES) { + Object.defineProperty(oauth, oauthValue[2], { + get: function() { + return this.parseAndSanitizeTokenString(this.authData.password)[oauthValue[0]]; + }, + set: function(val) { + let tokens = this.parseAndSanitizeTokenString(this.authData.password); + let valueChanged = (val != tokens[oauthValue[0]]) + if (valueChanged) { + tokens[oauthValue[0]] = val; + this.authData.updateLoginData(this.authData.user, JSON.stringify(tokens)); + } + }, + enumerable: true, + }); + } + } + + return oauth; + }, + + getOAuthValue: function(currentTokenString, type = "access") { + try { + let tokens = JSON.parse(currentTokenString); + if (tokens.hasOwnProperty(type)) + return tokens[type]; + } catch (e) { + //NOOP + } + return ""; + }, + + sendRequest: async function (wbxml, command, syncData, allowSoftFail = false) { + let ALLOWED_RETRIES = { + PasswordPrompt : 3, + NetworkError : 1, + } + + let rv = {}; + let oauthData = eas.network.getOAuthObj({ accountData: syncData.accountData }); + let syncState = syncData.getSyncState().state; + + for (;;) { + + if (rv.errorType) { + let retry = false; + + if (ALLOWED_RETRIES[rv.errorType] > 0) { + ALLOWED_RETRIES[rv.errorType]--; + + + switch (rv.errorType) { + + case "PasswordPrompt": + { + + if (oauthData) { + oauthData.accessToken = ""; + retry = true; + } else { + let authData = eas.network.getAuthData(syncData.accountData); + syncData.setSyncState("passwordprompt"); + let promptData = { + windowID: "auth:" + syncData.accountData.accountID, + accountname: syncData.accountData.getAccountProperty("accountname"), + usernameLocked: syncData.accountData.isConnected(), + username: authData.user + } + let credentials = await TbSync.passwordManager.asyncPasswordPrompt(promptData, eas.openWindows); + if (credentials) { + retry = true; + authData.updateLoginData(credentials.username, credentials.password); + } + } + } + break; + + case "NetworkError": + { + // Could not connect to server. Can we rerun autodiscover? + // Note: Autodiscover is currently not supported by OAuth + if (syncData.accountData.getAccountProperty( "servertype") == "auto" && !oauthData) { + let errorcode = await eas.network.updateServerConnectionViaAutodiscover(syncData); + console.log("ERR: " + errorcode); + if (errorcode == 200) { + // autodiscover succeeded, retry with new data + retry = true; + } else if (errorcode == 401) { + // manipulate rv to run password prompt + ALLOWED_RETRIES[rv.errorType]++; + rv.errorType = "PasswordPrompt"; + rv.errorObj = eas.sync.finish("error", "401"); + continue; // with the next loop, skip connection to the server + } + } + } + break; + + } + } + + if (!retry) throw rv.errorObj; + } + + // check OAuth situation before connecting + if (oauthData && (!oauthData.accessToken || oauthData.isExpired())) { + syncData.setSyncState("oauthprompt"); + let _rv = {} + if (!(await oauthData.asyncConnect(_rv))) { + throw eas.sync.finish("error", _rv.error); + } + } + + // Return to original syncstate + if (syncState != syncData.getSyncState().state) { + syncData.setSyncState(syncState); + } + rv = await this.sendRequestPromise(wbxml, command, syncData, allowSoftFail); + + if (rv.errorType) { + // make sure, there is a valid ALLOWED_RETRIES setting for the returned error + if (rv.errorType && !ALLOWED_RETRIES.hasOwnProperty(rv.errorType)) { + ALLOWED_RETRIES[rv.errorType] = 1; + } + } else { + return rv; + } + } + }, + + sendRequestPromise: function (wbxml, command, syncData, allowSoftFail = false) { + let msg = "Sending data <" + syncData.getSyncState().state + "> for " + syncData.accountData.getAccountProperty("accountname"); + if (syncData.currentFolderData) msg += " (" + syncData.currentFolderData.getFolderProperty("foldername") + ")"; + syncData.request = eas.network.logXML(wbxml, msg); + syncData.response = ""; + + let connection = eas.network.getAuthData(syncData.accountData); + let userAgent = syncData.accountData.getAccountProperty("useragent"); //plus calendar.useragent.extra = Lightning/5.4.5.2 + let deviceType = syncData.accountData.getAccountProperty("devicetype"); + let deviceId = syncData.accountData.getAccountProperty("deviceId"); + + TbSync.dump("Sending (EAS v"+syncData.accountData.getAccountProperty("asversion") +")", "POST " + eas.network.getEasURL(syncData.accountData) + '?Cmd=' + command + '&User=' + encodeURIComponent(connection.user) + '&DeviceType=' +deviceType + '&DeviceId=' + deviceId, true); + + const textEncoder = new TextEncoder(); + let encoded = textEncoder.encode(wbxml); + // console.log("wbxml: " + wbxml); + // console.log("byte array: " + encoded); + // console.log("length :" + wbxml.length + " vs " + encoded.byteLength + " vs " + encoded.length); + + return new Promise(function(resolve,reject) { + // Create request handler - API changed with TB60 to new XMKHttpRequest() + syncData.req = new XMLHttpRequest(); + syncData.req.mozBackgroundRequest = true; + syncData.req.open("POST", eas.network.getEasURL(syncData.accountData) + '?Cmd=' + command + '&User=' + encodeURIComponent(connection.user) + '&DeviceType=' +encodeURIComponent(deviceType) + '&DeviceId=' + deviceId, true); + syncData.req.overrideMimeType("text/plain"); + syncData.req.setRequestHeader("User-Agent", userAgent); + syncData.req.setRequestHeader("Content-Type", "application/vnd.ms-sync.wbxml"); + if (connection.password) { + if (eas.network.getOAuthObj({ accountData: syncData.accountData })) { + syncData.req.setRequestHeader("Authorization", 'Bearer ' + eas.network.getOAuthValue(connection.password, "access")); + } else { + syncData.req.setRequestHeader("Authorization", 'Basic ' + TbSync.tools.b64encode(connection.user + ':' + connection.password)); + } + } + + if (syncData.accountData.getAccountProperty("asversion") == "2.5") { + syncData.req.setRequestHeader("MS-ASProtocolVersion", "2.5"); + } else { + syncData.req.setRequestHeader("MS-ASProtocolVersion", "14.0"); + } + syncData.req.setRequestHeader("Content-Length", encoded.length); + if (syncData.accountData.getAccountProperty("provision")) { + syncData.req.setRequestHeader("X-MS-PolicyKey", syncData.accountData.getAccountProperty("policykey")); + TbSync.dump("PolicyKey used", syncData.accountData.getAccountProperty("policykey")); + } + + syncData.req.timeout = eas.Base.getConnectionTimeout(); + + syncData.req.ontimeout = function () { + if (allowSoftFail) { + resolve(""); + } else { + reject(eas.sync.finish("error", "timeout")); + } + }; + + syncData.req.onerror = function () { + if (allowSoftFail) { + resolve(""); + } else { + let error = TbSync.network.createTCPErrorFromFailedXHR(syncData.req) || "networkerror"; + let rv = {}; + rv.errorObj = eas.sync.finish("error", error); + rv.errorType = "NetworkError"; + resolve(rv); + } + }; + + syncData.req.onload = function() { + let response = syncData.req.responseText; + switch(syncData.req.status) { + + case 200: //OK + let msg = "Receiving data <" + syncData.getSyncState().state + "> for " + syncData.accountData.getAccountProperty("accountname"); + if (syncData.currentFolderData) msg += " (" + syncData.currentFolderData.getFolderProperty("foldername") + ")"; + syncData.response = eas.network.logXML(response, msg); + + //What to do on error? IS this an error? Yes! + if (!allowSoftFail && response.length !== 0 && response.substr(0, 4) !== String.fromCharCode(0x03, 0x01, 0x6A, 0x00)) { + TbSync.dump("Recieved Data", "Expecting WBXML but got junk (request status = " + syncData.req.status + ", ready state = " + syncData.req.readyState + "\n>>>>>>>>>>\n" + response + "\n<<<<<<<<<<\n"); + reject(eas.sync.finish("warning", "invalid")); + } else { + resolve(response); + } + break; + + case 401: // AuthError + case 403: // Forbiddden (some servers send forbidden on AuthError, like Freenet) + let rv = {}; + rv.errorObj = eas.sync.finish("error", "401"); + rv.errorType = "PasswordPrompt"; + resolve(rv); + break; + + case 449: // Request for new provision (enable it if needed) + //enable provision + syncData.accountData.setAccountProperty("provision", true); + syncData.accountData.resetAccountProperty("policykey"); + reject(eas.sync.finish("resyncAccount", syncData.req.status)); + break; + + case 451: // Redirect - update host and login manager + let header = syncData.req.getResponseHeader("X-MS-Location"); + let newHost = header.slice(header.indexOf("://") + 3, header.indexOf("/M")); + + TbSync.dump("redirect (451)", "header: " + header + ", oldHost: " +syncData.accountData.getAccountProperty("host") + ", newHost: " + newHost); + + syncData.accountData.setAccountProperty("host", newHost); + reject(eas.sync.finish("resyncAccount", syncData.req.status)); + break; + + default: + if (allowSoftFail) { + resolve(""); + } else { + reject(eas.sync.finish("error", "httperror::" + syncData.req.status)); + } + } + }; + + syncData.req.send(encoded); + + }); + }, + + + + + + + + + + + // RESPONSE EVALUATION + + logXML : function (wbxml, what) { + let rawxml = eas.wbxmltools.convert2xml(wbxml); + let xml = null; + if (rawxml) { + xml = rawxml.split('><').join('>\n<'); + } + + //include xml in log, if userdatalevel 2 or greater + if (TbSync.prefs.getIntPref("log.userdatalevel") > 1) { + + //log raw wbxml if userdatalevel is 3 or greater + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) { + let charcodes = []; + for (let i=0; i< wbxml.length; i++) charcodes.push(wbxml.charCodeAt(i).toString(16)); + let bytestring = charcodes.join(" "); + TbSync.dump("WBXML: " + what, "\n" + bytestring); + } + + if (xml) { + //raw xml is save xml with all special chars in user data encoded by encodeURIComponent - KEEP that in order to be able to analyze logged XML + //let xml = decodeURIComponent(rawxml.split('><').join('>\n<')); + TbSync.dump("XML: " + what, "\n" + xml); + } else { + TbSync.dump("XML: " + what, "\nFailed to convert WBXML to XML!\n"); + } + } + + return xml; + }, + + //returns false on parse error and null on empty response (if allowed) + getDataFromResponse: function (wbxml, allowEmptyResponse = !eas.flags.allowEmptyResponse) { + //check for empty wbxml + if (wbxml.length === 0) { + if (allowEmptyResponse) return null; + else throw eas.sync.finish("warning", "empty-response"); + } + + //convert to save xml (all special chars in user data encoded by encodeURIComponent) and check for parse errors + let xml = eas.wbxmltools.convert2xml(wbxml); + if (xml === false) { + throw eas.sync.finish("warning", "wbxml-parse-error"); + } + + //retrieve data and check for empty data (all returned data fields are already decoded by decodeURIComponent) + let wbxmlData = eas.xmltools.getDataFromXMLString(xml); + if (wbxmlData === null) { + if (allowEmptyResponse) return null; + else throw eas.sync.finish("warning", "response-contains-no-data"); + } + + //debug + eas.xmltools.printXmlData(wbxmlData, false); //do not include ApplicationData in log + return wbxmlData; + }, + + updateSynckey: function (syncData, wbxmlData) { + let synckey = eas.xmltools.getWbxmlDataField(wbxmlData,"Sync.Collections.Collection.SyncKey"); + + if (synckey) { + // This COULD be a cause of problems... + syncData.synckey = synckey; + syncData.currentFolderData.setFolderProperty("synckey", synckey); + } else { + throw eas.sync.finish("error", "wbxmlmissingfield::Sync.Collections.Collection.SyncKey"); + } + }, + + checkStatus : function (syncData, wbxmlData, path, rootpath="", allowSoftFail = false) { + //path is relative to wbxmlData + //rootpath is the absolute path and must be specified, if wbxml is not the root node and thus path is not the rootpath + let status = eas.xmltools.getWbxmlDataField(wbxmlData,path); + let fullpath = (rootpath=="") ? path : rootpath; + let elements = fullpath.split("."); + let type = elements[0]; + + //check if fallback to main class status: the answer could just be a "Sync.Status" instead of a "Sync.Collections.Collections.Status" + if (status === false) { + let mainStatus = eas.xmltools.getWbxmlDataField(wbxmlData, type + "." + elements[elements.length-1]); + if (mainStatus === false) { + //both possible status fields are missing, abort + throw eas.sync.finish("warning", "wbxmlmissingfield::" + fullpath, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + } else { + //the alternative status could be extracted + status = mainStatus; + fullpath = type + "." + elements[elements.length-1]; + } + } + + //check if all is fine (not bad) + if (status == "1") { + return ""; + } + + TbSync.dump("wbxml status check", type + ": " + fullpath + " = " + status); + + //handle errrors based on type + let statusType = type+"."+status; + switch (statusType) { + case "Sync.3": /* + MUST return to SyncKey element value of 0 for the collection. The client SHOULD either delete any items that were added + since the last successful Sync or the client MUST add those items back to the server after completing the full resynchronization + */ + TbSync.eventlog.add("warning", syncData.eventLogInfo, "Forced Folder Resync", "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + syncData.currentFolderData.remove(); + throw eas.sync.finish("resyncFolder", statusType); + + case "Sync.4": //Malformed request + case "Sync.5": //Temporary server issues or invalid item + case "Sync.6": //Invalid item + case "Sync.8": //Object not found + if (allowSoftFail) return statusType; + throw eas.sync.finish("warning", statusType, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + + case "Sync.7": //The client has changed an item for which the conflict policy indicates that the server's changes take precedence. + case "Sync.9": //User account could be out of disk space, also send if no write permission (TODO) + return ""; + + case "FolderDelete.3": // special system folder - fatal error + case "FolderDelete.6": // error on server + throw eas.sync.finish("warning", statusType, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + + case "FolderDelete.4": // folder does not exist - resync ( we allow delete only if folder is not subscribed ) + case "FolderDelete.9": // invalid synchronization key - resync + case "FolderSync.9": // invalid synchronization key - resync + case "Sync.12": // folder hierarchy changed + { + let folders = syncData.accountData.getAllFoldersIncludingCache(); + for (let folder of folders) { + folder.remove(); + } + // reset account + eas.Base.onEnableAccount(syncData.accountData); + throw eas.sync.finish("resyncAccount", statusType, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + } + } + + //handle global error (https://msdn.microsoft.com/en-us/library/ee218647(v=exchg.80).aspx) + let descriptions = {}; + switch(status) { + case "101": //invalid content + case "102": //invalid wbxml + case "103": //invalid xml + throw eas.sync.finish("error", "global." + status, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + + case "109": descriptions["109"]="DeviceTypeMissingOrInvalid"; + case "112": descriptions["112"]="ActiveDirectoryAccessDenied"; + case "126": descriptions["126"]="UserDisabledForSync"; + case "127": descriptions["127"]="UserOnNewMailboxCannotSync"; + case "128": descriptions["128"]="UserOnLegacyMailboxCannotSync"; + case "129": descriptions["129"]="DeviceIsBlockedForThisUser"; + case "130": descriptions["120"]="AccessDenied"; + case "131": descriptions["131"]="AccountDisabled"; + throw eas.sync.finish("error", "global.clientdenied"+ "::" + status + "::" + descriptions[status]); + + case "110": //server error - abort and disable autoSync for 30 minutes + { + let noAutosyncUntil = 30 * 60000 + Date.now(); + let humanDate = new Date(noAutosyncUntil).toUTCString(); + syncData.accountData.setAccountProperty("noAutosyncUntil", noAutosyncUntil); + throw eas.sync.finish("error", "global." + status, "AutoSync disabled until: " + humanDate + " \n\nRequest:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + +/* // reset account + * let folders = syncData.accountData.getAllFoldersIncludingCache(); + * for (let folder of folders) { + * folder.remove(); + * } + * // reset account + * eas.Base.onEnableAccount(syncData.accountData); + * throw eas.sync.finish("resyncAccount", statusType, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + */ + } + + case "141": // The device is not provisionable + case "142": // DeviceNotProvisioned + case "143": // PolicyRefresh + case "144": // InvalidPolicyKey + //enable provision + syncData.accountData.setAccountProperty("provision", true); + syncData.accountData.resetAccountProperty("policykey"); + throw eas.sync.finish("resyncAccount", statusType); + + default: + if (allowSoftFail) return statusType; + throw eas.sync.finish("error", statusType, "Request:\n" + syncData.request + "\n\nResponse:\n" + syncData.response); + + } + }, + + + + + + + + + + + // WBXML COMM STUFF + + setDeviceInformation: async function (syncData) { + if (syncData.accountData.getAccountProperty("asversion") == "2.5" || !syncData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("Settings")) { + return; + } + + syncData.setSyncState("prepare.request.setdeviceinfo"); + + let wbxml = wbxmltools.createWBXML(); + wbxml.switchpage("Settings"); + wbxml.otag("Settings"); + wbxml.otag("DeviceInformation"); + wbxml.otag("Set"); + wbxml.atag("Model", "Computer"); + wbxml.atag("FriendlyName", "TbSync on Device " + syncData.accountData.getAccountProperty("deviceId").substring(4)); + wbxml.atag("OS", Services.appinfo.OS); + wbxml.atag("UserAgent", syncData.accountData.getAccountProperty("useragent")); + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + syncData.setSyncState("send.request.setdeviceinfo"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Settings", syncData); + + syncData.setSyncState("eval.response.setdeviceinfo"); + let wbxmlData = eas.network.getDataFromResponse(response); + + eas.network.checkStatus(syncData, wbxmlData,"Settings.Status"); + }, + + getPolicykey: async function (syncData) { + //build WBXML to request provision + syncData.setSyncState("prepare.request.provision"); + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("Provision"); + wbxml.otag("Provision"); + wbxml.otag("Policies"); + wbxml.otag("Policy"); + wbxml.atag("PolicyType", (syncData.accountData.getAccountProperty("asversion") == "2.5") ? "MS-WAP-Provisioning-XML" : "MS-EAS-Provisioning-WBXML" ); + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + for (let loop=0; loop < 2; loop++) { + syncData.setSyncState("send.request.provision"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Provision", syncData); + + syncData.setSyncState("eval.response.provision"); + let wbxmlData = eas.network.getDataFromResponse(response); + let policyStatus = eas.xmltools.getWbxmlDataField(wbxmlData, "Provision.Policies.Policy.Status"); + let provisionStatus = eas.xmltools.getWbxmlDataField(wbxmlData, "Provision.Status"); + if (provisionStatus === false) { + throw eas.sync.finish("error", "wbxmlmissingfield::Provision.Status"); + } else if (provisionStatus != "1") { + //dump policy status as well + if (policyStatus) TbSync.dump("PolicyKey","Received policy status: " + policyStatus); + throw eas.sync.finish("error", "provision::" + provisionStatus); + } + + //reaching this point: provision status was ok + let policykey = eas.xmltools.getWbxmlDataField(wbxmlData,"Provision.Policies.Policy.PolicyKey"); + switch (policyStatus) { + case false: + throw eas.sync.finish("error", "wbxmlmissingfield::Provision.Policies.Policy.Status"); + + case "2": + //server does not have a policy for this device: disable provisioning + syncData.accountData.setAccountProperty("provision", false) + syncData.accountData.resetAccountProperty("policykey"); + throw eas.sync.finish("resyncAccount", "NoPolicyForThisDevice"); + + case "1": + if (policykey === false) { + throw eas.sync.finish("error", "wbxmlmissingfield::Provision.Policies.Policy.PolicyKey"); + } + TbSync.dump("PolicyKey","Received policykey (" + loop + "): " + policykey); + syncData.accountData.setAccountProperty("policykey", policykey); + break; + + default: + throw eas.sync.finish("error", "policy." + policyStatus); + } + + //build WBXML to acknowledge provision + syncData.setSyncState("prepare.request.provision"); + wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("Provision"); + wbxml.otag("Provision"); + wbxml.otag("Policies"); + wbxml.otag("Policy"); + wbxml.atag("PolicyType",(syncData.accountData.getAccountProperty("asversion") == "2.5") ? "MS-WAP-Provisioning-XML" : "MS-EAS-Provisioning-WBXML" ); + wbxml.atag("PolicyKey", policykey); + wbxml.atag("Status", "1"); + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + //this wbxml will be used by Send at the top of this loop + } + }, + + getSynckey: async function (syncData) { + syncData.setSyncState("prepare.request.synckey"); + //build WBXML to request a new syncKey + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.otag("Sync"); + wbxml.otag("Collections"); + wbxml.otag("Collection"); + if (syncData.accountData.getAccountProperty("asversion") == "2.5") wbxml.atag("Class", syncData.type); + wbxml.atag("SyncKey","0"); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + syncData.setSyncState("send.request.synckey"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Sync", syncData); + + syncData.setSyncState("eval.response.synckey"); + // get data from wbxml response + let wbxmlData = eas.network.getDataFromResponse(response); + //check status + eas.network.checkStatus(syncData, wbxmlData,"Sync.Collections.Collection.Status"); + //update synckey + eas.network.updateSynckey(syncData, wbxmlData); + }, + + getItemEstimate: async function (syncData) { + syncData.progressData.reset(); + + if (!syncData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("GetItemEstimate")) { + return; //do not throw, this is optional + } + + syncData.setSyncState("prepare.request.estimate"); + + // BUILD WBXML + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("GetItemEstimate"); + wbxml.otag("GetItemEstimate"); + wbxml.otag("Collections"); + wbxml.otag("Collection"); + if (syncData.accountData.getAccountProperty("asversion") == "2.5") { //got this order for 2.5 directly from Microsoft support + wbxml.atag("Class", syncData.type); //only 2.5 + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.switchpage("AirSync"); + // required ! + // https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-ascmd/ffbefa62-e315-40b9-9cc6-f8d74b5f65d4 + if (syncData.type == "Calendar") wbxml.atag("FilterType", syncData.currentFolderData.accountData.getAccountProperty("synclimit")); + else wbxml.atag("FilterType", "0"); // we may filter incomplete tasks + + wbxml.atag("SyncKey", syncData.synckey); + wbxml.switchpage("GetItemEstimate"); + } else { //14.0 + wbxml.switchpage("AirSync"); + wbxml.atag("SyncKey", syncData.synckey); + wbxml.switchpage("GetItemEstimate"); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.switchpage("AirSync"); + wbxml.otag("Options"); + // optional + if (syncData.type == "Calendar") wbxml.atag("FilterType", syncData.currentFolderData.accountData.getAccountProperty("synclimit")); + wbxml.atag("Class", syncData.type); + wbxml.ctag(); + wbxml.switchpage("GetItemEstimate"); + } + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + //SEND REQUEST + syncData.setSyncState("send.request.estimate"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "GetItemEstimate", syncData, /* allowSoftFail */ true); + + //VALIDATE RESPONSE + syncData.setSyncState("eval.response.estimate"); + + // get data from wbxml response, some servers send empty response if there are no changes, which is not an error + let wbxmlData = eas.network.getDataFromResponse(response, eas.flags.allowEmptyResponse); + if (wbxmlData === null) return; + + let status = eas.xmltools.getWbxmlDataField(wbxmlData, "GetItemEstimate.Response.Status"); + let estimate = eas.xmltools.getWbxmlDataField(wbxmlData, "GetItemEstimate.Response.Collection.Estimate"); + + if (status && status == "1") { //do not throw on error, with EAS v2.5 I get error 2 for tasks and calendars ??? + syncData.progressData.reset(0, estimate); + } + }, + + getUserInfo: async function (syncData) { + if (!syncData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("Settings")) { + return; + } + + syncData.setSyncState("prepare.request.getuserinfo"); + + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("Settings"); + wbxml.otag("Settings"); + wbxml.otag("UserInformation"); + wbxml.atag("Get"); + wbxml.ctag(); + wbxml.ctag(); + + syncData.setSyncState("send.request.getuserinfo"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Settings", syncData); + + + syncData.setSyncState("eval.response.getuserinfo"); + let wbxmlData = eas.network.getDataFromResponse(response); + + eas.network.checkStatus(syncData, wbxmlData,"Settings.Status"); + }, + + + + + + + + + + + // SEARCH + + getSearchResults: async function (accountData, currentQuery) { + + let _wbxml = eas.wbxmltools.createWBXML(); + _wbxml.switchpage("Search"); + _wbxml.otag("Search"); + _wbxml.otag("Store"); + _wbxml.atag("Name", "GAL"); + _wbxml.atag("Query", currentQuery); + _wbxml.otag("Options"); + _wbxml.atag("Range", "0-99"); //Z-Push needs a Range + //Not valid for GAL: https://msdn.microsoft.com/en-us/library/gg675461(v=exchg.80).aspx + //_wbxml.atag("DeepTraversal"); + //_wbxml.atag("RebuildResults"); + _wbxml.ctag(); + _wbxml.ctag(); + _wbxml.ctag(); + + let wbxml = _wbxml.getBytes(); + + eas.network.logXML(wbxml, "Send (GAL Search)"); + let command = "Search"; + + let authData = eas.network.getAuthData(accountData); + let oauthData = eas.network.getOAuthObj({ accountData }); + let userAgent = accountData.getAccountProperty("useragent"); //plus calendar.useragent.extra = Lightning/5.4.5.2 + let deviceType = accountData.getAccountProperty("devicetype"); + let deviceId = accountData.getAccountProperty("deviceId"); + + TbSync.dump("Sending (EAS v" + accountData.getAccountProperty("asversion") +")", "POST " + eas.network.getEasURL(accountData) + '?Cmd=' + command + '&User=' + encodeURIComponent(authData.user) + '&DeviceType=' +deviceType + '&DeviceId=' + deviceId, true); + + for (let i=0; i < 2; i++) { + // check OAuth situation before connecting + if (oauthData && (!oauthData.accessToken || oauthData.isExpired())) { + let _rv = {} + if (!(await oauthData.asyncConnect(_rv))) { + throw eas.sync.finish("error", _rv.error); + } + } + + try { + let response = await new Promise(function(resolve, reject) { + // Create request handler - API changed with TB60 to new XMKHttpRequest() + let req = new XMLHttpRequest(); + req.mozBackgroundRequest = true; + req.open("POST", eas.network.getEasURL(accountData) + '?Cmd=' + command + '&User=' + encodeURIComponent(authData.user) + '&DeviceType=' +encodeURIComponent(deviceType) + '&DeviceId=' + deviceId, true); + req.overrideMimeType("text/plain"); + req.setRequestHeader("User-Agent", userAgent); + req.setRequestHeader("Content-Type", "application/vnd.ms-sync.wbxml"); + + if (authData.password) { + if (eas.network.getOAuthObj({ accountData })) { + req.setRequestHeader("Authorization", 'Bearer ' + eas.network.getOAuthValue(authData.password, "access")); + } else { + req.setRequestHeader("Authorization", 'Basic ' + TbSync.tools.b64encode(authData.user + ':' + authData.password)); + } + } + + if (accountData.getAccountProperty("asversion") == "2.5") { + req.setRequestHeader("MS-ASProtocolVersion", "2.5"); + } else { + req.setRequestHeader("MS-ASProtocolVersion", "14.0"); + } + req.setRequestHeader("Content-Length", wbxml.length); + if (accountData.getAccountProperty("provision")) { + req.setRequestHeader("X-MS-PolicyKey", accountData.getAccountProperty("policykey")); + TbSync.dump("PolicyKey used", accountData.getAccountProperty("policykey")); + } + + req.timeout = eas.Base.getConnectionTimeout(); + + req.ontimeout = function () { + reject("GAL Search timeout"); + }; + + req.onerror = function () { + reject("GAL Search Error"); + }; + + req.onload = function() { + let response = req.responseText; + + switch(req.status) { + + case 200: //OK + eas.network.logXML(response, "Received (GAL Search"); + + //What to do on error? IS this an error? Yes! + if (response.length !== 0 && response.substr(0, 4) !== String.fromCharCode(0x03, 0x01, 0x6A, 0x00)) { + TbSync.dump("Recieved Data", "Expecting WBXML but got junk (request status = " + req.status + ", ready state = " + req.readyState + "\n>>>>>>>>>>\n" + response + "\n<<<<<<<<<<\n"); + reject("GAL Search Response Invalid"); + } else { + resolve(response); + } + break; + + case 401: // bad auth + resolve("401"); + break; + + default: + reject("GAL Search Failed: " + req.status); + } + }; + + req.send(wbxml); + + }); + + if (response === "401") { + // try to recover from bad auth via token refresh + if (oauthData) { + oauthData.accessToken = ""; + continue; + } + } + + return response; + } catch (e) { + Components.utils.reportError(e); + return; + } + } + }, + + + + + + + + + + + // OPTIONS + + getServerOptions: async function (syncData) { + syncData.setSyncState("prepare.request.options"); + let authData = eas.network.getAuthData(syncData.accountData); + + let userAgent = syncData.accountData.getAccountProperty("useragent"); //plus calendar.useragent.extra = Lightning/5.4.5.2 + TbSync.dump("Sending", "OPTIONS " + eas.network.getEasURL(syncData.accountData)); + + let allowedRetries = 5; + let retry; + let oauthData = eas.network.getOAuthObj({ accountData: syncData.accountData }); + + do { + retry = false; + + // Check OAuth situation before connecting + if (oauthData && (!oauthData.accessToken || oauthData.isExpired())) { + let _rv = {}; + syncData.setSyncState("oauthprompt"); + if (!(await oauthData.asyncConnect(_rv))) { + throw eas.sync.finish("error", _rv.error); + } + } + + let result = await new Promise(function(resolve,reject) { + syncData.req = new XMLHttpRequest(); + syncData.req.mozBackgroundRequest = true; + syncData.req.open("OPTIONS", eas.network.getEasURL(syncData.accountData), true); + syncData.req.overrideMimeType("text/plain"); + syncData.req.setRequestHeader("User-Agent", userAgent); + if (authData.password) { + if (eas.network.getOAuthObj({ accountData: syncData.accountData })) { + syncData.req.setRequestHeader("Authorization", 'Bearer ' + eas.network.getOAuthValue(authData.password, "access")); + } else { + syncData.req.setRequestHeader("Authorization", 'Basic ' + TbSync.tools.b64encode(authData.user + ':' + authData.password)); + } + } + syncData.req.timeout = eas.Base.getConnectionTimeout(); + + syncData.req.ontimeout = function () { + resolve(); + }; + + syncData.req.onerror = function () { + let responseData = {}; + responseData["MS-ASProtocolVersions"] = syncData.req.getResponseHeader("MS-ASProtocolVersions"); + responseData["MS-ASProtocolCommands"] = syncData.req.getResponseHeader("MS-ASProtocolCommands"); + + TbSync.dump("EAS OPTIONS with response (status: "+syncData.req.status+")", "\n" + + "responseText: " + syncData.req.responseText + "\n" + + "responseHeader(MS-ASProtocolVersions): " + responseData["MS-ASProtocolVersions"]+"\n" + + "responseHeader(MS-ASProtocolCommands): " + responseData["MS-ASProtocolCommands"]); + resolve(); + }; + + syncData.req.onload = function() { + syncData.setSyncState("eval.request.options"); + let responseData = {}; + + switch(syncData.req.status) { + case 401: // AuthError + let rv = {}; + rv.errorObj = eas.sync.finish("error", "401"); + rv.errorType = "PasswordPrompt"; + resolve(rv); + break; + + case 200: + responseData["MS-ASProtocolVersions"] = syncData.req.getResponseHeader("MS-ASProtocolVersions"); + responseData["MS-ASProtocolCommands"] = syncData.req.getResponseHeader("MS-ASProtocolCommands"); + + TbSync.dump("EAS OPTIONS with response (status: 200)", "\n" + + "responseText: " + syncData.req.responseText + "\n" + + "responseHeader(MS-ASProtocolVersions): " + responseData["MS-ASProtocolVersions"]+"\n" + + "responseHeader(MS-ASProtocolCommands): " + responseData["MS-ASProtocolCommands"]); + + if (responseData && responseData["MS-ASProtocolCommands"] && responseData["MS-ASProtocolVersions"]) { + syncData.accountData.setAccountProperty("allowedEasCommands", responseData["MS-ASProtocolCommands"]); + syncData.accountData.setAccountProperty("allowedEasVersions", responseData["MS-ASProtocolVersions"]); + syncData.accountData.setAccountProperty("lastEasOptionsUpdate", Date.now()); + } + resolve(); + break; + + default: + resolve(); + break; + + } + }; + + syncData.setSyncState("send.request.options"); + syncData.req.send(); + + }); + + if (result && result.hasOwnProperty("errorType") && result.errorType == "PasswordPrompt") { + if (allowedRetries > 0) { + if (oauthData) { + oauthData.accessToken = ""; + retry = true; + } else { + syncData.setSyncState("passwordprompt"); + let authData = eas.network.getAuthData(syncData.accountData); + let promptData = { + windowID: "auth:" + syncData.accountData.accountID, + accountname: syncData.accountData.getAccountProperty("accountname"), + usernameLocked: syncData.accountData.isConnected(), + username: authData.user + } + let credentials = await TbSync.passwordManager.asyncPasswordPrompt(promptData, eas.openWindows); + if (credentials) { + authData.updateLoginData(credentials.username, credentials.password); + retry = true; + } + } + } + + if (!retry) { + throw result.errorObj; + } + } + + allowedRetries--; + } while (retry); + }, + + + + + + + + + + + // AUTODISCOVER + + updateServerConnectionViaAutodiscover: async function (syncData) { + syncData.setSyncState("prepare.request.autodiscover"); + let authData = eas.network.getAuthData(syncData.accountData); + + syncData.setSyncState("send.request.autodiscover"); + let result = await eas.network.getServerConnectionViaAutodiscover(authData.user, authData.password, 30*1000); + + syncData.setSyncState("eval.response.autodiscover"); + if (result.errorcode == 200) { + //update account + syncData.accountData.setAccountProperty("host", eas.network.stripAutodiscoverUrl(result.server)); + syncData.accountData.setAccountProperty("user", result.user); + syncData.accountData.setAccountProperty("https", (result.server.substring(0,5) == "https")); + } + + return result.errorcode; + }, + + stripAutodiscoverUrl: function(url) { + let u = url; + while (u.endsWith("/")) { u = u.slice(0,-1); } + if (u.endsWith("/Microsoft-Server-ActiveSync")) u=u.slice(0, -28); + else TbSync.dump("Received non-standard EAS url via autodiscover:", url); + + return u.split("//")[1]; //cut off protocol + }, + + getServerConnectionViaAutodiscover : async function (user, password, maxtimeout) { + let urls = []; + let parts = user.split("@"); + + urls.push({"url":"http://autodiscover."+parts[1]+"/autodiscover/autodiscover.xml", "user":user}); + urls.push({"url":"http://"+parts[1]+"/autodiscover/autodiscover.xml", "user":user}); + urls.push({"url":"http://autodiscover."+parts[1]+"/Autodiscover/Autodiscover.xml", "user":user}); + urls.push({"url":"http://"+parts[1]+"/Autodiscover/Autodiscover.xml", "user":user}); + + urls.push({"url":"https://autodiscover."+parts[1]+"/autodiscover/autodiscover.xml", "user":user}); + urls.push({"url":"https://"+parts[1]+"/autodiscover/autodiscover.xml", "user":user}); + urls.push({"url":"https://autodiscover."+parts[1]+"/Autodiscover/Autodiscover.xml", "user":user}); + urls.push({"url":"https://"+parts[1]+"/Autodiscover/Autodiscover.xml", "user":user}); + + let requests = []; + let responses = []; //array of objects {url, error, server} + + for (let i=0; i< urls.length; i++) { + await TbSync.tools.sleep(200); + requests.push( eas.network.getServerConnectionViaAutodiscoverRedirectWrapper(urls[i].url, urls[i].user, password, maxtimeout) ); + } + + try { + responses = await Promise.all(requests); + } catch (e) { + responses.push(e.result); //this is actually a success, see return value of getServerConnectionViaAutodiscoverRedirectWrapper() + } + + let result; + let log = []; + for (let r=0; r < responses.length; r++) { + log.push("* "+responses[r].url+" @ " + responses[r].user +" : " + (responses[r].server ? responses[r].server : responses[r].error)); + + if (responses[r].server) { + result = {"server": responses[r].server, "user": responses[r].user, "error": "", "errorcode": 200}; + break; + } + + if (responses[r].error == 403 || responses[r].error == 401) { + //we could still find a valid server, so just store this state + result = {"server": "", "user": responses[r].user, "errorcode": responses[r].error, "error": TbSync.getString("status." + responses[r].error, "eas")}; + } + } + + //this is only reached on fail, if no result defined yet, use general error + if (!result) { + result = {"server": "", "user": user, "error": TbSync.getString("autodiscover.Failed","eas").replace("##user##", user), "errorcode": 503}; + } + + TbSync.eventlog.add("error", new TbSync.EventLogInfo("eas"), result.error, log.join("\n")); + return result; + }, + + getServerConnectionViaAutodiscoverRedirectWrapper : async function (url, user, password, maxtimeout) { + //using HEAD to find URL redirects until response URL no longer changes + // * XHR should follow redirects transparently, but that does not always work, POST data could get lost, so we + // * need to find the actual POST candidates (example: outlook.de accounts) + let result = {}; + let method = "HEAD"; + let connection = { url, user }; + + do { + await TbSync.tools.sleep(200); + result = await eas.network.getServerConnectionViaAutodiscoverRequest(method, connection, password, maxtimeout); + method = ""; + + if (result.error == "redirect found") { + TbSync.dump("EAS autodiscover URL redirect", "\n" + connection.url + " @ " + connection.user + " => \n" + result.url + " @ " + result.user); + connection.url = result.url; + connection.user = result.user; + method = "HEAD"; + } else if (result.error == "POST candidate found") { + method = "POST"; + } + + } while (method); + + //invert reject and resolve, so we exit the promise group on success right away + if (result.server) { + let e = new Error("Not an error (early exit from promise group)"); + e.result = result; + throw e; + } else { + return result; + } + }, + + getServerConnectionViaAutodiscoverRequest: function (method, connection, password, maxtimeout) { + TbSync.dump("Querry EAS autodiscover URL", connection.url + " @ " + connection.user); + + return new Promise(function(resolve,reject) { + + let xml = '<?xml version="1.0" encoding="utf-8"?>\r\n'; + xml += '<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006">\r\n'; + xml += '<Request>\r\n'; + xml += '<EMailAddress>' + connection.user + '</EMailAddress>\r\n'; + xml += '<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006</AcceptableResponseSchema>\r\n'; + xml += '</Request>\r\n'; + xml += '</Autodiscover>\r\n'; + + let userAgent = eas.prefs.getCharPref("clientID.useragent"); //plus calendar.useragent.extra = Lightning/5.4.5.2 + + // Create request handler - API changed with TB60 to new XMKHttpRequest() + let req = new XMLHttpRequest(); + req.mozBackgroundRequest = true; + req.open(method, connection.url, true); + req.timeout = maxtimeout; + req.setRequestHeader("User-Agent", userAgent); + + let secure = (connection.url.substring(0,8).toLowerCase() == "https://"); + + if (method == "POST") { + req.setRequestHeader("Content-Length", xml.length); + req.setRequestHeader("Content-Type", "text/xml"); + if (secure && password) { + // OAUTH accounts cannot authenticate against the standard discovery services + // updateServerConnectionViaAutodiscover() is not passing them on + req.setRequestHeader("Authorization", "Basic " + TbSync.tools.b64encode(connection.user + ":" + password)); + } + } + + req.ontimeout = function () { + TbSync.dump("EAS autodiscover with timeout", "\n" + connection.url + " => \n" + req.responseURL); + resolve({"url":req.responseURL, "error":"timeout", "server":"", "user":connection.user}); + }; + + req.onerror = function () { + let error = TbSync.network.createTCPErrorFromFailedXHR(req); + if (!error) error = req.responseText; + TbSync.dump("EAS autodiscover with error ("+error+")", "\n" + connection.url + " => \n" + req.responseURL); + resolve({"url":req.responseURL, "error":error, "server":"", "user":connection.user}); + }; + + req.onload = function() { + //initiate rerun on redirects + if (req.responseURL != connection.url) { + resolve({"url":req.responseURL, "error":"redirect found", "server":"", "user":connection.user}); + return; + } + + //initiate rerun on HEAD request without redirect (rerun and do a POST on this) + if (method == "HEAD") { + resolve({"url":req.responseURL, "error":"POST candidate found", "server":"", "user":connection.user}); + return; + } + + //ignore POST without autherization (we just do them to get redirect information) + if (!secure) { + resolve({"url":req.responseURL, "error":"unsecure POST", "server":"", "user":connection.user}); + return; + } + + //evaluate secure POST requests which have not been redirected + TbSync.dump("EAS autodiscover POST with status (" + req.status + ")", "\n" + connection.url + " => \n" + req.responseURL + "\n[" + req.responseText + "]"); + + if (req.status === 200) { + let data = null; + // getDataFromXMLString may throw an error which cannot be catched outside onload, + // because we are in an async callback of the surrounding Promise + // Alternatively we could just return the responseText and do any data analysis outside of the Promise + try { + data = eas.xmltools.getDataFromXMLString(req.responseText); + } catch (e) { + resolve({"url":req.responseURL, "error":"bad response", "server":"", "user":connection.user}); + return; + } + + if (!(data === null) && data.Autodiscover && data.Autodiscover.Response && data.Autodiscover.Response.Action) { + // "Redirect" or "Settings" are possible + if (data.Autodiscover.Response.Action.Redirect) { + // redirect, start again with new user + let newuser = action.Redirect; + resolve({"url":req.responseURL, "error":"redirect found", "server":"", "user":newuser}); + + } else if (data.Autodiscover.Response.Action.Settings) { + // get server settings + let server = eas.xmltools.nodeAsArray(data.Autodiscover.Response.Action.Settings.Server); + + for (let count = 0; count < server.length; count++) { + if (server[count].Type == "MobileSync" && server[count].Url) { + resolve({"url":req.responseURL, "error":"", "server":server[count].Url, "user":connection.user}); + return; + } + } + } + } else { + resolve({"url":req.responseURL, "error":"invalid", "server":"", "user":connection.user}); + } + } else { + resolve({"url":req.responseURL, "error":req.status, "server":"", "user":connection.user}); + } + }; + + if (method == "HEAD") req.send(); + else req.send(xml); + + }); + }, + + getServerConnectionViaAutodiscoverV2JsonRequest: function (url, maxtimeout) { + TbSync.dump("Querry EAS autodiscover V2 URL", url); + + return new Promise(function(resolve,reject) { + + let userAgent = eas.prefs.getCharPref("clientID.useragent"); //plus calendar.useragent.extra = Lightning/5.4.5.2 + + // Create request handler - API changed with TB60 to new XMKHttpRequest() + let req = new XMLHttpRequest(); + req.mozBackgroundRequest = true; + req.open("GET", url, true); + req.timeout = maxtimeout; + req.setRequestHeader("User-Agent", userAgent); + + req.ontimeout = function () { + TbSync.dump("EAS autodiscover V2 with timeout", "\n" + url + " => \n" + req.responseURL); + resolve({"url":req.responseURL, "error":"timeout", "server":""}); + }; + + req.onerror = function () { + let error = TbSync.network.createTCPErrorFromFailedXHR(req); + if (!error) error = req.responseText; + TbSync.dump("EAS autodiscover V2 with error ("+error+")", "\n" + url + " => \n" + req.responseURL); + resolve({"url":req.responseURL, "error":error, "server":""}); + }; + + req.onload = function() { + if (req.status === 200) { + let data = JSON.parse(req.responseText); + + if (data && data.Url) { + resolve({"url":req.responseURL, "error":"", "server": eas.network.stripAutodiscoverUrl(data.Url)}); + } else { + resolve({"url":req.responseURL, "error":"invalid", "server":""}); + } + return; + } + + resolve({"url":req.responseURL, "error":req.status, "server":""}); + }; + + req.send(); + }); + } +} diff --git a/content/includes/sync.js b/content/includes/sync.js new file mode 100644 index 0000000..451b661 --- /dev/null +++ b/content/includes/sync.js @@ -0,0 +1,1504 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +// - https://dxr.mozilla.org/comm-central/source/calendar/base/public/calIEvent.idl +// - https://dxr.mozilla.org/comm-central/source/calendar/base/public/calIItemBase.idl +// - https://dxr.mozilla.org/comm-central/source/calendar/base/public/calICalendar.idl +// - https://dxr.mozilla.org/comm-central/source/calendar/base/modules/calAsyncUtils.jsm + +// https://msdn.microsoft.com/en-us/library/dd299454(v=exchg.80).aspx + +var sync = { + + + + finish: function (aStatus = "", msg = "", details = "") { + let status = TbSync.StatusData.SUCCESS + switch (aStatus) { + + case "": + case "ok": + status = TbSync.StatusData.SUCCESS; + break; + + case "info": + status = TbSync.StatusData.INFO; + break; + + case "resyncAccount": + status = TbSync.StatusData.ACCOUNT_RERUN; + break; + + case "resyncFolder": + status = TbSync.StatusData.FOLDER_RERUN; + break; + + case "warning": + status = TbSync.StatusData.WARNING; + break; + + case "error": + status = TbSync.StatusData.ERROR; + break; + + default: + console.log("TbSync/EAS: Unknown status <"+aStatus+">"); + status = TbSync.StatusData.ERROR; + break; + } + + let e = new Error(); + e.name = "eas4tbsync"; + e.message = status.toUpperCase() + ": " + msg.toString() + " (" + details.toString() + ")"; + e.statusData = new TbSync.StatusData(status, msg.toString(), details.toString()); + return e; + }, + + + resetFolderSyncInfo: function (folderData) { + folderData.resetFolderProperty("synckey"); + folderData.resetFolderProperty("lastsynctime"); + }, + + + // update folders avail on server and handle added, removed and renamed + // folders + folderList: async function(syncData) { + //should we recheck options/commands? Always check, if we have no info about asversion! + if (syncData.accountData.getAccountProperty("asversion", "") == "" || (Date.now() - syncData.accountData.getAccountProperty("lastEasOptionsUpdate")) > 86400000 ) { + await eas.network.getServerOptions(syncData); + } + + //only update the actual used asversion, if we are currently not connected or it has not yet been set + if (syncData.accountData.getAccountProperty("asversion", "") == "" || !syncData.accountData.isConnected()) { + //eval the currently in the UI selected EAS version + let asversionselected = syncData.accountData.getAccountProperty("asversionselected"); + let allowedVersionsString = syncData.accountData.getAccountProperty("allowedEasVersions").trim(); + let allowedVersionsArray = allowedVersionsString.split(","); + + if (asversionselected == "auto") { + if (allowedVersionsArray.includes("14.0")) syncData.accountData.setAccountProperty("asversion", "14.0"); + else if (allowedVersionsArray.includes("2.5")) syncData.accountData.setAccountProperty("asversion", "2.5"); + else if (allowedVersionsString == "") { + throw eas.sync.finish("error", "InvalidServerOptions"); + } else { + throw eas.sync.finish("error", "nosupportedeasversion::"+allowedVersionsArray.join(", ")); + } + } else if (allowedVersionsString != "" && !allowedVersionsArray.includes(asversionselected)) { + throw eas.sync.finish("error", "notsupportedeasversion::"+asversionselected+"::"+allowedVersionsArray.join(", ")); + } else { + //just use the value set by the user + syncData.accountData.setAccountProperty("asversion", asversionselected); + } + } + + //do we need to get a new policy key? + if (syncData.accountData.getAccountProperty("provision") && syncData.accountData.getAccountProperty("policykey") == "0") { + await eas.network.getPolicykey(syncData); + } + + //set device info + await eas.network.setDeviceInformation(syncData); + + syncData.setSyncState("prepare.request.folders"); + let foldersynckey = syncData.accountData.getAccountProperty("foldersynckey"); + + //build WBXML to request foldersync + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("FolderHierarchy"); + wbxml.otag("FolderSync"); + wbxml.atag("SyncKey", foldersynckey); + wbxml.ctag(); + + syncData.setSyncState("send.request.folders"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "FolderSync", syncData); + + syncData.setSyncState("eval.response.folders"); + let wbxmlData = eas.network.getDataFromResponse(response); + eas.network.checkStatus(syncData, wbxmlData,"FolderSync.Status"); + + let synckey = eas.xmltools.getWbxmlDataField(wbxmlData,"FolderSync.SyncKey"); + if (synckey) { + syncData.accountData.setAccountProperty("foldersynckey", synckey); + } else { + throw eas.sync.finish("error", "wbxmlmissingfield::FolderSync.SyncKey"); + } + + // If we reach this point, wbxmlData contains FolderSync node, + // so the next "if" will not fail with an javascript error, no need + // to use save getWbxmlDataField function. + + // Are there any changes in folder hierarchy? + if (wbxmlData.FolderSync.Changes) { + // Looking for additions. + let add = eas.xmltools.nodeAsArray(wbxmlData.FolderSync.Changes.Add); + for (let count = 0; count < add.length; count++) { + // Only add allowed folder types to DB (include trash(4), so we can find trashed folders. + if (!["9","14","8","13","7","15", "4"].includes(add[count].Type)) + continue; + + let existingFolder = syncData.accountData.getFolder("serverID", add[count].ServerId); + if (existingFolder) { + // Server has send us an ADD for a folder we alreay have, treat as update. + existingFolder.setFolderProperty("foldername", add[count].DisplayName); + existingFolder.setFolderProperty("type", add[count].Type); + existingFolder.setFolderProperty("parentID", add[count].ParentId); + } else { + // Create folder obj for new folder settings. + let newFolder = syncData.accountData.createNewFolder(); + switch (add[count].Type) { + case "9": // contact + case "14": + newFolder.setFolderProperty("targetType", "addressbook"); + break; + case "8": // event + case "13": + newFolder.setFolderProperty("targetType", "calendar"); + break; + case "7": // todo + case "15": + newFolder.setFolderProperty("targetType", "calendar"); + break; + default: + newFolder.setFolderProperty("targetType", "unknown type ("+add[count].Type+")"); + break; + + } + + newFolder.setFolderProperty("serverID", add[count].ServerId); + newFolder.setFolderProperty("foldername", add[count].DisplayName); + newFolder.setFolderProperty("type", add[count].Type); + newFolder.setFolderProperty("parentID", add[count].ParentId); + + // Do we have a cached folder? + let cachedFolderData = syncData.accountData.getFolderFromCache("serverID", add[count].ServerId); + if (cachedFolderData) { + // Copy fields from cache which we want to re-use. + newFolder.setFolderProperty("targetColor", cachedFolderData.getFolderProperty("targetColor")); + newFolder.setFolderProperty("targetName", cachedFolderData.getFolderProperty("targetName")); + newFolder.setFolderProperty("downloadonly", cachedFolderData.getFolderProperty("downloadonly")); + } + } + } + + // Looking for updates. + let update = eas.xmltools.nodeAsArray(wbxmlData.FolderSync.Changes.Update); + for (let count = 0; count < update.length; count++) { + let existingFolder = syncData.accountData.getFolder("serverID", update[count].ServerId); + if (existingFolder) { + // Update folder. + existingFolder.setFolderProperty("foldername", update[count].DisplayName); + existingFolder.setFolderProperty("type", update[count].Type); + existingFolder.setFolderProperty("parentID", update[count].ParentId); + } + } + + // Looking for deletes. Do not delete the targets, + // but keep them as stale/unconnected elements. + let del = eas.xmltools.nodeAsArray(wbxmlData.FolderSync.Changes.Delete); + for (let count = 0; count < del.length; count++) { + let existingFolder = syncData.accountData.getFolder("serverID", del[count].ServerId); + if (existingFolder) { + existingFolder.remove("[deleted on server]"); + } + } + } + }, + + + + + + deleteFolder: async function (syncData) { + if (!syncData.currentFolderData) { + return; + } + + if (!syncData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("FolderDelete")) { + throw eas.sync.finish("error", "notsupported::FolderDelete"); + } + + syncData.setSyncState("prepare.request.deletefolder"); + let foldersynckey = syncData.accountData.getAccountProperty("foldersynckey"); + + //request foldersync + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.switchpage("FolderHierarchy"); + wbxml.otag("FolderDelete"); + wbxml.atag("SyncKey", foldersynckey); + wbxml.atag("ServerId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.ctag(); + + syncData.setSyncState("send.request.deletefolder"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "FolderDelete", syncData); + + syncData.setSyncState("eval.response.deletefolder"); + let wbxmlData = eas.network.getDataFromResponse(response); + + eas.network.checkStatus(syncData, wbxmlData,"FolderDelete.Status"); + + let synckey = eas.xmltools.getWbxmlDataField(wbxmlData,"FolderDelete.SyncKey"); + if (synckey) { + syncData.accountData.setAccountProperty("foldersynckey", synckey); + syncData.currentFolderData.remove(); + } else { + throw eas.sync.finish("error", "wbxmlmissingfield::FolderDelete.SyncKey"); + } + }, + + + + + + singleFolder: async function (syncData) { + // add target to syncData + try { + // accessing the target for the first time will check if it is avail and if not will create it (if possible) + syncData.target = await syncData.currentFolderData.targetData.getTarget(); + } catch (e) { + Components.utils.reportError(e); + throw eas.sync.finish("warning", e.message); + } + + //get syncData type, which is also used in WBXML for the CLASS element + syncData.type = null; + switch (syncData.currentFolderData.getFolderProperty("type")) { + case "9": //contact + case "14": + syncData.type = "Contacts"; + break; + case "8": //event + case "13": + syncData.type = "Calendar"; + break; + case "7": //todo + case "15": + syncData.type = "Tasks"; + break; + default: + throw eas.sync.finish("info", "skipped"); + break; + } + + syncData.setSyncState("preparing"); + + //get synckey if needed + syncData.synckey = syncData.currentFolderData.getFolderProperty("synckey"); + if (syncData.synckey == "") { + await eas.network.getSynckey(syncData); + } + + //sync folder + syncData.timeOfLastSync = syncData.currentFolderData.getFolderProperty( "lastsynctime") / 1000; + syncData.timeOfThisSync = (Date.now() / 1000) - 1; + + let lightningBatch = false; + let lightningReadOnly = ""; + let error = null; + + // We ned to intercept any throw error, because lightning needs a few operations after sync finished + try { + switch (syncData.type) { + case "Contacts": + await eas.sync.easFolder(syncData); + break; + + case "Calendar": + case "Tasks": + //save current value of readOnly (or take it from the setting) + lightningReadOnly = syncData.target.calendar.getProperty("readOnly") || syncData.currentFolderData.getFolderProperty( "downloadonly"); + syncData.target.calendar.setProperty("readOnly", false); + + lightningBatch = true; + syncData.target.calendar.startBatch(); + + await eas.sync.easFolder(syncData); + break; + } + } catch (report) { + error = report; + } + + if (lightningBatch) { + syncData.target.calendar.endBatch(); + syncData.target.calendar.setProperty("readOnly", lightningReadOnly); + } + + if (error) throw error; + }, + + + + + + + + + + + // --------------------------------------------------------------------------- + // MAIN FUNCTIONS TO SYNC AN EAS FOLDER + // --------------------------------------------------------------------------- + + easFolder: async function (syncData) { + syncData.progressData.reset(); + + if (syncData.currentFolderData.getFolderProperty("downloadonly")) { + await eas.sync.revertLocalChanges(syncData); + } + + await eas.network.getItemEstimate(syncData); + await eas.sync.requestRemoteChanges(syncData); + + if (!syncData.currentFolderData.getFolderProperty("downloadonly")) { + let sendChanges = await eas.sync.sendLocalChanges(syncData); + if (sendChanges) { + // This is ugly as hell, but Microsoft sometimes sets the state of the + // remote account to "changed" after we have send a local change (even + // though it has acked the change) and this will cause the server to + // send a change request with our next sync. Because we follow the + // "server wins" policy, this will overwrite any additional local change + // we have done in the meantime. This is stupid, but we wait 2s and + // hope it is enough to catch this second ack of the local change. + let timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); + await new Promise(function(resolve, reject) { + let event = { + notify: function(timer) { + resolve(); + } + } + timer.initWithCallback(event, 2000, Components.interfaces.nsITimer.TYPE_ONE_SHOT); + }); + await eas.sync.requestRemoteChanges(syncData); + } + } + }, + + + requestRemoteChanges: async function (syncData) { + do { + syncData.setSyncState("prepare.request.remotechanges"); + syncData.request = ""; + syncData.response = ""; + + // BUILD WBXML + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.otag("Sync"); + wbxml.otag("Collections"); + wbxml.otag("Collection"); + if (syncData.accountData.getAccountProperty("asversion") == "2.5") wbxml.atag("Class", syncData.type); + wbxml.atag("SyncKey", syncData.synckey); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.atag("DeletesAsMoves"); + wbxml.atag("GetChanges"); + wbxml.atag("WindowSize", eas.prefs.getIntPref("maxitems").toString()); + + if (syncData.accountData.getAccountProperty("asversion") != "2.5") { + wbxml.otag("Options"); + if (syncData.type == "Calendar") wbxml.atag("FilterType", syncData.currentFolderData.accountData.getAccountProperty("synclimit")); + wbxml.atag("Class", syncData.type); + wbxml.switchpage("AirSyncBase"); + wbxml.otag("BodyPreference"); + wbxml.atag("Type", "1"); + wbxml.ctag(); + wbxml.switchpage("AirSync"); + wbxml.ctag(); + } else if (syncData.type == "Calendar") { //in 2.5 we only send it to filter Calendar + wbxml.otag("Options"); + wbxml.atag("FilterType", syncData.currentFolderData.accountData.getAccountProperty("synclimit")); + wbxml.ctag(); + } + + wbxml.ctag(); + wbxml.ctag(); + wbxml.ctag(); + + //SEND REQUEST + syncData.setSyncState("send.request.remotechanges"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Sync", syncData); + + //VALIDATE RESPONSE + // get data from wbxml response, some servers send empty response if there are no changes, which is not an error + let wbxmlData = eas.network.getDataFromResponse(response, eas.flags.allowEmptyResponse); + if (wbxmlData === null) return; + + //check status, throw on error + eas.network.checkStatus(syncData, wbxmlData,"Sync.Collections.Collection.Status"); + + //PROCESS COMMANDS + await eas.sync.processCommands(wbxmlData, syncData); + + //Update count in UI + syncData.setSyncState("eval.response.remotechanges"); + + //update synckey + eas.network.updateSynckey(syncData, wbxmlData); + + if (!eas.xmltools.hasWbxmlDataField(wbxmlData,"Sync.Collections.Collection.MoreAvailable")) { + //Feedback from users: They want to see the final count + await TbSync.tools.sleep(100); + return; + } + } while (true); + + }, + + + sendLocalChanges: async function (syncData) { + let maxnumbertosend = eas.prefs.getIntPref("maxitems"); + syncData.progressData.reset(0, syncData.target.getItemsFromChangeLog().length); + + //keep track of failed items + syncData.failedItems = []; + + let done = false; + let numberOfItemsToSend = maxnumbertosend; + let sendChanges = false; + do { + syncData.setSyncState("prepare.request.localchanges"); + syncData.request = ""; + syncData.response = ""; + + //get changed items from ChangeLog + let changes = syncData.target.getItemsFromChangeLog(numberOfItemsToSend); + //console.log("chnages", changes); + let c=0; + let e=0; + + //keep track of send items during this request + let changedItems = []; + let addedItems = {}; + let sendItems = []; + + // BUILD WBXML + let wbxml = eas.wbxmltools.createWBXML(); + wbxml.otag("Sync"); + wbxml.otag("Collections"); + wbxml.otag("Collection"); + if (syncData.accountData.getAccountProperty("asversion") == "2.5") wbxml.atag("Class", syncData.type); + wbxml.atag("SyncKey", syncData.synckey); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.otag("Commands"); + + for (let i=0; i<changes.length; i++) if (!syncData.failedItems.includes(changes[i].itemId)) { + //TbSync.dump("CHANGES",(i+1) + "/" + changes.length + " ("+changes[i].status+"," + changes[i].itemId + ")"); + let item = null; + switch (changes[i].status) { + + case "added_by_user": + item = await syncData.target.getItem(changes[i].itemId); + if (item) { + //filter out bad object types for this folder + if (syncData.type == "Contacts" && item.isMailList) { + // Mailing lists are not supported, this is not an error + TbSync.eventlog.add("warning", syncData.eventLogInfo, "MailingListNotSupportedItemSkipped"); + syncData.target.removeItemFromChangeLog(changes[i].itemId); + } else if (syncData.type == eas.sync.getEasItemType(item)) { + //create a temp clientId, to cope with too long or invalid clientIds (for EAS) + let clientId = Date.now() + "-" + c; + addedItems[clientId] = changes[i].itemId; + sendItems.push({type: changes[i].status, id: changes[i].itemId}); + + wbxml.otag("Add"); + wbxml.atag("ClientId", clientId); //Our temp clientId will get replaced by an id generated by the server + wbxml.otag("ApplicationData"); + wbxml.switchpage(syncData.type); + +/*wbxml.atag("TimeZone", "xP///0UAdQByAG8AcABlAC8AQgBlAHIAbABpAG4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAFAAIAAAAAAAAAAAAAAEUAdQByAG8AcABlAC8AQgBlAHIAbABpAG4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAFAAEAAAAAAAAAxP///w=="); +wbxml.atag("AllDayEvent", "0"); +wbxml.switchpage("AirSyncBase"); +wbxml.otag("Body"); + wbxml.atag("Type", "1"); + wbxml.atag("EstimatedDataSize", "0"); + wbxml.atag("Data"); +wbxml.ctag(); + +wbxml.switchpage(syncData.type); +wbxml.atag("BusyStatus", "2"); +wbxml.atag("OrganizerName", "REDACTED.REDACTED"); +wbxml.atag("OrganizerEmail", "REDACTED.REDACTED@REDACTED"); +wbxml.atag("DtStamp", "20190131T091024Z"); +wbxml.atag("EndTime", "20180906T083000Z"); +wbxml.atag("Location"); +wbxml.atag("Reminder", "5"); +wbxml.atag("Sensitivity", "0"); +wbxml.atag("Subject", "SE-CN weekly sync"); +wbxml.atag("StartTime", "20180906T080000Z"); +wbxml.atag("UID", "1D51E503-9DFE-4A46-A6C2-9129E5E00C1D"); +wbxml.atag("MeetingStatus", "3"); +wbxml.otag("Attendees"); + wbxml.otag("Attendee"); + wbxml.atag("Email", "REDACTED.REDACTED@REDACTED"); + wbxml.atag("Name", "REDACTED.REDACTED"); + wbxml.atag("AttendeeType", "1"); + wbxml.ctag(); +wbxml.ctag(); +wbxml.atag("Categories"); +wbxml.otag("Recurrence"); + wbxml.atag("Type", "1"); + wbxml.atag("DayOfWeek", "16"); + wbxml.atag("Interval", "1"); +wbxml.ctag(); +wbxml.otag("Exceptions"); + wbxml.otag("Exception"); + wbxml.atag("ExceptionStartTime", "20181227T090000Z"); + wbxml.atag("Deleted", "1"); + wbxml.ctag(); +wbxml.ctag();*/ + + wbxml.append(await eas.sync.getWbxmlFromThunderbirdItem(item, syncData)); + wbxml.switchpage("AirSync"); + wbxml.ctag(); + wbxml.ctag(); + c++; + } else { + eas.sync.updateFailedItems(syncData, "forbidden" + eas.sync.getEasItemType(item) +"ItemIn" + syncData.type + "Folder", item.primaryKey, item.toString()); + e++; + } + } else { + syncData.target.removeItemFromChangeLog(changes[i].itemId); + } + break; + + case "modified_by_user": + item = await syncData.target.getItem(changes[i].itemId); + if (item) { + //filter out bad object types for this folder + if (syncData.type == eas.sync.getEasItemType(item)) { + wbxml.otag("Change"); + wbxml.atag("ServerId", changes[i].itemId); + wbxml.otag("ApplicationData"); + wbxml.switchpage(syncData.type); + wbxml.append(await eas.sync.getWbxmlFromThunderbirdItem(item, syncData)); + wbxml.switchpage("AirSync"); + wbxml.ctag(); + wbxml.ctag(); + changedItems.push(changes[i].itemId); + sendItems.push({type: changes[i].status, id: changes[i].itemId}); + c++; + } else { + eas.sync.updateFailedItems(syncData, "forbidden" + eas.sync.getEasItemType(item) +"ItemIn" + syncData.type + "Folder", item.primaryKey, item.toString()); + e++; + } + } else { + syncData.target.removeItemFromChangeLog(changes[i].itemId); + } + break; + + case "deleted_by_user": + wbxml.otag("Delete"); + wbxml.atag("ServerId", changes[i].itemId); + wbxml.ctag(); + changedItems.push(changes[i].itemId); + sendItems.push({type: changes[i].status, id: changes[i].itemId}); + c++; + break; + } + } + + wbxml.ctag(); //Commands + wbxml.ctag(); //Collection + wbxml.ctag(); //Collections + wbxml.ctag(); //Sync + + + if (c > 0) { //if there was at least one actual local change, send request + sendChanges = true; + //SEND REQUEST & VALIDATE RESPONSE + syncData.setSyncState("send.request.localchanges"); + let response = await eas.network.sendRequest(wbxml.getBytes(), "Sync", syncData); + + syncData.setSyncState("eval.response.localchanges"); + + //get data from wbxml response + let wbxmlData = eas.network.getDataFromResponse(response); + + //check status and manually handle error states which support softfails + let errorcause = eas.network.checkStatus(syncData, wbxmlData, "Sync.Collections.Collection.Status", "", true); + switch (errorcause) { + case "": + break; + + case "Sync.4": //Malformed request + case "Sync.6": //Invalid item + //some servers send a global error - to catch this, we reduce the number of items we send to the server + if (sendItems.length == 1) { + //the request contained only one item, so we know which one failed + if (sendItems[0].type == "deleted_by_user") { + //we failed to delete an item, discard and place message in log + syncData.target.removeItemFromChangeLog(sendItems[0].id); + TbSync.eventlog.add("warning", syncData.eventLogInfo, "ErrorOnDelete::"+sendItems[0].id); + } else { + let foundItem = await syncData.target.getItem(sendItems[0].id); + if (foundItem) { + eas.sync.updateFailedItems(syncData, errorcause, foundItem.primaryKey, foundItem.toString()); + } else { + //should not happen + syncData.target.removeItemFromChangeLog(sendItems[0].id); + } + } + syncData.progressData.inc(); + //restore numberOfItemsToSend + numberOfItemsToSend = maxnumbertosend; + } else if (sendItems.length > 1) { + //reduce further + numberOfItemsToSend = Math.min(1, Math.round(sendItems.length / 5)); + } else { + //sendItems.length == 0 ??? recheck but this time let it handle all cases + eas.network.checkStatus(syncData, wbxmlData, "Sync.Collections.Collection.Status"); + } + break; + + default: + //recheck but this time let it handle all cases + eas.network.checkStatus(syncData, wbxmlData, "Sync.Collections.Collection.Status"); + } + + await TbSync.tools.sleep(10, true); + + if (errorcause == "") { + //PROCESS RESPONSE + await eas.sync.processResponses(wbxmlData, syncData, addedItems, changedItems); + + //PROCESS COMMANDS + await eas.sync.processCommands(wbxmlData, syncData); + + //remove all items from changelog that did not fail + for (let a=0; a < changedItems.length; a++) { + syncData.target.removeItemFromChangeLog(changedItems[a]); + syncData.progressData.inc(); + } + + //update synckey + eas.network.updateSynckey(syncData, wbxmlData); + } + + } else if (e==0) { //if there was no local change and also no error (which will not happen twice) finish + + done = true; + + } + + } while (!done); + + //was there an error? + if (syncData.failedItems.length > 0) { + throw eas.sync.finish("warning", "ServerRejectedSomeItems::" + syncData.failedItems.length); + } + return sendChanges; + }, + + + + + revertLocalChanges: async function (syncData) { + let maxnumbertosend = eas.prefs.getIntPref("maxitems"); + syncData.progressData.reset(0, syncData.target.getItemsFromChangeLog().length); + if (syncData.progressData.todo == 0) { + return; + } + + let viaItemOperations = (syncData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("ItemOperations")); + + //get changed items from ChangeLog + do { + syncData.setSyncState("prepare.request.revertlocalchanges"); + let changes = syncData.target.getItemsFromChangeLog(maxnumbertosend); + let c=0; + syncData.request = ""; + syncData.response = ""; + + // BUILD WBXML + let wbxml = eas.wbxmltools.createWBXML(); + if (viaItemOperations) { + wbxml.switchpage("ItemOperations"); + wbxml.otag("ItemOperations"); + } else { + wbxml.otag("Sync"); + wbxml.otag("Collections"); + wbxml.otag("Collection"); + if (syncData.accountData.getAccountProperty("asversion") == "2.5") wbxml.atag("Class", syncData.type); + wbxml.atag("SyncKey", syncData.synckey); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.otag("Commands"); + } + + for (let i=0; i<changes.length; i++) { + let item = null; + let ServerId = changes[i].itemId; + let foundItem = await syncData.target.getItem(ServerId); + + switch (changes[i].status) { + case "added_by_user": //remove + if (foundItem) { + await syncData.target.deleteItem(foundItem); + } + break; + + case "modified_by_user": + if (foundItem) { //delete item so it can be replaced with a fresh copy, the changelog entry will be changed from modified to deleted + await syncData.target.deleteItem(foundItem); + } + case "deleted_by_user": + if (viaItemOperations) { + wbxml.otag("Fetch"); + wbxml.atag("Store", "Mailbox"); + wbxml.switchpage("AirSync"); + wbxml.atag("CollectionId", syncData.currentFolderData.getFolderProperty("serverID")); + wbxml.atag("ServerId", ServerId); + wbxml.switchpage("ItemOperations"); + wbxml.otag("Options"); + wbxml.switchpage("AirSyncBase"); + wbxml.otag("BodyPreference"); + wbxml.atag("Type","1"); + wbxml.ctag(); + wbxml.switchpage("ItemOperations"); + wbxml.ctag(); + wbxml.ctag(); + } else { + wbxml.otag("Fetch"); + wbxml.atag("ServerId", ServerId); + wbxml.ctag(); + } + c++; + break; + } + } + + if (viaItemOperations) { + wbxml.ctag(); //ItemOperations + } else { + wbxml.ctag(); //Commands + wbxml.ctag(); //Collection + wbxml.ctag(); //Collections + wbxml.ctag(); //Sync + } + + if (c > 0) { //if there was at least one actual local change, send request + let error = false; + let wbxmlData = ""; + + //SEND REQUEST & VALIDATE RESPONSE + try { + syncData.setSyncState("send.request.revertlocalchanges"); + let response = await eas.network.sendRequest(wbxml.getBytes(), (viaItemOperations) ? "ItemOperations" : "Sync", syncData); + + syncData.setSyncState("eval.response.revertlocalchanges"); + + //get data from wbxml response + wbxmlData = eas.network.getDataFromResponse(response); + } catch (e) { + //we do not handle errors, IF there was an error, wbxmlData is empty and will trigger the fallback + } + + let fetchPath = (viaItemOperations) ? "ItemOperations.Response.Fetch" : "Sync.Collections.Collection.Responses.Fetch"; + if (eas.xmltools.hasWbxmlDataField(wbxmlData, fetchPath)) { + + //looking for additions + let add = eas.xmltools.nodeAsArray(eas.xmltools.getWbxmlDataField(wbxmlData, fetchPath)); + for (let count = 0; count < add.length; count++) { + await TbSync.tools.sleep(10, true); + + let ServerId = add[count].ServerId; + let data = (viaItemOperations) ? add[count].Properties : add[count].ApplicationData; + + if (data && ServerId) { + let foundItem = await syncData.target.getItem(ServerId); + if (!foundItem) { //do NOT add, if an item with that ServerId was found + let newItem = eas.sync.createItem(syncData); + try { + eas.sync[syncData.type].setThunderbirdItemFromWbxml(newItem, data, ServerId, syncData); + await syncData.target.addItem(newItem); + } catch (e) { + eas.xmltools.printXmlData(add[count], true); //include application data in log + TbSync.eventlog.add("warning", syncData.eventLogInfo, "BadItemSkipped::JavaScriptError", newItem.toString()); + throw e; // unable to add item to Thunderbird - fatal error + } + } else { + //should not happen, since we deleted that item beforehand + syncData.target.removeItemFromChangeLog(ServerId); + } + syncData.progressData.inc(); + } else { + error = true; + break; + } + } + } else { + error = true; + } + + if (error) { + //if ItemOperations.Fetch fails, fall back to Sync.Fetch, if that fails, fall back to resync + if (viaItemOperations) { + viaItemOperations = false; + TbSync.eventlog.add("info", syncData.eventLogInfo, "Server returned error during ItemOperations.Fetch, falling back to Sync.Fetch."); + } else { + await eas.sync.revertLocalChangesViaResync(syncData); + return; + } + } + + } else { //if there was no more local change we need to revert, return + + return; + + } + + } while (true); + + }, + + revertLocalChangesViaResync: async function (syncData) { + TbSync.eventlog.add("info", syncData.eventLogInfo, "Server does not support ItemOperations.Fetch and/or Sync.Fetch, must revert via resync."); + let changes = syncData.target.getItemsFromChangeLog(); + + syncData.progressData.reset(0, changes.length); + syncData.setSyncState("prepare.request.revertlocalchanges"); + + //remove all changes, so we can get them fresh from the server + for (let i=0; i<changes.length; i++) { + let item = null; + let ServerId = changes[i].itemId; + syncData.target.removeItemFromChangeLog(ServerId); + let foundItem = await syncData.target.getItem(ServerId); + if (foundItem) { //delete item with that ServerId + await syncData.target.deleteItem(foundItem); + } + syncData.progressData.inc(); + } + + //This will resync all missing items fresh from the server + TbSync.eventlog.add("info", syncData.eventLogInfo, "RevertViaFolderResync"); + eas.sync.resetFolderSyncInfo(syncData.currentFolderData); + throw eas.sync.finish("resyncFolder", "RevertViaFolderResync"); + }, + + + + + // --------------------------------------------------------------------------- + // SUB FUNCTIONS CALLED BY MAIN FUNCTION + // --------------------------------------------------------------------------- + + processCommands: async function (wbxmlData, syncData) { + //any commands for us to work on? If we reach this point, Sync.Collections.Collection is valid, + //no need to use the save getWbxmlDataField function + if (wbxmlData.Sync.Collections.Collection.Commands) { + + //looking for additions + let add = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Commands.Add); + for (let count = 0; count < add.length; count++) { + await TbSync.tools.sleep(10, true); + + let ServerId = add[count].ServerId; + let data = add[count].ApplicationData; + + let foundItem = await syncData.target.getItem(ServerId); + if (!foundItem) { + //do NOT add, if an item with that ServerId was found + let newItem = eas.sync.createItem(syncData); + try { + eas.sync[syncData.type].setThunderbirdItemFromWbxml(newItem, data, ServerId, syncData); + await syncData.target.addItem(newItem); + } catch (e) { + eas.xmltools.printXmlData(add[count], true); //include application data in log + TbSync.eventlog.add("warning", syncData.eventLogInfo, "BadItemSkipped::JavaScriptError", newItem.toString()); + throw e; // unable to add item to Thunderbird - fatal error + } + } else { + TbSync.eventlog.add("info", syncData.eventLogInfo, "Add request, but element exists already, skipped.", ServerId); + } + syncData.progressData.inc(); + } + + //looking for changes + let upd = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Commands.Change); + //inject custom change object for debug + //upd = JSON.parse('[{"ServerId":"2tjoanTeS0CJ3QTsq5vdNQAAAAABDdrY6Gp03ktAid0E7Kub3TUAAAoZy4A1","ApplicationData":{"DtStamp":"20171109T142149Z"}}]'); + for (let count = 0; count < upd.length; count++) { + await TbSync.tools.sleep(10, true); + + let ServerId = upd[count].ServerId; + let data = upd[count].ApplicationData; + + syncData.progressData.inc(); + let foundItem = await syncData.target.getItem(ServerId); + if (foundItem) { //only update, if an item with that ServerId was found + + let keys = Object.keys(data); + //replace by smart merge + if (keys.length == 1 && keys[0] == "DtStamp") TbSync.dump("DtStampOnly", keys); //ignore DtStamp updates (fix with smart merge) + else { + + if (foundItem.changelogStatus !== null) { + TbSync.eventlog.add("info", syncData.eventLogInfo, "Change request from server, but also local modifications, server wins!", ServerId); + foundItem.changelogStatus = null; + } + + let newItem = foundItem.clone(); + try { + eas.sync[syncData.type].setThunderbirdItemFromWbxml(newItem, data, ServerId, syncData); + await syncData.target.modifyItem(newItem, foundItem); + } catch (e) { + TbSync.eventlog.add("warning", syncData.eventLogInfo, "BadItemSkipped::JavaScriptError", newItem.toString()); + eas.xmltools.printXmlData(upd[count], true); //include application data in log + throw e; // unable to mod item to Thunderbird - fatal error + } + } + + } + } + + //looking for deletes + let del = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Commands.Delete).concat(eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Commands.SoftDelete)); + for (let count = 0; count < del.length; count++) { + await TbSync.tools.sleep(10, true); + + let ServerId = del[count].ServerId; + + let foundItem = await syncData.target.getItem(ServerId); + if (foundItem) { //delete item with that ServerId + await syncData.target.deleteItem(foundItem); + } + syncData.progressData.inc(); + } + + } + }, + + + updateFailedItems: function (syncData, cause, id, data) { + //something is wrong with this item, move it to the end of changelog and go on + if (!syncData.failedItems.includes(id)) { + //the extra parameter true will re-add the item to the end of the changelog + syncData.target.removeItemFromChangeLog(id, true); + syncData.failedItems.push(id); + TbSync.eventlog.add("info", syncData.eventLogInfo, "BadItemSkipped::" + TbSync.getString("status." + cause ,"eas"), "\n\nRequest:\n" + syncData.request + "\n\nResponse:\n" + syncData.response + "\n\nElement:\n" + data); + } + }, + + + processResponses: async function (wbxmlData, syncData, addedItems, changedItems) { + //any responses for us to work on? If we reach this point, Sync.Collections.Collection is valid, + //no need to use the save getWbxmlDataField function + if (wbxmlData.Sync.Collections.Collection.Responses) { + + //looking for additions (Add node contains, status, old ClientId and new ServerId) + let add = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Responses.Add); + for (let count = 0; count < add.length; count++) { + await TbSync.tools.sleep(10, true); + + //get the true Thunderbird UID of this added item (we created a temp clientId during add) + add[count].ClientId = addedItems[add[count].ClientId]; + + //look for an item identfied by ClientId and update its id to the new id received from the server + let foundItem = await syncData.target.getItem(add[count].ClientId); + if (foundItem) { + + //Check status, stop sync if bad, allow soft fail + let errorcause = eas.network.checkStatus(syncData, add[count],"Status","Sync.Collections.Collection.Responses.Add["+count+"].Status", true); + if (errorcause !== "") { + //something is wrong with this item, move it to the end of changelog and go on + eas.sync.updateFailedItems(syncData, errorcause, foundItem.primaryKey, foundItem.toString()); + } else { + let newItem = foundItem.clone(); + newItem.primaryKey = add[count].ServerId; + syncData.target.removeItemFromChangeLog(add[count].ClientId); + await syncData.target.modifyItem(newItem, foundItem); + syncData.progressData.inc(); + } + + } + } + + //looking for modifications + let upd = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Responses.Change); + for (let count = 0; count < upd.length; count++) { + let foundItem = await syncData.target.getItem(upd[count].ServerId); + if (foundItem) { + + //Check status, stop sync if bad, allow soft fail + let errorcause = eas.network.checkStatus(syncData, upd[count],"Status","Sync.Collections.Collection.Responses.Change["+count+"].Status", true); + if (errorcause !== "") { + //something is wrong with this item, move it to the end of changelog and go on + eas.sync.updateFailedItems(syncData, errorcause, foundItem.primaryKey, foundItem.toString()); + //also remove from changedItems + let p = changedItems.indexOf(upd[count].ServerId); + if (p>-1) changedItems.splice(p,1); + } + + } + } + + //looking for deletions + let del = eas.xmltools.nodeAsArray(wbxmlData.Sync.Collections.Collection.Responses.Delete); + for (let count = 0; count < del.length; count++) { + //What can we do about failed deletes? SyncLog + eas.network.checkStatus(syncData, del[count],"Status","Sync.Collections.Collection.Responses.Delete["+count+"].Status", true); + } + + } + }, + + + + + + + + + + + // --------------------------------------------------------------------------- + // HELPER FUNCTIONS AND DEFINITIONS + // --------------------------------------------------------------------------- + + MAP_EAS2TB : { + //EAS Importance: 0 = LOW | 1 = NORMAL | 2 = HIGH + Importance : { "0":"9", "1":"5", "2":"1"}, //to PRIORITY + //EAS Sensitivity : 0 = Normal | 1 = Personal | 2 = Private | 3 = Confidential + Sensitivity : { "0":"PUBLIC", "1":"PRIVATE", "2":"PRIVATE", "3":"CONFIDENTIAL"}, //to CLASS + //EAS BusyStatus: 0 = Free | 1 = Tentative | 2 = Busy | 3 = Work | 4 = Elsewhere + BusyStatus : {"0":"TRANSPARENT", "1":"unset", "2":"OPAQUE", "3":"OPAQUE", "4":"OPAQUE"}, //to TRANSP + //EAS AttendeeStatus: 0 =Response unknown (but needed) | 2 = Tentative | 3 = Accept | 4 = Decline | 5 = Not responded (and not needed) || 1 = Organizer in ResponseType + ATTENDEESTATUS : {"0": "NEEDS-ACTION", "1":"Orga", "2":"TENTATIVE", "3":"ACCEPTED", "4":"DECLINED", "5":"ACCEPTED"}, + }, + + MAP_TB2EAS : { + //TB PRIORITY: 9 = LOW | 5 = NORMAL | 1 = HIGH + PRIORITY : { "9":"0", "5":"1", "1":"2","unset":"1"}, //to Importance + //TB CLASS: PUBLIC, PRIVATE, CONFIDENTIAL) + CLASS : { "PUBLIC":"0", "PRIVATE":"2", "CONFIDENTIAL":"3", "unset":"0"}, //to Sensitivity + //TB TRANSP : free = TRANSPARENT, busy = OPAQUE) + TRANSP : {"TRANSPARENT":"0", "unset":"1", "OPAQUE":"2"}, // to BusyStatus + //TB STATUS: NEEDS-ACTION, ACCEPTED, DECLINED, TENTATIVE, (DELEGATED, COMPLETED, IN-PROCESS - for todo) + ATTENDEESTATUS : {"NEEDS-ACTION":"0", "ACCEPTED":"3", "DECLINED":"4", "TENTATIVE":"2", "DELEGATED":"5","COMPLETED":"5", "IN-PROCESS":"5"}, + }, + + mapEasPropertyToThunderbird : function (easProp, tbProp, data, item) { + if (data[easProp]) { + //store original EAS value + let easPropValue = eas.xmltools.checkString(data[easProp]); + item.setProperty("X-EAS-" + easProp, easPropValue); + //map EAS value to TB value (use setCalItemProperty if there is one option which can unset/delete the property) + eas.tools.setCalItemProperty(item, tbProp, eas.sync.MAP_EAS2TB[easProp][easPropValue]); + } + }, + + mapThunderbirdPropertyToEas: function (tbProp, easProp, item) { + if (item.hasProperty("X-EAS-" + easProp) && eas.tools.getCalItemProperty(item, tbProp) == eas.sync.MAP_EAS2TB[easProp][item.getProperty("X-EAS-" + easProp)]) { + //we can use our stored EAS value, because it still maps to the current TB value + return item.getProperty("X-EAS-" + easProp); + } else { + return eas.sync.MAP_TB2EAS[tbProp][eas.tools.getCalItemProperty(item, tbProp)]; + } + }, + + getEasItemType(aItem) { + if (aItem instanceof TbSync.addressbook.AbItem) { + return "Contacts"; + } else if (aItem instanceof TbSync.lightning.TbItem) { + return aItem.isTodo ? "Tasks" : "Calendar"; + } else { + throw "Unknown aItem."; + } + }, + + createItem(syncData) { + switch (syncData.type) { + case "Contacts": + return syncData.target.createNewCard(); + break; + + case "Tasks": + return syncData.target.createNewTodo(); + break; + + case "Calendar": + return syncData.target.createNewEvent(); + break; + + default: + throw "Unknown item type <" + syncData.type + ">"; + } + }, + + async getWbxmlFromThunderbirdItem(item, syncData, isException = false) { + try { + let wbxml = await eas.sync[syncData.type].getWbxmlFromThunderbirdItem(item, syncData, isException); + return wbxml; + } catch (e) { + TbSync.eventlog.add("warning", syncData.eventLogInfo, "BadItemSkipped::JavaScriptError", item.toString()); + throw e; // unable to read item from Thunderbird - fatal error + } + }, + + + + + + + + // --------------------------------------------------------------------------- + // LIGHTNING HELPER FUNCTIONS AND DEFINITIONS + // These functions are needed only by tasks and events, so they + // are placed here, even though they are not type independent, + // but I did not want to add another "lightning" sub layer. + // + // The item in these functions is a native lightning item. + // --------------------------------------------------------------------------- + + setItemSubject: function (item, syncData, data) { + if (data.Subject) item.title = eas.xmltools.checkString(data.Subject); + }, + + setItemLocation: function (item, syncData, data) { + if (data.Location) item.setProperty("location", eas.xmltools.checkString(data.Location)); + }, + + + setItemCategories: function (item, syncData, data) { + if (data.Categories && data.Categories.Category) { + let cats = []; + if (Array.isArray(data.Categories.Category)) cats = data.Categories.Category; + else cats.push(data.Categories.Category); + item.setCategories(cats); + } + }, + + getItemCategories: function (item, syncData) { + let asversion = syncData.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncData.type); //init wbxml with "" and not with precodes, also activate type codePage (Calendar, Tasks, Contacts etc) + + //to properly "blank" categories, we need to always include the container + let categories = item.getCategories({}); + if (categories.length > 0) { + wbxml.otag("Categories"); + for (let i=0; i<categories.length; i++) wbxml.atag("Category", categories[i]); + wbxml.ctag(); + } else { + wbxml.atag("Categories"); + } + return wbxml.getBytes(); + }, + + + setItemBody: function (item, syncData, data) { + let asversion = syncData.accountData.getAccountProperty("asversion"); + if (asversion == "2.5") { + if (data.Body) item.setProperty("description", eas.xmltools.checkString(data.Body)); + } else { + if (data.Body && /* data.Body.EstimatedDataSize > 0 && */ data.Body.Data) item.setProperty("description", eas.xmltools.checkString(data.Body.Data)); //EstimatedDataSize is optional + } + }, + + getItemBody: function (item, syncData) { + let asversion = syncData.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncData.type); //init wbxml with "" and not with precodes, also activate type codePage (Calendar, Tasks, Contacts etc) + + let description = (item.hasProperty("description")) ? item.getProperty("description") : ""; + if (asversion == "2.5") { + wbxml.atag("Body", description); + } else { + wbxml.switchpage("AirSyncBase"); + wbxml.otag("Body"); + wbxml.atag("Type", "1"); + wbxml.atag("EstimatedDataSize", "" + description.length); + wbxml.atag("Data", description); + wbxml.ctag(); + //does not work with horde at the moment, does not work with task, does not work with exceptions + //if (syncData.accountData.getAccountProperty("horde") == "0") wbxml.atag("NativeBodyType", "1"); + + //return to code page of this type + wbxml.switchpage(syncData.type); + } + return wbxml.getBytes(); + }, + + //item is a native lightning item + setItemRecurrence: function (item, syncData, data, timezone) { + if (data.Recurrence) { + item.recurrenceInfo = new CalRecurrenceInfo(); + item.recurrenceInfo.item = item; + let recRule = TbSync.lightning.cal.createRecurrenceRule(); + switch (data.Recurrence.Type) { + case "0": + recRule.type = "DAILY"; + break; + case "1": + recRule.type = "WEEKLY"; + break; + case "2": + case "3": + recRule.type = "MONTHLY"; + break; + case "5": + case "6": + recRule.type = "YEARLY"; + break; + } + + if (data.Recurrence.CalendarType) { + // TODO + } + if (data.Recurrence.DayOfMonth) { + recRule.setComponent("BYMONTHDAY", [data.Recurrence.DayOfMonth]); + } + if (data.Recurrence.DayOfWeek) { + let DOW = data.Recurrence.DayOfWeek; + if (DOW == 127 && (recRule.type == "MONTHLY" || recRule.type == "YEARLY")) { + recRule.setComponent("BYMONTHDAY", [-1]); + } + else { + let days = []; + for (let i = 0; i < 7; ++i) { + if (DOW & 1 << i) days.push(i + 1); + } + if (data.Recurrence.WeekOfMonth) { + for (let i = 0; i < days.length; ++i) { + if (data.Recurrence.WeekOfMonth == 5) { + days[i] = -1 * (days[i] + 8); + } + else { + days[i] += 8 * (data.Recurrence.WeekOfMonth - 0); + } + } + } + recRule.setComponent("BYDAY", days); + } + } + if (data.Recurrence.FirstDayOfWeek) { + //recRule.setComponent("WKST", [data.Recurrence.FirstDayOfWeek]); // WKST is not a valid component + //recRule.weekStart = data.Recurrence.FirstDayOfWeek; // - (NS_ERROR_NOT_IMPLEMENTED) [calIRecurrenceRule.weekStart] + TbSync.eventlog.add("info", syncData.eventLogInfo, "FirstDayOfWeek tag ignored (not supported).", item.icalString); + } + + if (data.Recurrence.Interval) { + recRule.interval = data.Recurrence.Interval; + } + if (data.Recurrence.IsLeapMonth) { + // TODO + } + if (data.Recurrence.MonthOfYear) { + recRule.setComponent("BYMONTH", [data.Recurrence.MonthOfYear]); + } + if (data.Recurrence.Occurrences) { + recRule.count = data.Recurrence.Occurrences; + } + if (data.Recurrence.Until) { + //time string could be in compact/basic or extended form of ISO 8601, + //cal.createDateTime only supports compact/basic, our own method takes both styles + recRule.untilDate = eas.tools.createDateTime(data.Recurrence.Until); + } + if (data.Recurrence.Start) { + TbSync.eventlog.add("info", syncData.eventLogInfo, "Start tag in recurring task is ignored, recurrence will start with first entry.", item.icalString); + } + + item.recurrenceInfo.insertRecurrenceItemAt(recRule, 0); + + if (data.Exceptions && syncData.type == "Calendar") { // only events, tasks cannot have exceptions + // Exception could be an object or an array of objects + let exceptions = [].concat(data.Exceptions.Exception); + for (let exception of exceptions) { + //exception.ExceptionStartTime is in UTC, but the Recurrence Object is in local timezone + let dateTime = TbSync.lightning.cal.createDateTime(exception.ExceptionStartTime).getInTimezone(timezone); + if (data.AllDayEvent == "1") { + dateTime.isDate = true; + // Pass to replacement event unless overriden + if (!exception.AllDayEvent) { + exception.AllDayEvent = "1"; + } + } + if (exception.Deleted == "1") { + item.recurrenceInfo.removeOccurrenceAt(dateTime); + } + else { + let replacement = item.recurrenceInfo.getOccurrenceFor(dateTime); + // replacement is a native lightning item, so we can access its id via .id + eas.sync[syncData.type].setThunderbirdItemFromWbxml(replacement, exception, replacement.id, syncData, "recurrence"); + // Reminders should carry over from parent, but setThunderbirdItemFromWbxml clears all alarms + if (!exception.Reminder && item.getAlarms({}).length) { + replacement.addAlarm(item.getAlarms({})[0]); + } + // Removing a reminder requires EAS 16.0 + item.recurrenceInfo.modifyException(replacement, true); + } + } + } + } + }, + + getItemRecurrence: async function (item, syncData, localStartDate = null) { + let asversion = syncData.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncData.type); //init wbxml with "" and not with precodes, also activate type codePage (Calendar, Tasks etc) + + if (item.recurrenceInfo && (syncData.type == "Calendar" || syncData.type == "Tasks")) { + let deleted = []; + let hasRecurrence = false; + let startDate = (syncData.type == "Calendar") ? item.startDate : item.entryDate; + + for (let recRule of item.recurrenceInfo.getRecurrenceItems({})) { + if (recRule.date) { + if (recRule.isNegative) { + // EXDATE + deleted.push(recRule); + } + else { + // RDATE + TbSync.eventlog.add("info", syncData.eventLogInfo, "Ignoring RDATE rule (not supported)", recRule.icalString); + } + continue; + } + if (recRule.isNegative) { + // EXRULE + TbSync.eventlog.add("info", syncData.eventLogInfo, "Ignoring EXRULE rule (not supported)", recRule.icalString); + continue; + } + + // RRULE + wbxml.otag("Recurrence"); + hasRecurrence = true; + + let type = 0; + let monthDays = recRule.getComponent("BYMONTHDAY", {}); + let weekDays = recRule.getComponent("BYDAY", {}); + let months = recRule.getComponent("BYMONTH", {}); + let weeks = []; + + // Unpack 1MO style days + for (let i = 0; i < weekDays.length; ++i) { + if (weekDays[i] > 8) { + weeks[i] = Math.floor(weekDays[i] / 8); + weekDays[i] = weekDays[i] % 8; + } + else if (weekDays[i] < -8) { + // EAS only supports last week as a special value, treat + // all as last week or assume every month has 5 weeks? + // Change to last week + //weeks[i] = 5; + // Assumes 5 weeks per month for week <= -2 + weeks[i] = 6 - Math.floor(-weekDays[i] / 8); + weekDays[i] = -weekDays[i] % 8; + } + } + if (monthDays[0] && monthDays[0] == -1) { + weeks = [5]; + weekDays = [1, 2, 3, 4, 5, 6, 7]; // 127 + monthDays[0] = null; + } + // Type + if (recRule.type == "WEEKLY") { + type = 1; + if (!weekDays.length) { + weekDays = [startDate.weekday + 1]; + } + } + else if (recRule.type == "MONTHLY" && weeks.length) { + type = 3; + } + else if (recRule.type == "MONTHLY") { + type = 2; + if (!monthDays.length) { + monthDays = [startDate.day]; + } + } + else if (recRule.type == "YEARLY" && weeks.length) { + type = 6; + } + else if (recRule.type == "YEARLY") { + type = 5; + if (!monthDays.length) { + monthDays = [startDate.day]; + } + if (!months.length) { + months = [startDate.month + 1]; + } + } + wbxml.atag("Type", type.toString()); + + //Tasks need a Start tag, but we cannot allow a start date different from the start of the main item (thunderbird does not support that) + if (localStartDate) wbxml.atag("Start", localStartDate); + + // TODO: CalendarType: 14.0 and up + // DayOfMonth + if (monthDays[0]) { + // TODO: Multiple days of month - multiple Recurrence tags? + wbxml.atag("DayOfMonth", monthDays[0].toString()); + } + // DayOfWeek + if (weekDays.length) { + let bitfield = 0; + for (let day of weekDays) { + bitfield |= 1 << (day - 1); + } + wbxml.atag("DayOfWeek", bitfield.toString()); + } + // FirstDayOfWeek: 14.1 and up + //wbxml.atag("FirstDayOfWeek", recRule.weekStart); - (NS_ERROR_NOT_IMPLEMENTED) [calIRecurrenceRule.weekStart] + // Interval + wbxml.atag("Interval", recRule.interval.toString()); + // TODO: IsLeapMonth: 14.0 and up + // MonthOfYear + if (months.length) { + wbxml.atag("MonthOfYear", months[0].toString()); + } + // Occurrences + if (recRule.isByCount) { + wbxml.atag("Occurrences", recRule.count.toString()); + } + // Until + else if (recRule.untilDate != null) { + //Events need the Until data in compact form, Tasks in the basic form + wbxml.atag("Until", eas.tools.getIsoUtcString(recRule.untilDate, (syncData.type == "Tasks"))); + } + // WeekOfMonth + if (weeks.length) { + wbxml.atag("WeekOfMonth", weeks[0].toString()); + } + wbxml.ctag(); + } + + if (syncData.type == "Calendar" && hasRecurrence) { //Exceptions only allowed in Calendar and only if a valid Recurrence was added + let modifiedIds = item.recurrenceInfo.getExceptionIds({}); + if (deleted.length || modifiedIds.length) { + wbxml.otag("Exceptions"); + for (let exception of deleted) { + wbxml.otag("Exception"); + wbxml.atag("ExceptionStartTime", eas.tools.getIsoUtcString(exception.date)); + wbxml.atag("Deleted", "1"); + //Docs say it is allowed, but if present, it does not work + //if (asversion == "2.5") { + // wbxml.atag("UID", item.id); //item.id is not valid, use UID or primaryKey + //} + wbxml.ctag(); + } + for (let exceptionId of modifiedIds) { + let replacement = item.recurrenceInfo.getExceptionFor(exceptionId); + wbxml.otag("Exception"); + wbxml.atag("ExceptionStartTime", eas.tools.getIsoUtcString(exceptionId)); + wbxml.append(await eas.sync.getWbxmlFromThunderbirdItem(replacement, syncData, true)); + wbxml.ctag(); + } + wbxml.ctag(); + } + } + } + + return wbxml.getBytes(); + } + +} diff --git a/content/includes/tasksync.js b/content/includes/tasksync.js new file mode 100644 index 0000000..e703152 --- /dev/null +++ b/content/includes/tasksync.js @@ -0,0 +1,211 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +var Tasks = { + + // --------------------------------------------------------------------------- // + // Read WBXML and set Thunderbird item + // --------------------------------------------------------------------------- // + setThunderbirdItemFromWbxml: function (tbItem, data, id, syncdata, mode = "standard") { + + let item = tbItem instanceof TbSync.lightning.TbItem ? tbItem.nativeItem : tbItem; + + let asversion = syncdata.accountData.getAccountProperty("asversion"); + item.id = id; + eas.sync.setItemSubject(item, syncdata, data); + if (TbSync.prefs.getIntPref("log.userdatalevel") > 2) TbSync.dump("Processing " + mode + " task item", item.title + " (" + id + ")"); + + eas.sync.setItemBody(item, syncdata, data); + eas.sync.setItemCategories(item, syncdata, data); + eas.sync.setItemRecurrence(item, syncdata, data); + + let dueDate = null; + if (data.DueDate && data.UtcDueDate) { + //extract offset from EAS data + let DueDate = new Date(data.DueDate); + let UtcDueDate = new Date(data.UtcDueDate); + let offset = (UtcDueDate.getTime() - DueDate.getTime())/60000; + + //timezone is identified by its offset + let utc = cal.createDateTime(eas.tools.dateToBasicISOString(UtcDueDate)); //format "19800101T000000Z" - UTC + dueDate = utc.getInTimezone(eas.tools.guessTimezoneByCurrentOffset(offset, utc)); + item.dueDate = dueDate; + } + + if (data.StartDate && data.UtcStartDate) { + //extract offset from EAS data + let StartDate = new Date(data.StartDate); + let UtcStartDate = new Date(data.UtcStartDate); + let offset = (UtcStartDate.getTime() - StartDate.getTime())/60000; + + //timezone is identified by its offset + let utc = cal.createDateTime(eas.tools.dateToBasicISOString(UtcStartDate)); //format "19800101T000000Z" - UTC + item.entryDate = utc.getInTimezone(eas.tools.guessTimezoneByCurrentOffset(offset, utc)); + } else { + //there is no start date? if this is a recurring item, we MUST add an entryDate, otherwise Thunderbird will not display the recurring items + if (data.Recurrence) { + if (dueDate) { + item.entryDate = dueDate; + TbSync.eventlog.add("info", syncdata, "Copy task dueData to task startDate, because Thunderbird needs a startDate for recurring items.", item.icalString); + } else { + TbSync.eventlog.add("info", syncdata, "Task without startDate and without dueDate but with recurrence info is not supported by Thunderbird. Recurrence will be lost.", item.icalString); + } + } + } + + eas.sync.mapEasPropertyToThunderbird ("Sensitivity", "CLASS", data, item); + eas.sync.mapEasPropertyToThunderbird ("Importance", "PRIORITY", data, item); + + let msTodoCompat = eas.prefs.getBoolPref("msTodoCompat"); + + item.clearAlarms(); + if (data.ReminderSet && data.ReminderTime) { + let UtcAlarmDate = eas.tools.createDateTime(data.ReminderTime); + let alarm = new CalAlarm(); + alarm.action = "DISPLAY"; + + if (msTodoCompat) + { + // Microsoft To-Do only uses due dates (no start dates) an doesn't have a time part in the due date + // dirty hack: Use the reminder date as due date and set a reminder exactly to the due date + // drawback: only works if due date and reminder is set to the same day - this could maybe checked here but I don't know how + item.entryDate = UtcAlarmDate; + item.dueDate = UtcAlarmDate; + alarm.related = Components.interfaces.calIAlarm.ALARM_RELATED_START; + alarm.offset = TbSync.lightning.cal.createDuration(); + alarm.offset.inSeconds = 0; + } + else if (data.UtcStartDate) + { + let UtcDate = eas.tools.createDateTime(data.UtcStartDate); + alarm.related = Components.interfaces.calIAlarm.ALARM_RELATED_START; + alarm.offset = UtcAlarmDate.subtractDate(UtcDate); + } + else + { + // Alternative solution for Microsoft To-Do: + // alarm correctly set but because time part of due date is always "0:00", all tasks for today are shown as overdue + alarm.related = Components.interfaces.calIAlarm.ALARM_RELATED_ABSOLUTE; + alarm.alarmDate = UtcAlarmDate; + } + item.addAlarm(alarm); + } + + //status/percentage cannot be mapped + if (data.Complete) { + if (data.Complete == "0") { + item.isCompleted = false; + } else { + item.isCompleted = true; + if (data.DateCompleted) item.completedDate = eas.tools.createDateTime(data.DateCompleted); + } + } + }, + +/* + Regenerate: After complete, the completed task is removed from the series and stored as an new entry. The series starts an week (as set) after complete date with one less occurence + + */ + + + + + + + + // --------------------------------------------------------------------------- // + //read TB event and return its data as WBXML + // --------------------------------------------------------------------------- // + getWbxmlFromThunderbirdItem: async function (tbItem, syncdata) { + let item = tbItem instanceof TbSync.lightning.TbItem ? tbItem.nativeItem : tbItem; + + let asversion = syncdata.accountData.getAccountProperty("asversion"); + let wbxml = eas.wbxmltools.createWBXML("", syncdata.type); //init wbxml with "" and not with precodes, and set initial codepage + + //Order of tags taken from: https://msdn.microsoft.com/en-us/library/dn338924(v=exchg.80).aspx + + //Subject + wbxml.atag("Subject", (item.title) ? item.title : ""); + + //Body + wbxml.append(eas.sync.getItemBody(item, syncdata)); + + //Importance + wbxml.atag("Importance", eas.sync.mapThunderbirdPropertyToEas("PRIORITY", "Importance", item)); + + //tasks is using extended ISO 8601 (2019-01-18T00:00:00.000Z) instead of basic (20190118T000000Z), + //eas.tools.getIsoUtcString returns extended if true as second parameter is present + + // TB will enforce a StartDate if it has a recurrence + let localStartDate = null; + if (item.entryDate) { + wbxml.atag("UtcStartDate", eas.tools.getIsoUtcString(item.entryDate, true)); + //to fake the local time as UTC, getIsoUtcString needs the third parameter to be true + localStartDate = eas.tools.getIsoUtcString(item.entryDate, true, true); + wbxml.atag("StartDate", localStartDate); + } + + // Tasks without DueDate are breaking O365 - use StartDate as DueDate + if (item.entryDate || item.dueDate) { + wbxml.atag("UtcDueDate", eas.tools.getIsoUtcString(item.dueDate ? item.dueDate : item.entryDate, true)); + //to fake the local time as UTC, getIsoUtcString needs the third parameter to be true + wbxml.atag("DueDate", eas.tools.getIsoUtcString(item.dueDate ? item.dueDate : item.entryDate, true, true)); + } + + //Categories + wbxml.append(eas.sync.getItemCategories(item, syncdata)); + + //Recurrence (only if localStartDate has been set) + if (localStartDate) wbxml.append(await eas.sync.getItemRecurrence(item, syncdata, localStartDate)); + + //Complete + if (item.isCompleted) { + wbxml.atag("Complete", "1"); + wbxml.atag("DateCompleted", eas.tools.getIsoUtcString(item.completedDate, true)); + } else { + wbxml.atag("Complete", "0"); + } + + //Sensitivity + wbxml.atag("Sensitivity", eas.sync.mapThunderbirdPropertyToEas("CLASS", "Sensitivity", item)); + + //ReminderTime and ReminderSet + let alarms = item.getAlarms({}); + if (alarms.length>0 && (item.entryDate || item.dueDate)) { + let reminderTime; + if (alarms[0].offset) { + //create Date obj from entryDate by converting item.entryDate to an extended UTC ISO string, which can be parsed by Date + //if entryDate is missing, the startDate of this object is set to its dueDate + let UtcDate = new Date(eas.tools.getIsoUtcString(item.entryDate ? item.entryDate : item.dueDate, true)); + //add offset + UtcDate.setSeconds(UtcDate.getSeconds() + alarms[0].offset.inSeconds); + reminderTime = UtcDate.toISOString(); + } else { + reminderTime = eas.tools.getIsoUtcString(alarms[0].alarmDate, true); + } + wbxml.atag("ReminderTime", reminderTime); + wbxml.atag("ReminderSet", "1"); + } else { + wbxml.atag("ReminderSet", "0"); + } + + return wbxml.getBytes(); + }, +} diff --git a/content/includes/tools.js b/content/includes/tools.js new file mode 100644 index 0000000..a2298b9 --- /dev/null +++ b/content/includes/tools.js @@ -0,0 +1,529 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + +var tools = { + + setCalItemProperty: function (item, prop, value) { + if (value == "unset") item.deleteProperty(prop); + else item.setProperty(prop, value); + }, + + getCalItemProperty: function (item, prop) { + if (item.hasProperty(prop)) return item.getProperty(prop); + else return "unset"; + }, + + isString: function (s) { + return (typeof s == 'string' || s instanceof String); + }, + + getIdentityKey: function (email) { + for (let account of MailServices.accounts.accounts) { + if (account.defaultIdentity && account.defaultIdentity.email == email) return account.defaultIdentity.key; + } + return ""; + }, + + parentIsTrash: function (folderData) { + let parentID = folderData.getFolderProperty("parentID"); + if (parentID == "0") return false; + + let parentFolder = folderData.accountData.getFolder("serverID", parentID); + if (parentFolder && parentFolder.getFolderProperty("type") == "4") return true; + + return false; + }, + + getNewDeviceId: function () { + //taken from https://jsfiddle.net/briguy37/2MVFd/ + let d = new Date().getTime(); + let uuid = 'xxxxxxxxxxxxxxxxyxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + let r = (d + Math.random()*16)%16 | 0; + d = Math.floor(d/16); + return (c=='x' ? r : (r&0x3|0x8)).toString(16); + }); + return "MZTB" + uuid; + }, + + getUriFromDirectoryId: function(ownerId) { + let directories = MailServices.ab.directories; + for (let directory of directories) { + if (directory instanceof Components.interfaces.nsIAbDirectory) { + if (ownerId.startsWith(directory.dirPrefId)) return directory.URI; + } + } + return null; + }, + + //function to get correct uri of current card for global book as well for mailLists + getSelectedUri : function(aUri, aCard) { + if (aUri == "moz-abdirectory://?") { + //get parent via card owner + return eas.tools.getUriFromDirectoryId(aCard.directoryId); + } else if (MailServices.ab.getDirectory(aUri).isMailList) { + //MailList suck, we have to cut the url to get the parent + return aUri.substring(0, aUri.lastIndexOf("/")) + } else { + return aUri; + } + }, + + //read file from within the XPI package + fetchFile: function (aURL, returnType = "Array") { + return new Promise((resolve, reject) => { + let uri = Services.io.newURI(aURL); + let channel = Services.io.newChannelFromURI(uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Components.interfaces.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT, + Components.interfaces.nsIContentPolicy.TYPE_OTHER); + + NetUtil.asyncFetch(channel, (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + reject(status); + return; + } + + try { + let data = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + if (returnType == "Array") { + resolve(data.replace("\r","").split("\n")) + } else { + resolve(data); + } + } catch (ex) { + reject(ex); + } + }); + }); + }, + + + + + + + + + + + // TIMEZONE STUFF + + TimeZoneDataStructure : class { + constructor() { + this.buf = new DataView(new ArrayBuffer(172)); + } + +/* + Buffer structure: + @000 utcOffset (4x8bit as 1xLONG) + + @004 standardName (64x8bit as 32xWCHAR) + @068 standardDate (16x8 as 1xSYSTEMTIME) + @084 standardBias (4x8bit as 1xLONG) + + @088 daylightName (64x8bit as 32xWCHAR) + @152 daylightDate (16x8 as 1xSTRUCT) + @168 daylightBias (4x8bit as 1xLONG) +*/ + + set easTimeZone64 (b64) { + //clear buffer + for (let i=0; i<172; i++) this.buf.setUint8(i, 0); + //load content into buffer + let content = (b64 == "") ? "" : atob(b64); + for (let i=0; i<content.length; i++) this.buf.setUint8(i, content.charCodeAt(i)); + } + + get easTimeZone64 () { + let content = ""; + for (let i=0; i<172; i++) content += String.fromCharCode(this.buf.getUint8(i)); + return (btoa(content)); + } + + getstr (byteoffset) { + let str = ""; + //walk thru the buffer in 32 steps of 16bit (wchars) + for (let i=0;i<32;i++) { + let cc = this.buf.getUint16(byteoffset+i*2, true); + if (cc == 0) break; + str += String.fromCharCode(cc); + } + return str; + } + + setstr (byteoffset, str) { + //clear first + for (let i=0;i<32;i++) this.buf.setUint16(byteoffset+i*2, 0); + //walk thru the buffer in steps of 16bit (wchars) + for (let i=0;i<str.length && i<32; i++) this.buf.setUint16(byteoffset+i*2, str.charCodeAt(i), true); + } + + getsystemtime (buf, offset) { + let systemtime = { + get wYear () { return buf.getUint16(offset + 0, true); }, + get wMonth () { return buf.getUint16(offset + 2, true); }, + get wDayOfWeek () { return buf.getUint16(offset + 4, true); }, + get wDay () { return buf.getUint16(offset + 6, true); }, + get wHour () { return buf.getUint16(offset + 8, true); }, + get wMinute () { return buf.getUint16(offset + 10, true); }, + get wSecond () { return buf.getUint16(offset + 12, true); }, + get wMilliseconds () { return buf.getUint16(offset + 14, true); }, + toString() { return [this.wYear, this.wMonth, this.wDay].join("-") + ", " + this.wDayOfWeek + ", " + [this.wHour,this.wMinute,this.wSecond].join(":") + "." + this.wMilliseconds}, + + set wYear (v) { buf.setUint16(offset + 0, v, true); }, + set wMonth (v) { buf.setUint16(offset + 2, v, true); }, + set wDayOfWeek (v) { buf.setUint16(offset + 4, v, true); }, + set wDay (v) { buf.setUint16(offset + 6, v, true); }, + set wHour (v) { buf.setUint16(offset + 8, v, true); }, + set wMinute (v) { buf.setUint16(offset + 10, v, true); }, + set wSecond (v) { buf.setUint16(offset + 12, v, true); }, + set wMilliseconds (v) { buf.setUint16(offset + 14, v, true); }, + }; + return systemtime; + } + + get standardDate () {return this.getsystemtime (this.buf, 68); } + get daylightDate () {return this.getsystemtime (this.buf, 152); } + + get utcOffset () { return this.buf.getInt32(0, true); } + set utcOffset (v) { this.buf.setInt32(0, v, true); } + + get standardBias () { return this.buf.getInt32(84, true); } + set standardBias (v) { this.buf.setInt32(84, v, true); } + get daylightBias () { return this.buf.getInt32(168, true); } + set daylightBias (v) { this.buf.setInt32(168, v, true); } + + get standardName () {return this.getstr(4); } + set standardName (v) {return this.setstr(4, v); } + get daylightName () {return this.getstr(88); } + set daylightName (v) {return this.setstr(88, v); } + + toString () { return ["", + "utcOffset: "+ this.utcOffset, + "standardName: "+ this.standardName, + "standardDate: "+ this.standardDate.toString(), + "standardBias: "+ this.standardBias, + "daylightName: "+ this.daylightName, + "daylightDate: "+ this.daylightDate.toString(), + "daylightBias: "+ this.daylightBias].join("\n"); } + }, + + + //Date has a toISOString method, which returns the Date obj as extended ISO 8601, + //however EAS MS-ASCAL uses compact/basic ISO 8601, + dateToBasicISOString : function (date) { + function pad(number) { + if (number < 10) { + return '0' + number; + } + return number.toString(); + } + + return pad(date.getUTCFullYear()) + + pad(date.getUTCMonth() + 1) + + pad(date.getUTCDate()) + + 'T' + + pad(date.getUTCHours()) + + pad(date.getUTCMinutes()) + + pad(date.getUTCSeconds()) + + 'Z'; + }, + + + //Save replacement for cal.createDateTime, which accepts compact/basic and also extended ISO 8601, + //cal.createDateTime only supports compact/basic + createDateTime: function(str) { + let datestring = str; + if (str.indexOf("-") == 4) { + //this looks like extended ISO 8601 + let tempDate = new Date(str); + datestring = eas.tools.dateToBasicISOString(tempDate); + } + return TbSync.lightning.cal.createDateTime(datestring); + }, + + + // Convert TB date to UTC and return it as basic or extended ISO 8601 String + getIsoUtcString: function(origdate, requireExtendedISO = false, fakeUTC = false) { + let date = origdate.clone(); + //floating timezone cannot be converted to UTC (cause they float) - we have to overwrite it with the local timezone + if (date.timezone.tzid == "floating") date.timezone = eas.defaultTimezoneInfo.timezone; + //to get the UTC string we could use icalString (which does not work on allDayEvents, or calculate it from nativeTime) + date.isDate = 0; + let UTC = date.getInTimezone(eas.utcTimezone); + if (fakeUTC) UTC = date.clone(); + + function pad(number) { + if (number < 10) { + return '0' + number; + } + return number; + } + + if (requireExtendedISO) { + return UTC.year + + "-" + pad(UTC.month + 1 ) + + "-" + pad(UTC.day) + + "T" + pad(UTC.hour) + + ":" + pad(UTC.minute) + + ":" + pad(UTC.second) + + "." + "000" + + "Z"; + } else { + return UTC.icalString; + } + }, + + getNowUTC : function() { + return TbSync.lightning.cal.dtz.jsDateToDateTime(new Date()).getInTimezone(TbSync.lightning.cal.dtz.UTC); + }, + + //guess the IANA timezone (used by TB) based on the current offset (standard or daylight) + guessTimezoneByCurrentOffset: function(curOffset, utcDateTime) { + //if we only now the current offset and the current date, we need to actually try each TZ. + let tzService = TbSync.lightning.cal.timezoneService; + + //first try default tz + let test = utcDateTime.getInTimezone(eas.defaultTimezoneInfo.timezone); + TbSync.dump("Matching TZ via current offset: " + test.timezone.tzid + " @ " + curOffset, test.timezoneOffset/-60); + if (test.timezoneOffset/-60 == curOffset) return test.timezone; + + //second try UTC + test = utcDateTime.getInTimezone(eas.utcTimezone); + TbSync.dump("Matching TZ via current offset: " + test.timezone.tzid + " @ " + curOffset, test.timezoneOffset/-60); + if (test.timezoneOffset/-60 == curOffset) return test.timezone; + + //third try all others + for (let timezoneId of tzService.timezoneIds) { + let test = utcDateTime.getInTimezone(tzService.getTimezone(timezoneId)); + TbSync.dump("Matching TZ via current offset: " + test.timezone.tzid + " @ " + curOffset, test.timezoneOffset/-60); + if (test.timezoneOffset/-60 == curOffset) return test.timezone; + } + + //return default TZ as fallback + return eas.defaultTimezoneInfo.timezone; + }, + + + //guess the IANA timezone (used by TB) based on stdandard offset, daylight offset and standard name + guessTimezoneByStdDstOffset: function(stdOffset, dstOffset, stdName = "") { + + //get a list of all zones + //alternativly use cal.fromRFC3339 - but this is only doing this: + //https://dxr.mozilla.org/comm-central/source/calendar/base/modules/calProviderUtils.jsm + + //cache timezone data on first attempt + if (eas.cachedTimezoneData === null) { + eas.cachedTimezoneData = {}; + eas.cachedTimezoneData.iana = {}; + eas.cachedTimezoneData.abbreviations = {}; + eas.cachedTimezoneData.stdOffset = {}; + eas.cachedTimezoneData.bothOffsets = {}; + + let tzService = TbSync.lightning.cal.timezoneService; + + //cache timezones data from internal IANA data + for (let timezoneId of tzService.timezoneIds) { + let timezone = tzService.getTimezone(timezoneId); + let tzInfo = eas.tools.getTimezoneInfo(timezone); + + eas.cachedTimezoneData.bothOffsets[tzInfo.std.offset+":"+tzInfo.dst.offset] = timezone; + eas.cachedTimezoneData.stdOffset[tzInfo.std.offset] = timezone; + + eas.cachedTimezoneData.abbreviations[tzInfo.std.abbreviation] = timezoneId; + eas.cachedTimezoneData.iana[timezoneId] = tzInfo; + + //TbSync.dump("TZ ("+ tzInfo.std.id + " :: " + tzInfo.dst.id + " :: " + tzInfo.std.displayname + " :: " + tzInfo.dst.displayname + " :: " + tzInfo.std.offset + " :: " + tzInfo.dst.offset + ")", tzService.getTimezone(id)); + } + + //make sure, that UTC timezone is there + eas.cachedTimezoneData.bothOffsets["0:0"] = eas.utcTimezone; + + //multiple TZ share the same offset and abbreviation, make sure the default timezone is present + eas.cachedTimezoneData.abbreviations[eas.defaultTimezoneInfo.std.abbreviation] = eas.defaultTimezoneInfo.std.id; + eas.cachedTimezoneData.bothOffsets[eas.defaultTimezoneInfo.std.offset+":"+eas.defaultTimezoneInfo.dst.offset] = eas.defaultTimezoneInfo.timezone; + eas.cachedTimezoneData.stdOffset[eas.defaultTimezoneInfo.std.offset] = eas.defaultTimezoneInfo.timezone; + + } + + /* + 1. Try to find name in Windows names and map to IANA -> if found, does the stdOffset match? -> if so, done + 2. Try to parse our own format, split name and test each chunk for IANA -> if found, does the stdOffset match? -> if so, done + 3. Try if one of the chunks matches international code -> if found, does the stdOffset match? -> if so, done + 4. Fallback: Use just the offsets */ + + + //check for windows timezone name + if (eas.windowsToIanaTimezoneMap[stdName] && eas.cachedTimezoneData.iana[eas.windowsToIanaTimezoneMap[stdName]] && eas.cachedTimezoneData.iana[eas.windowsToIanaTimezoneMap[stdName]].std.offset == stdOffset ) { + //the windows timezone maps multiple IANA zones to one (Berlin*, Rome, Bruessel) + //check the windowsZoneName of the default TZ and of the winning, if they match, use default TZ + //so Rome could win, even Berlin is the default IANA zone + if (eas.defaultTimezoneInfo.std.windowsZoneName && eas.windowsToIanaTimezoneMap[stdName] != eas.defaultTimezoneInfo.std.id && eas.cachedTimezoneData.iana[eas.windowsToIanaTimezoneMap[stdName]].std.offset == eas.defaultTimezoneInfo.std.offset && stdName == eas.defaultTimezoneInfo.std.windowsZoneName) { + TbSync.dump("Timezone matched via windows timezone name ("+stdName+") with default TZ overtake", eas.windowsToIanaTimezoneMap[stdName] + " -> " + eas.defaultTimezoneInfo.std.id); + return eas.defaultTimezoneInfo.timezone; + } + + TbSync.dump("Timezone matched via windows timezone name ("+stdName+")", eas.windowsToIanaTimezoneMap[stdName]); + return eas.cachedTimezoneData.iana[eas.windowsToIanaTimezoneMap[stdName]].timezone; + } + + let parts = stdName.replace(/[;,()\[\]]/g," ").split(" "); + for (let i = 0; i < parts.length; i++) { + //check for IANA + if (eas.cachedTimezoneData.iana[parts[i]] && eas.cachedTimezoneData.iana[parts[i]].std.offset == stdOffset) { + TbSync.dump("Timezone matched via IANA", parts[i]); + return eas.cachedTimezoneData.iana[parts[i]].timezone; + } + + //check for international abbreviation for standard period (CET, CAT, ...) + if (eas.cachedTimezoneData.abbreviations[parts[i]] && eas.cachedTimezoneData.iana[eas.cachedTimezoneData.abbreviations[parts[i]]] && eas.cachedTimezoneData.iana[eas.cachedTimezoneData.abbreviations[parts[i]]].std.offset == stdOffset) { + TbSync.dump("Timezone matched via international abbreviation (" + parts[i] +")", eas.cachedTimezoneData.abbreviations[parts[i]]); + return eas.cachedTimezoneData.iana[eas.cachedTimezoneData.abbreviations[parts[i]]].timezone; + } + } + + //fallback to zone based on stdOffset and dstOffset, if we have that cached + if (eas.cachedTimezoneData.bothOffsets[stdOffset+":"+dstOffset]) { + TbSync.dump("Timezone matched via both offsets (std:" + stdOffset +", dst:" + dstOffset + ")", eas.cachedTimezoneData.bothOffsets[stdOffset+":"+dstOffset].tzid); + return eas.cachedTimezoneData.bothOffsets[stdOffset+":"+dstOffset]; + } + + //fallback to zone based on stdOffset only, if we have that cached + if (eas.cachedTimezoneData.stdOffset[stdOffset]) { + TbSync.dump("Timezone matched via std offset (" + stdOffset +")", eas.cachedTimezoneData.stdOffset[stdOffset].tzid); + return eas.cachedTimezoneData.stdOffset[stdOffset]; + } + + //return default timezone, if everything else fails + TbSync.dump("Timezone could not be matched via offsets (std:" + stdOffset +", dst:" + dstOffset + "), using default timezone", eas.defaultTimezoneInfo.std.id); + return eas.defaultTimezoneInfo.timezone; + }, + + + //extract standard and daylight timezone data + getTimezoneInfo: function (timezone) { + let tzInfo = {}; + + tzInfo.std = eas.tools.getTimezoneInfoObject(timezone, "standard"); + tzInfo.dst = eas.tools.getTimezoneInfoObject(timezone, "daylight"); + + if (tzInfo.dst === null) tzInfo.dst = tzInfo.std; + + tzInfo.timezone = timezone; + return tzInfo; + }, + + + //get timezone info for standard/daylight + getTimezoneInfoObject: function (timezone, standardOrDaylight) { + + //handle UTC + if (timezone.isUTC) { + let obj = {} + obj.id = "UTC"; + obj.offset = 0; + obj.abbreviation = "UTC"; + obj.displayname = "Coordinated Universal Time (UTC)"; + return obj; + } + + //we could parse the icalstring by ourself, but I wanted to use ICAL.parse - TODO try catch + let info = TbSync.lightning.ICAL.parse("BEGIN:VCALENDAR\r\n" + timezone.icalComponent.toString() + "\r\nEND:VCALENDAR"); + let comp = new TbSync.lightning.ICAL.Component(info); + let vtimezone =comp.getFirstSubcomponent("vtimezone"); + let id = vtimezone.getFirstPropertyValue("tzid").toString(); + let zone = vtimezone.getFirstSubcomponent(standardOrDaylight); + + if (zone) { + let obj = {}; + obj.id = id; + + //get offset + let utcOffset = zone.getFirstPropertyValue("tzoffsetto").toString(); + let o = parseInt(utcOffset.replace(":","")); //-330 = - 3h 30min + let h = Math.floor(o / 100); //-3 -> -180min + let m = o - (h*100) //-330 - -300 = -30 + obj.offset = -1*((h*60) + m); + + //get international abbreviation (CEST, CET, CAT ... ) + obj.abbreviation = ""; + try { + obj.abbreviation = zone.getFirstPropertyValue("tzname").toString(); + } catch(e) { + TbSync.dump("Failed TZ", timezone.icalComponent.toString()); + } + + //get displayname + obj.displayname = /*"("+utcOffset+") " +*/ obj.id;// + ", " + obj.abbreviation; + + //get DST switch date + let rrule = zone.getFirstPropertyValue("rrule"); + let dtstart = zone.getFirstPropertyValue("dtstart"); + if (rrule && dtstart) { + /* + + THE switchdate PART OF THE OBJECT IS MICROSOFT SPECIFIC, EVERYTHING ELSE IS THUNDERBIRD GENERIC, I LET IT SIT HERE ANYHOW + + https://msdn.microsoft.com/en-us/library/windows/desktop/ms725481(v=vs.85).aspx + + To select the correct day in the month, set the wYear member to zero, the wHour and wMinute members to + the transition time, the wDayOfWeek member to the appropriate weekday, and the wDay member to indicate + the occurrence of the day of the week within the month (1 to 5, where 5 indicates the final occurrence during the + month if that day of the week does not occur 5 times). + + Using this notation, specify 02:00 on the first Sunday in April as follows: + wHour = 2, wMonth = 4, wDayOfWeek = 0, wDay = 1. + Specify 02:00 on the last Thursday in October as follows: + wHour = 2, wMonth = 10, wDayOfWeek = 4, wDay = 5. + + So we have to parse the RRULE to exract wDay + RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 */ + + let parts =rrule.toString().split(";"); + let rules = {}; + for (let i = 0; i< parts.length; i++) { + let sub = parts[i].split("="); + if (sub.length == 2) rules[sub[0]] = sub[1]; + } + + if (rules.FREQ == "YEARLY" && rules.BYDAY && rules.BYMONTH && rules.BYDAY.length > 2) { + obj.switchdate = {}; + obj.switchdate.month = parseInt(rules.BYMONTH); + + let days = ["SU","MO","TU","WE","TH","FR","SA"]; + obj.switchdate.dayOfWeek = days.indexOf(rules.BYDAY.substring(rules.BYDAY.length-2)); + obj.switchdate.weekOfMonth = parseInt(rules.BYDAY.substring(0, rules.BYDAY.length-2)); + if (obj.switchdate.weekOfMonth<0 || obj.switchdate.weekOfMonth>5) obj.switchdate.weekOfMonth = 5; + + //get switch time from dtstart + let dttime = eas.tools.createDateTime(dtstart.toString()); + obj.switchdate.hour = dttime.hour; + obj.switchdate.minute = dttime.minute; + obj.switchdate.second = dttime.second; + } + } + + return obj; + } + return null; + }, +} + +//TODO: Invites +/* + cal.itip.checkAndSendOrigial = cal.itip.checkAndSend; + cal.itip.checkAndSend = function(aOpType, aItem, aOriginalItem) { + //if this item is added_by_user, do not call checkAndSend yet, because the UID is wrong, we need to sync first to get the correct ID - TODO + TbSync.dump("cal.checkAndSend", aOpType); + cal.itip.checkAndSendOrigial(aOpType, aItem, aOriginalItem); + } +*/ diff --git a/content/includes/wbxmltools.js b/content/includes/wbxmltools.js new file mode 100644 index 0000000..ae0e38c --- /dev/null +++ b/content/includes/wbxmltools.js @@ -0,0 +1,877 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + +var wbxmltools = { + + // Convert a WBXML (WAP Binary XML) to plain XML - returns save xml with all special chars in the user data encoded by encodeURIComponent + convert2xml: function (wbxml) { + + let num = 4; //skip the 4 first bytes which are mostly 0x03 (WBXML Version 1.3), 0x01 (unknown public identifier), 0x6A (utf-8), 0x00 (Length of string table) + + //the main code page will be set to the the first codepage used + let mainCodePage = null; + + let tagStack = []; + let xml = ""; + let codepage = 0; + + while (num < wbxml.length) { + let data = wbxml.substr(num, 1).charCodeAt(0); + let token = data & 0x3F; //removes content bit(6) and attribute bit(7) + let tokenHasContent = ((data & 0x40) != 0); //checks if content bit is set + let tokenHasAttributes = ((data & 0x80) != 0); //checks if attribute bit is set + + switch(token) { + case 0x00: // switch of codepage (new codepage is next byte) + num = num + 1; + codepage = (wbxml.substr(num, 1)).charCodeAt(0) & 0xFF; + break; + + case 0x01: // Indicates the end of an attribute list or the end of an element + // tagStack contains a list of opened tags, which await to be closed + xml = xml + tagStack.pop(); + break; + + case 0x02: // A character entity. Followed by a mb_u_int32 encoding the character entity number. + TbSync.dump("wbxml", "Encoded character entity has not yet been implemented. Sorry."); + return false; + break; + + case 0x03: // Inline string followed by a termstr. (0x00) + let termpos = wbxml.indexOf(String.fromCharCode(0x00), num); + //encode all special chars in the user data by encodeURIComponent which does not encode the apostrophe, so we need to do that by hand + xml = xml + encodeURIComponent(wbxml.substring(num + 1, termpos)).replace(/'/g, "%27"); + num = termpos; + break; + + case 0x04: // An unknown tag or attribute name. Followed by an mb_u_int32 that encodes an offset into the string table. + case 0x40: // Inline string document-type-specific extension token. Token is followed by a termstr. + case 0x41: // Inline string document-type-specific extension token. Token is followed by a termstr. + case 0x42: // Inline string document-type-specific extension token. Token is followed by a termstr. + case 0x43: // Processing instruction. + case 0x44: // Unknown tag, with content. + case 0x80: // Inline integer document-type-specific extension token. Token is followed by a mb_uint_32. + case 0x81: // Inline integer document-type-specific extension token. Token is followed by a mb_uint_32. + case 0x82: // Inline integer document-type-specific extension token. Token is followed by a mb_uint_32. + case 0x83: // String table reference. Followed by a mb_u_int32 encoding a byte offset from the beginning of the string table. + case 0x84: // Unknown tag, with attributes. + case 0xC0: // Single-byte document-type-specific extension token. + case 0xC1: // Single-byte document-type-specific extension token. + case 0xC2: // Single-byte document-type-specific extension token. + case 0xC3: // Opaque document-type-specific data. + case 0xC4: // Unknown tag, with content and attributes. + TbSync.dump("wbxml", "Global token <" + token + "> has not yet been implemented. Sorry."); + return false; + break; + + default: + // if this code page is not the mainCodePage (or mainCodePage is not yet set = very first tag), add codePageTag with current codepage + let codePageTag = (codepage != mainCodePage) ? " xmlns='" + this.getNamespace(codepage) + "'" : ""; + + // if no mainCodePage has been defined yet, use the current codepage, which is either the initialized/default value of codepage or a value set by SWITCH_PAGE + if (mainCodePage === null) mainCodePage = codepage; + + if (!tokenHasContent) { + xml = xml + "<" + this. getCodepageToken(codepage, token) + codePageTag + "/>"; + } else { + xml = xml + "<" +this. getCodepageToken(codepage, token) + codePageTag +">"; + //add the closing tag to the stack, so it can get properly closed later + tagStack.push("</" +this. getCodepageToken(codepage, token) + ">"); + } + + if (this.isUnknownToken(codepage, token)) { + TbSync.eventlog.add("warning", null, "WBXML: Unknown token <" + token + "> for codepage <"+codepage+">."); + } + } + num = num + 1; + } + return (xml == "") ? "" : '<?xml version="1.0"?>' + xml; + }, + + isUnknownToken: function (codepage, token) { + if (this.codepages[codepage] && token in this.codepages[codepage]) return false; + else return true; + }, + + getNamespace: function (codepage) { + return (this.namespaces[codepage]) ? this.namespaces[codepage] : "UnknownCodePage" + codepage ; + }, + + getCodepageToken: function (codepage, token) { + return this.isUnknownToken(codepage, token) ? "Unknown." + codepage + "." + token : this.codepages[codepage][token]; + }, + + // This returns a wbxml object, which allows to add tags (using names), switch codepages, or open and close tags, it is also possible to append pure (binary) wbxml + // If no wbxmlstring is present, default to the "init" string ( WBXML Version 1.3, unknown public identifier, UTF-8, Length of string table) + createWBXML: function (wbxmlstring = String.fromCharCode(0x03, 0x01, 0x6A, 0x00), initialCodepage = "") { + let wbxml = { + _codepage : 0, + _wbxml : wbxmlstring, + + append : function (wbxmlstring) { + this._wbxml = this._wbxml + wbxmlstring; + }, + + // adding a string content tag as <tagname>contentstring</tagname> + atag : function (tokenname, content = "") { + //check if tokenname is in current codepage + if ((this._codepage in wbxmltools.codepages2) == false) throw "[wbxmltools] Unknown codepage <"+this._codepage+">"; + if ((tokenname in wbxmltools.codepages2[this._codepage]) == false) throw "[wbxmltools] Unknown tokenname <"+tokenname+"> for codepage <"+wbxmltools.namespaces[this._codepage]+">"; + + if (content == "") { + //empty, just add token + this._wbxml += String.fromCharCode(wbxmltools.codepages2[this._codepage][tokenname]); + } else { + //not empty,add token with enabled content bit and also add inlinestringidentifier + this._wbxml += String.fromCharCode(wbxmltools.codepages2[this._codepage][tokenname] | 0x40, 0x03); + //add content + for (let i=0; i< content.length; i++) this._wbxml += String.fromCharCode(content.charCodeAt(i)); + //add string termination and tag close + this._wbxml += String.fromCharCode(0x00, 0x01); + } + }, + + switchpage : function (name) { + let codepage = wbxmltools.namespaces.indexOf(name); + if (codepage == -1) throw "[wbxmltools] Unknown codepage <"+ name +">"; + this._codepage = codepage; + this._wbxml += String.fromCharCode(0x00, codepage); + }, + + ctag : function () { + this._wbxml += String.fromCharCode(0x01); + }, + + //opentag is assumed to add a token with content, otherwise use addtag + otag : function (tokenname) { + this._wbxml += String.fromCharCode(wbxmltools.codepages2[this._codepage][tokenname] | 0x40); + }, + + getCharCodes : function () { + let value = ""; + for (let i=0; i<this._wbxml.length; i++) value += ("00" + this._wbxml.charCodeAt(i).toString(16)).substr(-2) + " "; + return value; + }, + + getBytes : function () { + return this._wbxml; + } + }; + if (initialCodepage) wbxml._codepage = wbxmltools.namespaces.indexOf(initialCodepage); + return wbxml; + }, + + + + + + codepages2 : [], + + buildCodepages2 : function () { + for (let i=0; i<this.codepages.length; i++) { + let inverted = {}; + for (let token in this.codepages[i]) { + inverted[this.codepages[i][token]] = token; + } + this.codepages2.push(inverted); + } + }, + + + + + + codepages : [ + // Code Page 0: AirSync + { + 0x05: 'Sync', + 0x06: 'Responses', + 0x07: 'Add', + 0x08: 'Change', + 0x09: 'Delete', + 0x0A: 'Fetch', + 0x0B: 'SyncKey', + 0x0C: 'ClientId', + 0x0D: 'ServerId', + 0x0E: 'Status', + 0x0F: 'Collection', + 0x10: 'Class', + 0x12: 'CollectionId', + 0x13: 'GetChanges', + 0x14: 'MoreAvailable', + 0x15: 'WindowSize', + 0x16: 'Commands', + 0x17: 'Options', + 0x18: 'FilterType', + 0x1B: 'Conflict', + 0x1C: 'Collections', + 0x1D: 'ApplicationData', + 0x1E: 'DeletesAsMoves', + 0x20: 'Supported', + 0x21: 'SoftDelete', + 0x22: 'MIMESupport', + 0x23: 'MIMETruncation', + 0x24: 'Wait', + 0x25: 'Limit', + 0x26: 'Partial', + 0x27: 'ConversationMode', + 0x28: 'MaxItems', + 0x29: 'HeartbeatInterval' + }, + // Code Page 1: Contacts + { + 0x05: 'Anniversary', + 0x06: 'AssistantName', + 0x07: 'AssistantPhoneNumber', + 0x08: 'Birthday', + 0x09: 'Body', + 0x0A: 'BodySize', + 0x0B: 'BodyTruncated', + 0x0C: 'Business2PhoneNumber', + 0x0D: 'BusinessAddressCity', + 0x0E: 'BusinessAddressCountry', + 0x0F: 'BusinessAddressPostalCode', + 0x10: 'BusinessAddressState', + 0x11: 'BusinessAddressStreet', + 0x12: 'BusinessFaxNumber', + 0x13: 'BusinessPhoneNumber', + 0x14: 'CarPhoneNumber', + 0x15: 'Categories', + 0x16: 'Category', + 0x17: 'Children', + 0x18: 'Child', + 0x19: 'CompanyName', + 0x1A: 'Department', + 0x1B: 'Email1Address', + 0x1C: 'Email2Address', + 0x1D: 'Email3Address', + 0x1E: 'FileAs', + 0x1F: 'FirstName', + 0x20: 'Home2PhoneNumber', + 0x21: 'HomeAddressCity', + 0x22: 'HomeAddressCountry', + 0x23: 'HomeAddressPostalCode', + 0x24: 'HomeAddressState', + 0x25: 'HomeAddressStreet', + 0x26: 'HomeFaxNumber', + 0x27: 'HomePhoneNumber', + 0x28: 'JobTitle', + 0x29: 'LastName', + 0x2A: 'MiddleName', + 0x2B: 'MobilePhoneNumber', + 0x2C: 'OfficeLocation', + 0x2D: 'OtherAddressCity', + 0x2E: 'OtherAddressCountry', + 0x2F: 'OtherAddressPostalCode', + 0x30: 'OtherAddressState', + 0x31: 'OtherAddressStreet', + 0x32: 'PagerNumber', + 0x33: 'RadioPhoneNumber', + 0x34: 'Spouse', + 0x35: 'Suffix', + 0x36: 'Title', + 0x37: 'WebPage', + 0x38: 'YomiCompanyName', + 0x39: 'YomiFirstName', + 0x3A: 'YomiLastName', + 0x3B: 'CompressedRTF', + 0x3C: 'Picture', + 0x3D: 'Alias', + 0x3E: 'WeightedRank' + }, + // Code Page 2: Email + { + 0x05: 'Attachment', + 0x06: 'Attachments', + 0x07: 'AttName', + 0x08: 'AttSize', + 0x09: 'Att0Id', + 0x0a: 'AttMethod', + 0x0b: 'AttRemoved', + 0x0c: 'Body', + 0x0d: 'BodySize', + 0x0e: 'BodyTruncated', + 0x0f: 'DateReceived', + 0x10: 'DisplayName', + 0x11: 'DisplayTo', + 0x12: 'Importance', + 0x13: 'MessageClass', + 0x14: 'Subject', + 0x15: 'Read', + 0x16: 'To', + 0x17: 'Cc', + 0x18: 'From', + 0x19: 'ReplyTo', + 0x1a: 'AllDayEvent', + 0x1b: 'Categories', + 0x1c: 'Category', + 0x1d: 'DTStamp', + 0x1e: 'EndTime', + 0x1f: 'InstanceType', + 0x20: 'BusyStatus', + 0x21: 'Location', + 0x22: 'MeetingRequest', + 0x23: 'Organizer', + 0x24: 'RecurrenceId', + 0x25: 'Reminder', + 0x26: 'ResponseRequested', + 0x27: 'Recurrences', + 0x28: 'Recurrence', + 0x29: 'Recurrence_Type', + 0x2a: 'Recurrence_Until', + 0x2b: 'Recurrence_Occurrences', + 0x2c: 'Recurrence_Interval', + 0x2d: 'Recurrence_DayOfWeek', + 0x2e: 'Recurrence_DayOfMonth', + 0x2f: 'Recurrence_WeekOfMonth', + 0x30: 'Recurrence_MonthOfYear', + 0x31: 'StartTime', + 0x32: 'Sensitivity', + 0x33: 'TimeZone', + 0x34: 'GlobalObjId', + 0x35: 'ThreadTopic', + 0x36: 'MIMEData', + 0x37: 'MIMETruncated', + 0x38: 'MIMESize', + 0x39: 'InternetCPID', + 0x3a: 'Flag', + 0x3b: 'Status', + 0x3c: 'ContentClass', + 0x3d: 'FlagType', + 0x3e: 'CompleteTime', + 0x3f: 'DisallowNewTimeProposal' + }, + // Code Page 3: AirNotify (WBXML code page 3 is no longer in use) + {}, + // Code Page 4: Calendar + { + 0x05: 'TimeZone', + 0x06: 'AllDayEvent', + 0x07: 'Attendees', + 0x08: 'Attendee', + 0x09: 'Email', + 0x0a: 'Name', + 0x0b: 'Body', + 0x0c: 'BodyTruncated', + 0x0d: 'BusyStatus', + 0x0e: 'Categories', + 0x0f: 'Category', + 0x10: 'CompressedRTF', + 0x11: 'DtStamp', + 0x12: 'EndTime', + 0x13: 'Exception', + 0x14: 'Exceptions', + 0x15: 'Deleted', + 0x16: 'ExceptionStartTime', + 0x17: 'Location', + 0x18: 'MeetingStatus', + 0x19: 'OrganizerEmail', + 0x1a: 'OrganizerName', + 0x1b: 'Recurrence', + 0x1c: 'Type', + 0x1d: 'Until', + 0x1e: 'Occurrences', + 0x1f: 'Interval', + 0x20: 'DayOfWeek', + 0x21: 'DayOfMonth', + 0x22: 'WeekOfMonth', + 0x23: 'MonthOfYear', + 0x24: 'Reminder', + 0x25: 'Sensitivity', + 0x26: 'Subject', + 0x27: 'StartTime', + 0x28: 'UID', + 0x29: 'AttendeeStatus', + 0x2a: 'AttendeeType', + 0x2b: 'Attachment', + 0x2c: 'Attachments', + 0x2d: 'AttName', + 0x2e: 'AttSize', + 0x2f: 'AttOid', + 0x30: 'AttMethod', + 0x31: 'AttRemoved', + 0x32: 'DisplayName', + 0x33: 'DisallowNewTimeProposal', + 0x34: 'ResponseRequested', + 0x35: 'AppointmentReplyTime', + 0x36: 'ResponseType', + 0x37: 'CalendarType', + 0x38: 'IsLeapMonth', + 0x39: 'FirstDayOfWeek', + 0x3a: 'OnlineMeetingConfLink', + 0x3b: 'OnlineMeetingExternalLink' + }, + // Code Page 5: Move + { + 0x05: 'MoveItems', + 0x06: 'Move', + 0x07: 'SrcMsgId', + 0x08: 'SrcFldId', + 0x09: 'DstFldId', + 0x0A: 'Response', + 0x0B: 'Status', + 0x0C: 'DstMsgId' + }, + // Code Page 6: GetItemEstimate + { + 0x05: 'GetItemEstimate', + 0x06: 'Version', + 0x07: 'Collections', + 0x08: 'Collection', + 0x09: 'Class', + 0x0A: 'CollectionId', + 0x0B: 'DateTime', + 0x0C: 'Estimate', + 0x0D: 'Response', + 0x0E: 'Status' + }, + // Code Page 7: FolderHierarchy + { + 0x07: 'DisplayName', + 0x08: 'ServerId', + 0x09: 'ParentId', + 0x0A: 'Type', + 0x0C: 'Status', + 0x0E: 'Changes', + 0x0F: 'Add', + 0x10: 'Delete', + 0x11: 'Update', + 0x12: 'SyncKey', + 0x13: 'FolderCreate', + 0x14: 'FolderDelete', + 0x15: 'FolderUpdate', + 0x16: 'FolderSync', + 0x17: 'Count' + }, + // Code Page 8: MeetingResponse + { + 0x05: 'CalendarId', + 0x06: 'CollectionId', + 0x07: 'MeetingResponse', + 0x08: 'RequestId', + 0x09: 'Request', + 0x0a: 'Result', + 0x0b: 'Status', + 0x0c: 'UserResponse', + 0x0e: 'InstanceId' + }, + // Code Page 9: Tasks + { + 0x05: 'Body', + 0x06: 'BodySize', + 0x07: 'BodyTruncated', + 0x08: 'Categories', + 0x09: 'Category', + 0x0A: 'Complete', + 0x0B: 'DateCompleted', + 0x0C: 'DueDate', + 0x0D: 'UtcDueDate', + 0x0E: 'Importance', + 0x0F: 'Recurrence', + 0x10: 'Type', + 0x11: 'Start', + 0x12: 'Until', + 0x13: 'Occurrences', + 0x14: 'Interval', + 0x15: 'DayOfMonth', + 0x16: 'DayOfWeek', + 0x17: 'WeekOfMonth', + 0x18: 'MonthOfYear', + 0x19: 'Regenerate', + 0x1A: 'DeadOccur', + 0x1B: 'ReminderSet', + 0x1C: 'ReminderTime', + 0x1D: 'Sensitivity', + 0x1E: 'StartDate', + 0x1F: 'UtcStartDate', + 0x20: 'Subject', + 0x22: 'OrdinalDate', + 0x23: 'SubOrdinalDate', + 0x24: 'CalendarType', + 0x25: 'IsLeapMonth', + 0x26: 'FirstDayOfWeek' + }, + // Code Page 10: ResolveRecipients + { + 0x05: 'ResolveRecipients', + 0x06: 'Response', + 0x07: 'Status', + 0x08: 'Type', + 0x09: 'Recipient', + 0x0a: 'DisplayName', + 0x0b: 'EmailAddress', + 0x0c: 'Certificates', + 0x0d: 'Certificate', + 0x0e: 'MiniCertificate', + 0x0f: 'Options', + 0x10: 'To', + 0x11: 'CertificateRetrieval', + 0x12: 'RecipientCount', + 0x13: 'MaxCertificates', + 0x14: 'MaxAmbiguousRecipients', + 0x15: 'CertificateCount', + 0x16: 'Availability', + 0x17: 'StartTime', + 0x18: 'EndTime', + 0x19: 'MergedFreeBusy', + 0x1a: 'Picture', + 0x1b: 'MaxSize', + 0x1c: 'Data', + 0x1d: 'MaxPictures' + }, + // Code Page 11: ValidateCert + { + 0x05: 'ValidateCert', + 0x06: 'Certificates', + 0x07: 'Certificate', + 0x08: 'CertificateChain', + 0x09: 'CheckCRL', + 0x0a: 'Status' + }, + // Code Page 12: Contacts2 + { + 0x05: 'CustomerId', + 0x06: 'GovernmentId', + 0x07: 'IMAddress', + 0x08: 'IMAddress2', + 0x09: 'IMAddress3', + 0x0a: 'ManagerName', + 0x0b: 'CompanyMainPhone', + 0x0c: 'AccountName', + 0x0d: 'NickName', + 0x0e: 'MMS' + }, + // Code Page 13: Ping + { + 0x05: 'Ping', + 0x06: 'AutdState', + //(Not used) + 0x07: 'Status', + 0x08: 'HeartbeatInterval', + 0x09: 'Folders', + 0x0A: 'Folder', + 0x0B: 'Id', + 0x0C: 'Class', + 0x0D: 'MaxFolders' + }, + // Code Page 14: Provision + { + 0x05: 'Provision', + 0x06: 'Policies', + 0x07: 'Policy', + 0x08: 'PolicyType', + 0x09: 'PolicyKey', + 0x0A: 'Data', + 0x0B: 'Status', + 0x0C: 'RemoteWipe', + 0x0D: 'EASProvisionDoc', + 0x0E: 'DevicePasswordEnabled', + 0x0F: 'AlphanumericDevicePasswordRequired', + 0x10: 'DeviceEncryptionEnabled', + 0x10: 'RequireStorageCardEncryption', + 0x11: 'PasswordRecoveryEnabled', + 0x13: 'AttachmentsEnabled', + 0x14: 'MinDevicePasswordLength', + 0x15: 'MaxInactivityTimeDeviceLock', + 0x16: 'MaxDevicePasswordFailedAttempts', + 0x17: 'MaxAttachmentSize', + 0x18: 'AllowSimpleDevicePassword', + 0x19: 'DevicePasswordExpiration', + 0x1A: 'DevicePasswordHistory', + 0x1B: 'AllowStorageCard', + 0x1C: 'AllowCamera', + 0x1D: 'RequireDeviceEncryption', + 0x1E: 'AllowUnsignedApplications', + 0x1F: 'AllowUnsignedInstallationPackages', + 0x20: 'MinDevicePasswordComplexCharacters', + 0x21: 'AllowWiFi', + 0x22: 'AllowTextMessaging', + 0x23: 'AllowPOPIMAPEmail', + 0x24: 'AllowBluetooth', + 0x25: 'AllowIrDA', + 0x26: 'RequireManualSyncWhenRoaming', + 0x27: 'AllowDesktopSync', + 0x28: 'MaxCalendarAgeFilter', + 0x29: 'AllowHTMLEmail', + 0x2A: 'MaxEmailAgeFilter', + 0x2B: 'MaxEmailBodyTruncationSize', + 0x2C: 'MaxEmailHTMLBodyTruncationSize', + 0x2D: 'RequireSignedSMIMEMessages', + 0x2E: 'RequireEncryptedSMIMEMessages', + 0x2F: 'RequireSignedSMIMEAlgorithm', + 0x30: 'RequireEncryptionSMIMEAlgorithm', + 0x31: 'AllowSMIMEEncryptionAlgorithmNegotiation', + 0x32: 'AllowSMIMESoftCerts', + 0x33: 'AllowBrowser', + 0x34: 'AllowConsumerEmail', + 0x35: 'AllowRemoteDesktop', + 0x36: 'AllowInternetSharing', + 0x37: 'UnapprovedInROMApplicationList', + 0x38: 'ApplicationName', + 0x39: 'ApprovedApplicationList', + 0x3A: 'Hash' + }, + // Code Page 15: Search + { + 0x05: 'Search', + 0x06: 'Stores', + 0x07: 'Store', + 0x08: 'Name', + 0x09: 'Query', + 0x0a: 'Options', + 0x0b: 'Range', + 0x0c: 'Status', + 0x0d: 'Response', + 0x0e: 'Result', + 0x0f: 'Properties', + 0x10: 'Total', + 0x11: 'EqualTo', + 0x12: 'Value', + 0x13: 'And', + 0x14: 'Or', + 0x15: 'FreeText', + 0x17: 'DeepTraversal', + 0x18: 'LongId', + 0x19: 'RebuildResults', + 0x1a: 'LessThan', + 0x1b: 'GreaterThan', + 0x1c: 'Schema', + 0x1d: 'Supported', + 0x1e: 'UserName', + 0x1f: 'Password', + 0x20: 'ConversationId', + 0x21: 'Picture', + 0x22: 'MaxSize', + 0x23: 'MaxPictures' + }, + // Code Page 16: GAL + { + 0x05: 'DisplayName', + 0x06: 'Phone', + 0x07: 'Office', + 0x08: 'Title', + 0x09: 'Company', + 0x0a: 'Alias', + 0x0b: 'FirstName', + 0x0c: 'LastName', + 0x0d: 'HomePhone', + 0x0e: 'MobilePhone', + 0x0f: 'EmailAddress', + 0x10: 'Picture', + 0x11: 'Status', + 0x12: 'Data' + }, + // Code Page 17: AirSyncBase + { + 0x05: 'BodyPreference', + 0x06: 'Type', + 0x07: 'TruncationSize', + 0x08: 'AllOrNone', + 0x0A: 'Body', + 0x0B: 'Data', + 0x0C: 'EstimatedDataSize', + 0x0D: 'Truncated', + 0x0E: 'Attachments', + 0x0F: 'Attachment', + 0x10: 'DisplayName', + 0x11: 'FileReference', + 0x12: 'Method', + 0x13: 'ContentId', + 0x14: 'ContentLocation', + 0x15: 'IsInline', + 0x16: 'NativeBodyType', + 0x17: 'ContentType', + 0x18: 'Preview', + 0x19: 'BodyPartPreference', + 0x1A: 'BodyPart', + 0x1B: 'Status' + }, + // Code Page 18: Settings + { + 0x05: 'Settings', + 0x06: 'Status', + 0x07: 'Get', + 0x08: 'Set', + 0x09: 'Oof', + 0x0A: 'OofState', + 0x0B: 'StartTime', + 0x0C: 'EndTime', + 0x0D: 'OofMessage', + 0x0E: 'AppliesToInternal', + 0x0F: 'AppliesToExternalKnown', + 0x10: 'AppliesToExternalUnknown', + 0x11: 'Enabled', + 0x12: 'ReplyMessage', + 0x13: 'BodyType', + 0x14: 'DevicePassword', + 0x15: 'Password', + 0x16: 'DeviceInformation', + 0x17: 'Model', + 0x18: 'IMEI', + 0x19: 'FriendlyName', + 0x1A: 'OS', + 0x1B: 'OSLanguage', + 0x1C: 'PhoneNumber', + 0x1D: 'UserInformation', + 0x1E: 'EmailAddresses', + 0x1F: 'SMTPAddress', + 0x20: 'UserAgent', + 0x21: 'EnableOutboundSMS', + 0x22: 'MobileOperator', + 0x23: 'PrimarySmtpAddress', + 0x24: 'Accounts', + 0x25: 'Account', + 0x26: 'AccountId', + 0x27: 'AccountName', + 0x28: 'UserDisplayName', + 0x29: 'SendDisabled', + 0x2B: 'RightsManagementInformation' + }, + // Code Page 19: DocumentLibrary + { + 0x05: 'LinkId', + 0x06: 'DisplayName', + 0x07: 'IsFolder', + 0x08: 'CreationDate', + 0x09: 'LastModifiedDate', + 0x0a: 'IsHidden', + 0x0b: 'ContentLength', + 0x0c: 'ContentType' + }, + // Code Page 20: ItemOperations + { + 0x05: 'ItemOperations', + 0x06: 'Fetch', + 0x07: 'Store', + 0x08: 'Options', + 0x09: 'Range', + 0x0A: 'Total', + 0x0B: 'Properties', + 0x0C: 'Data', + 0x0D: 'Status', + 0x0E: 'Response', + 0x0F: 'Version', + 0x10: 'Schema', + 0x11: 'Part', + 0x12: 'EmptyFolderContents', + 0x13: 'DeleteSubFolders', + 0x14: 'UserName', + 0x15: 'Password', + 0x16: 'Move', + 0x17: 'DstFldId', + 0x18: 'ConversationId', + 0x19: 'MoveAlways' + }, + // Code Page 21: ComposeMail + { + 0x05: 'SendMail', + 0x06: 'SmartForward', + 0x07: 'SmartReply', + 0x08: 'SaveInSentItems', + 0x09: 'ReplaceMime', + 0x0b: 'Source', + 0x0c: 'FolderId', + 0x0d: 'ItemId', + 0x0e: 'LongId', + 0x0f: 'InstanceId', + 0x10: 'Mime', + 0x11: 'ClientId', + 0x12: 'Status', + 0x13: 'AccountId', + 0x15: 'Forwardees', + 0x16: 'Forwardee', + 0x17: 'ForwardeeName', + 0x18: 'ForwardeeEmail' + }, + // Code Page 22: Email2 + { + 0x05: 'UmCallerID', + 0x06: 'UmUserNotes', + 0x07: 'UmAttDuration', + 0x08: 'UmAttOrder', + 0x09: 'ConversationId', + 0x0a: 'ConversationIndex', + 0x0b: 'LastVerbExecuted', + 0x0c: 'LastVerbExecutionTime', + 0x0d: 'ReceivedAsBcc', + 0x0e: 'Sender', + 0x0f: 'CalendarType', + 0x10: 'IsLeapMonth', + 0x11: 'AccountId', + 0x12: 'FirstDayOfWeek', + 0x13: 'MeetingMessageType', + 0x15: 'IsDraft', + 0x16: 'Bcc', + 0x17: 'Send' + }, + // Code Page 23: Notes + { + 0x05: 'Subject', + 0x06: 'MessageClass', + 0x07: 'LastModifiedDate', + 0x08: 'Categories', + 0x09: 'Category' + }, + // Code Page 24: RightsManagement + { + 0x05: 'RightsManagementSupport', + 0x06: 'RightsManagementTemplates', + 0x07: 'RightsManagementTemplate', + 0x08: 'RightsManagementLicense', + 0x09: 'EditAllowed', + 0x0a: 'ReplyAllowed', + 0x0b: 'ReplyAllAllowed', + 0x0c: 'ForwardAllowed', + 0x0d: 'ModifyRecipientsAllowed', + 0x0e: 'ExtractAllowed', + 0x0f: 'PrintAllowed', + 0x10: 'ExportAllowed', + 0x11: 'ProgrammaticAccessAllowed', + 0x12: 'Owner', + 0x13: 'ContentExpiryDate', + 0x14: 'TemplateID', + 0x15: 'TemplateName', + 0x16: 'TemplateDescription', + 0x17: 'ContentOwner', + 0x18: 'RemoveRightsManagementDistribution' + } + ], + + namespaces : [ + 'AirSync', + 'Contacts', + 'Email', + 'AirNotify', + 'Calendar', + 'Move', + 'GetItemEstimate', + 'FolderHierarchy', + 'MeetingResponse', + 'Tasks', + 'ResolveRecipients', + 'ValidateCert', + 'Contacts2', + 'Ping', + 'Provision', + 'Search', + 'Gal', + 'AirSyncBase', + 'Settings', + 'DocumentLibrary', + 'ItemOperations', + 'ComposeMail', + 'Email2', + 'Notes', + 'RightsManagement' + ] + +}; + +wbxmltools.buildCodepages2(); diff --git a/content/includes/xmltools.js b/content/includes/xmltools.js new file mode 100644 index 0000000..68b69d0 --- /dev/null +++ b/content/includes/xmltools.js @@ -0,0 +1,155 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + +var xmltools = { + + isString : function (obj) { + return (Object.prototype.toString.call(obj) === '[object String]'); + }, + + checkString : function(d, fallback = "") { + if (this.isString(d)) return d; + else return fallback; + }, + + nodeAsArray : function (node) { + let a = []; + if (node) { + //return, if already an array + if (node instanceof Array) return node; + + //else push node into an array + a.push(node); + } + return a; + }, + + hasWbxmlDataField: function(wbxmlData, path) { + if (wbxmlData) { + let pathElements = path.split("."); + let data = wbxmlData; + for (let x = 0; x < pathElements.length; x++) { + if (data[pathElements[x]]) data = data[pathElements[x]]; + else return false; + } + return true; + } + return false; + }, + + getWbxmlDataField: function(wbxmlData,path) { + if (wbxmlData) { + let pathElements = path.split("."); + let data = wbxmlData; + let valid = true; + for (let x = 0; valid && x < pathElements.length; x++) { + if (data[pathElements[x]]) data = data[pathElements[x]]; + else valid = false; + } + if (valid) return data; + } + return false + }, + + //print content of xml data object (if debug output enabled) + printXmlData : function (data, printApplicationData) { + if (TbSync.prefs.getIntPref("log.userdatalevel") > 1 || (TbSync.prefs.getIntPref("log.userdatalevel") == 1 && printApplicationData)) { + let dump = JSON.stringify(data); + TbSync.dump("Extracted XML data", "\n" + dump); + } + }, + + getDataFromXMLString: function (str) { + let data = null; + let xml = ""; + if (str == "") return data; + + let oParser = (Services.vc.compare(Services.appinfo.platformVersion, "61.*") >= 0) ? new DOMParser() : Components.classes["@mozilla.org/xmlextras/domparser;1"].createInstance(Components.interfaces.nsIDOMParser); + try { + xml = oParser.parseFromString(str, "application/xml"); + } catch (e) { + //however, domparser does not throw an error, it returns an error document + //https://developer.mozilla.org/de/docs/Web/API/DOMParser + //just in case + throw eas.sync.finish("error", "malformed-xml"); + } + + //check if xml is error document + if (xml.documentElement.nodeName == "parsererror") { + TbSync.dump("BAD XML", "The above XML and WBXML could not be parsed correctly, something is wrong."); + throw eas.sync.finish("error", "malformed-xml"); + } + + try { + data = this.getDataFromXML(xml); + } catch (e) { + throw eas.sync.finish("error", "mailformed-data"); + } + + return data; + }, + + //create data object from XML node + getDataFromXML : function (nodes) { + + /* + * The passed nodes value could be an entire document in a single node (type 9) or a + * single element node (type 1) as returned by getElementById. It could however also + * be an array of nodes as returned by getElementsByTagName or a nodeList as returned + * by childNodes. In that case node.length is defined. + */ + + // create the return object + let obj = {}; + let nodeList = []; + let multiplicity = {}; + + if (nodes.length === undefined) nodeList.push(nodes); + else nodeList = nodes; + + // nodelist contains all childs, if two childs have the same name, we cannot add the chils as an object, but as an array of objects + for (let node of nodeList) { + if (node.nodeType == 1 || node.nodeType == 3) { + if (!multiplicity.hasOwnProperty(node.nodeName)) multiplicity[node.nodeName] = 0; + multiplicity[node.nodeName]++; + //if this nodeName has multiplicity > 1, prepare obj (but only once) + if (multiplicity[node.nodeName]==2) obj[node.nodeName] = []; + } + } + + // process nodes + for (let node of nodeList) { + switch (node.nodeType) { + case 9: + //document node, dive directly and process all children + if (node.hasChildNodes) obj = this.getDataFromXML(node.childNodes); + break; + case 1: + //element node + if (node.hasChildNodes) { + //if this is an element with only one text child, do not dive, but get text childs value + let o; + if (node.childNodes.length == 1 && node.childNodes.item(0).nodeType==3) { + //the passed xml is a save xml with all special chars in the user data encoded by encodeURIComponent + o = decodeURIComponent(node.childNodes.item(0).nodeValue); + } else { + o = this.getDataFromXML(node.childNodes); + } + //check, if we can add the object directly, or if we have to push it into an array + if (multiplicity[node.nodeName]>1) obj[node.nodeName].push(o) + else obj[node.nodeName] = o; + } + break; + } + } + return obj; + } + +}; diff --git a/content/locales.js b/content/locales.js new file mode 100644 index 0000000..7bc8b43 --- /dev/null +++ b/content/locales.js @@ -0,0 +1,3 @@ +var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + +TbSync.localizeOnLoad(window, "eas"); diff --git a/content/manager/createAccount.js b/content/manager/createAccount.js new file mode 100644 index 0000000..72c266b --- /dev/null +++ b/content/manager/createAccount.js @@ -0,0 +1,244 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + "use strict"; + +var { TbSync } = ChromeUtils.import("chrome://tbsync/content/tbsync.jsm"); + +const eas = TbSync.providers.eas; + +var tbSyncEasNewAccount = { + + startTime: 0, + maxTimeout: 30, + validating: false, + + onClose: function () { + //disallow closing of wizard while validating + return !this.validating; + }, + + onCancel: function (event) { + //disallow closing of wizard while validating + if (this.validating) { + event.preventDefault(); + } + }, + + onLoad: function () { + this.providerData = new TbSync.ProviderData("eas"); + + this.elementName = document.getElementById('tbsync.newaccount.name'); + this.elementUser = document.getElementById('tbsync.newaccount.user'); + this.elementUrl = document.getElementById('tbsync.newaccount.url'); + this.elementPass = document.getElementById('tbsync.newaccount.password'); + this.elementServertype = document.getElementById('tbsync.newaccount.servertype'); + + document.getElementById("tbsync.newaccount.wizard").getButton("back").hidden = true; + this.onUserDropdown(); + + document.getElementById("tbsync.error").hidden = true; + document.getElementById("tbsync.spinner").hidden = true; + + document.addEventListener("wizardfinish", tbSyncEasNewAccount.onFinish.bind(this)); + document.addEventListener("wizardcancel", tbSyncEasNewAccount.onCancel.bind(this)); + // bug https://bugzilla.mozilla.org/show_bug.cgi?id=1618252 + document.getElementById('tbsync.newaccount.wizard')._adjustWizardHeader(); + }, + + onUnload: function () { + }, + + onUserTextInput: function () { + document.getElementById("tbsync.error").hidden = true; + switch (this.elementServertype.value) { + case "select": + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = true; + break; + + case "auto": + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = (this.elementName.value.trim() == "" || this.elementUser.value == "" || this.elementPass.value == ""); + break; + + case "office365": + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = (this.elementName.value.trim() == "" || this.elementUser.value == ""); + break; + + case "custom": + default: + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = (this.elementName.value.trim() == "" || this.elementUser.value == "" || this.elementPass.value == "" || this.elementUrl.value.trim() == ""); + break; + } + }, + + onUserDropdown: function () { + if (this.elementServertype) { + switch (this.elementServertype.value) { + case "select": + document.getElementById('tbsync.newaccount.user.box').hidden = true; + document.getElementById('tbsync.newaccount.url.box').hidden = true; + document.getElementById('tbsync.newaccount.password.box').hidden = true; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").label = TbSync.getString("newaccount.add_custom","eas"); + break; + + case "auto": + document.getElementById('tbsync.newaccount.user.box').hidden = false; + document.getElementById('tbsync.newaccount.url.box').hidden = true; + document.getElementById('tbsync.newaccount.password.box').hidden = false; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").label = TbSync.getString("newaccount.add_auto","eas"); + break; + + case "office365": + document.getElementById('tbsync.newaccount.user.box').hidden = false; + document.getElementById('tbsync.newaccount.url.box').hidden = true; + document.getElementById('tbsync.newaccount.password.box').hidden = true; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").label = TbSync.getString("newaccount.add_custom","eas"); + break; + + case "custom": + default: + document.getElementById('tbsync.newaccount.user.box').hidden = false; + document.getElementById('tbsync.newaccount.url.box').hidden = false; + document.getElementById('tbsync.newaccount.password.box').hidden = false; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").label = TbSync.getString("newaccount.add_custom","eas"); + break; + } + this.onUserTextInput(); + //document.getElementById("tbsync.newaccount.name").focus(); + } + }, + + onFinish: function (event) { + if (document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled == false) { + //initiate validation of server connection + this.validate(); + } + event.preventDefault(); + }, + + validate: async function () { + let user = this.elementUser.value; + let servertype = this.elementServertype.value; + let accountname = this.elementName.value.trim(); + + let url = (servertype == "custom") ?this.elementUrl.value.trim() : ""; + let password = (servertype == "auto" || servertype == "custom") ? this.elementPass.value : ""; + + if ((servertype == "auto" || servertype == "office365") && user.split("@").length != 2) { + alert(TbSync.getString("autodiscover.NeedEmail","eas")) + return; + } + + this.validating = true; + let error = ""; + + //document.getElementById("tbsync.newaccount.wizard").canRewind = false; + document.getElementById("tbsync.error").hidden = true; + document.getElementById("tbsync.newaccount.wizard").getButton("cancel").disabled = true; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = true; + document.getElementById("tbsync.newaccount.name").disabled = true; + document.getElementById("tbsync.newaccount.user").disabled = true; + document.getElementById("tbsync.newaccount.password").disabled = true; + document.getElementById("tbsync.newaccount.servertype").disabled = true; + + tbSyncEasNewAccount.startTime = Date.now(); + tbSyncEasNewAccount.updateAutodiscoverStatus(); + document.getElementById("tbsync.spinner").hidden = false; + + //do autodiscover + if (servertype == "office365" || servertype == "auto") { + let updateTimer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); + updateTimer.initWithCallback({notify : function () {tbSyncEasNewAccount.updateAutodiscoverStatus()}}, 1000, 3); + + if (servertype == "office365") { + let v2 = await eas.network.getServerConnectionViaAutodiscoverV2JsonRequest("https://autodiscover-s.outlook.com/autodiscover/autodiscover.json?Email="+encodeURIComponent(user)+"&Protocol=ActiveSync"); + let oauthData = eas.network.getOAuthObj({ host: v2.server, user, accountname, servertype }); + if (oauthData) { + // ask for token + document.getElementById("tbsync.spinner").hidden = true; + let _rv = {}; + if (await oauthData.asyncConnect(_rv)) { + password = _rv.tokens; + } else { + error = TbSync.getString("status." + _rv.error, "eas"); + } + document.getElementById("tbsync.spinner").hidden = false; + url=v2.server; + } else { + error = TbSync.getString("status.404", "eas"); + } + } else { + let result = await eas.network.getServerConnectionViaAutodiscover(user, password, tbSyncEasNewAccount.maxTimeout*1000); + if (result.server) { + user = result.user; + url = result.server; + } else { + error = result.error; // is a localized string + } + } + + updateTimer.cancel(); + } + + //now validate the information + if (!error) { + if (!password) error = TbSync.getString("status.401", "eas"); + } + + //add if valid + if (!error) { + tbSyncEasNewAccount.addAccount(user, password, servertype, accountname, url); + } + + //end validation + document.getElementById("tbsync.newaccount.name").disabled = false; + document.getElementById("tbsync.newaccount.user").disabled = false; + document.getElementById("tbsync.newaccount.password").disabled = false; + document.getElementById("tbsync.newaccount.servertype").disabled = false; + document.getElementById("tbsync.newaccount.wizard").getButton("cancel").disabled = false; + document.getElementById("tbsync.newaccount.wizard").getButton("finish").disabled = false; + document.getElementById("tbsync.spinner").hidden = true; + //document.getElementById("tbsync.newaccount.wizard").canRewind = true; + + this.validating = false; + + //close wizard, if done + if (!error) { + document.getElementById("tbsync.newaccount.wizard").cancel(); + } else { + document.getElementById("tbsync.error.message").textContent = error; + document.getElementById("tbsync.error").hidden = false; + } + }, + + updateAutodiscoverStatus: function () { + let offset = Math.round(((Date.now() - tbSyncEasNewAccount.startTime)/1000)); + let timeout = (offset>2) ? " (" + (tbSyncEasNewAccount.maxTimeout - offset) + ")" : ""; + + document.getElementById('tbsync.newaccount.autodiscoverstatus').value = TbSync.getString("autodiscover.Querying","eas") + timeout; + }, + + addAccount (user, password, servertype, accountname, url) { + let newAccountEntry = this.providerData.getDefaultAccountEntries(); + newAccountEntry.user = user; + newAccountEntry.servertype = servertype; + + if (url) { + //if no protocoll is given, prepend "https://" + if (url.substring(0,4) != "http" || url.indexOf("://") == -1) url = "https://" + url.split("://").join("/"); + newAccountEntry.host = eas.network.stripAutodiscoverUrl(url); + newAccountEntry.https = (url.substring(0,5) == "https"); + } + + // Add the new account. + let newAccountData = this.providerData.addAccount(accountname, newAccountEntry); + eas.network.getAuthData(newAccountData).updateLoginData(user, password); + + window.close(); + } +}; diff --git a/content/manager/createAccount.xhtml b/content/manager/createAccount.xhtml new file mode 100644 index 0000000..73acf13 --- /dev/null +++ b/content/manager/createAccount.xhtml @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="UTF-8"?> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="tbSyncEasNewAccount.onLoad();" + onunload="tbSyncEasNewAccount.onUnload();" + onclose="return tbSyncEasNewAccount.onClose()" > + + <linkset> + <html:link rel="localization" href="toolkit/global/wizard.ftl"/> + </linkset> + + <script type="application/javascript" src="chrome://eas4tbsync/content/manager/createAccount.js"/> + <script type="application/javascript" src="chrome://eas4tbsync/content/locales.js"/> + + <wizard + title="__EAS4TBSYNCMSG_add.title__" + id="tbsync.newaccount.wizard"> + + <wizardpage onFirstPage="true" label="__EAS4TBSYNCMSG_add.shortdescription__"> + <description style="width: 350px">__EAS4TBSYNCMSG_add.description__</description> + + <richlistbox height="250" id="tbsync.newaccount.servertype" seltype="single" style="margin-top:1ex" onselect="tbSyncEasNewAccount.onUserDropdown();"> + <richlistitem value="auto" style="padding: 4px"> + <vbox><image src="chrome://eas4tbsync/content/skin/eas32.png" style="margin:1ex" /></vbox> + <vbox flex="1"> + <label class="header" value="__EAS4TBSYNCMSG_servertype.auto__" /> + <description>__EAS4TBSYNCMSG_servertype.description.auto__</description> + </vbox> + </richlistitem> + + <richlistitem value="custom" style="padding: 4px"> + <vbox><image src="chrome://eas4tbsync/content/skin/eas32.png" style="margin:1ex" /></vbox> + <vbox flex="1"> + <label class="header" value="__EAS4TBSYNCMSG_servertype.custom__" /> + <description>__EAS4TBSYNCMSG_servertype.description.custom__</description> + </vbox> + </richlistitem> + + <richlistitem value="office365" style="padding: 4px"> + <vbox><image src="chrome://eas4tbsync/content/skin/365_32.png" style="margin:1ex" /></vbox> + <vbox flex="1"> + <label class="header" value="__EAS4TBSYNCMSG_servertype.office365__" /> + <description>__EAS4TBSYNCMSG_servertype.description.office365__</description> + </vbox> + </richlistitem> + </richlistbox> + + <html:table style="margin-top:1em"> + <html:tr> + <html:td width="33%"><vbox pack="center"><label value="__EAS4TBSYNCMSG_add.name__" /></vbox></html:td> + <html:td width="67%"><html:input id="tbsync.newaccount.name" oninput="tbSyncEasNewAccount.onUserTextInput();"/></html:td> + </html:tr> + <html:tr id="tbsync.newaccount.user.box"> + <html:td><vbox pack="center"><label value="__EAS4TBSYNCMSG_add.user__" /></vbox></html:td> + <html:td><html:input id="tbsync.newaccount.user" oninput="tbSyncEasNewAccount.onUserTextInput();"/></html:td> + </html:tr> + <html:tr id="tbsync.newaccount.password.box"> + <html:td><vbox pack="center"><label value="__EAS4TBSYNCMSG_add.password__" /></vbox></html:td> + <html:td><html:input id="tbsync.newaccount.password" type="password" oninput="tbSyncEasNewAccount.onUserTextInput();"/></html:td> + </html:tr> + <html:tr id="tbsync.newaccount.url.box"> + <html:td><vbox pack="center"><label value="__EAS4TBSYNCMSG_add.url__" /></vbox></html:td> + <html:td><html:input id="tbsync.newaccount.url" oninput="tbSyncEasNewAccount.onUserTextInput();" tooltiptext="__EAS4TBSYNCMSG_add.urldescription__"/></html:td> + </html:tr> + + <!--html:tr style="height:40px; margin-top:1ex;margin-bottom:1ex;"> + <html:td><vbox pack="center"></vbox></html:td> + <html:td><vbox pack="center"></vbox></html:td> + </html:tr--> + </html:table> + + <vbox flex="1"> + </vbox> + <hbox id="tbsync.spinner"> + <image src="chrome://tbsync/content/skin/spinner.gif" style="margin-left:1em" width="16" height="16"/> + <label id='tbsync.newaccount.autodiscoverstatus' value="" /> + </hbox> + + <vbox id="tbsync.error" style="width: 450px;"> + <description id="tbsync.error.message" flex="1" style="font-weight: bold;"></description> + <vbox> + <button + id="tbsync.error.link" + label="__EAS4TBSYNCMSG_manager.ShowEventLog__" + oncommand="TbSync.eventlog.open();"/> + </vbox> + </vbox> + + + </wizardpage> + + </wizard> + +</window> diff --git a/content/manager/editAccountOverlay.js b/content/manager/editAccountOverlay.js new file mode 100644 index 0000000..7fdc18f --- /dev/null +++ b/content/manager/editAccountOverlay.js @@ -0,0 +1,56 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +const eas = TbSync.providers.eas; + +var tbSyncEditAccountOverlay = { + + onload: function (window, accountData) { + this.accountData = accountData; + + // special treatment for configuration label, which is a permanent setting and will not change by switching modes + let configlabel = window.document.getElementById("tbsync.accountsettings.label.config"); + if (configlabel) { + configlabel.setAttribute("value", TbSync.getString("config.custom", "eas")); + } + }, + + stripHost: function (document) { + let host = document.getElementById('tbsync.AccountPropertys.pref.host').value; + if (host.indexOf("https://") == 0) { + host = host.replace("https://",""); + document.getElementById('tbsync.AccountPropertys.pref.https').checked = true; + this.accountData.setAccountProperty("https", true); + } else if (host.indexOf("http://") == 0) { + host = host.replace("http://",""); + document.getElementById('tbsync.AccountPropertys.pref.https').checked = false; + this.accountData.setAccountProperty("https", false); + } + + while (host.endsWith("/")) { host = host.slice(0,-1); } + document.getElementById('tbsync.AccountPropertys.pref.host').value = host + this.accountData.setAccountProperty("host", host); + }, + + deleteFolder: function() { + let folderList = document.getElementById("tbsync.accountsettings.folderlist"); + if (folderList.selectedItem !== null && !folderList.disabled) { + let folderData = folderList.selectedItem.folderData; + + //only trashed folders can be purged (for example O365 does not show deleted folders but also does not allow to purge them) + if (!eas.tools.parentIsTrash(folderData)) return; + + if (folderData.getFolderProperty("selected")) window.alert(TbSync.getString("deletefolder.notallowed::" + folderData.getFolderProperty("foldername"), "eas")); + else if (window.confirm(TbSync.getString("deletefolder.confirm::" + folderData.getFolderProperty("foldername"), "eas"))) { + folderData.sync({syncList: false, syncJob: "deletefolder"}); + } + } + } +}; diff --git a/content/manager/editAccountOverlay.xhtml b/content/manager/editAccountOverlay.xhtml new file mode 100644 index 0000000..dc37731 --- /dev/null +++ b/content/manager/editAccountOverlay.xhtml @@ -0,0 +1,154 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://tbsync/content/skin/fix_dropdown_1534697.css" type="text/css"?> + +<overlay + id="tbSyncAccountConfig" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script type="application/javascript" src="chrome://eas4tbsync/content/manager/editAccountOverlay.js"/> + <script type="application/javascript" src="chrome://eas4tbsync/content/locales.js"/> + + <menuitem + id="TbSync.eas.FolderListContextMenuDelete" + label="__EAS4TBSYNCMSG_pref.ShowTrashedFolders__" + class="menuitem-iconic" + image="chrome://tbsync/content/skin/trash16.png" + appendto="tbsync.accountsettings.FolderListContextMenu" + oncommand="tbSyncEditAccountOverlay.deleteFolder();"/> + + <tab id="manager.tabs.accountsettings" label="__EAS4TBSYNCMSG_manager.tabs.accountsettings__" appendto="manager.tabs" /> + <tab id="manager.tabs.syncsettings" label="__EAS4TBSYNCMSG_manager.tabs.syncsettings__" appendto="manager.tabs" /> + + <tabpanel id="manager.tabpanels.accountsettings" appendto="manager.tabpanels" flex="1" orient="vertical"><!-- ACCOUNT SETTINGS --> + <vbox flex="1"> + <label class="header lockIfConnected" style="margin-left:0; margin-bottom:1ex;" value="" id="tbsync.accountsettings.label.config" /> + <html:table flex="1"> + <html:tr> + <html:td style="margin-right:1ex;"> + <vbox pack="center"> + <label style="text-align:left" control="tbsync.accountsettings.pref.accountname" value="__EAS4TBSYNCMSG_pref.AccountName__" /> + </vbox> + </html:td> + <html:td width="100%"> + <html:input id="tbsync.accountsettings.pref.accountname" /> + </html:td> + </html:tr> + + <html:tr> + <html:td style="margin-right:1ex;" > + <vbox pack="center"> + <label class="lockIfConnected" style="text-align:left" control="tbsync.accountsettings.pref.user" value="__EAS4TBSYNCMSG_pref.UserName__" /> + </vbox> + </html:td> + <html:td> + <html:input class="lockIfConnected" tooltiptext="__EAS4TBSYNCMSG_pref.UserNameDescription__" id="tbsync.accountsettings.pref.user" /> + </html:td> + </html:tr> + + <html:tr> + <html:td style="margin-right:1ex;" > + <vbox pack="center"> + <label class="lockIfConnected" style="text-align:left" control="tbsync.accountsettings.pref.host" value="__EAS4TBSYNCMSG_pref.ServerName__" /> + </vbox> + </html:td> + <html:td> + <html:input class="lockIfConnected" tooltiptext="__EAS4TBSYNCMSG_pref.ServerNameDescription__" id="tbsync.accountsettings.pref.host" onblur="tbSyncEditAccountOverlay.stripHost(document);" /> + </html:td> + </html:tr> + + <html:tr> + <html:td style="margin-right:1ex;" > + <hbox/> + </html:td> + <html:td> + <checkbox class="lockIfConnected" style="margin-bottom:1ex" id="tbsync.accountsettings.pref.https" label="__EAS4TBSYNCMSG_pref.usehttps__" /> + </html:td> + </html:tr> + + <html:tr> + <html:td style="margin-right:1ex;" > + <vbox id="asversion.hook" pack="center"> + <label class="lockIfConnected" style="text-align:left" control="tbsync.accountsettings.pref.asversionselected" value="__EAS4TBSYNCMSG_pref.ActiveSyncVersion__" /> + </vbox> + </html:td> + <html:td> + <menulist flex="0" id="tbsync.accountsettings.pref.asversionselected" class="lockIfConnected"> + <menupopup id="asversion.popup"> + <menuitem label="__EAS4TBSYNCMSG_pref.autodetect__" value="auto" /> + <menuitem label="v2.5" value="2.5" /> + <menuitem label="v14.0" value="14.0" /> + </menupopup> + </menulist> + </html:td> + </html:tr> + + <html:tr> + <html:td style="margin-right:1ex;" > + <label class="lockIfConnected" style="text-align:left" value="__EAS4TBSYNCMSG_pref.DeviceId__" /> + </html:td> + <html:td> + <label value="" disabled="true" id="tbsync.accountsettings.pref.deviceId" /> + </html:td> + </html:tr> + </html:table> + + <vbox flex="1" /> + <vbox class="showIfConnected"> + <hbox> + <vbox pack="center"><image src="chrome://tbsync/content/skin/info16.png" /></vbox> + <description flex="1">__EAS4TBSYNCMSG_manager.lockedsettings.description__</description> + </hbox> + </vbox> + + </vbox> + </tabpanel> + + <tabpanel id="manager.tabpanels.syncsettings" appendto="manager.tabpanels" flex="1" orient="vertical"><!-- SYNC SETTINGS --> + <vbox flex="1"> + <label style="margin-left:0; margin-bottom: 1ex;" class="header lockIfConnected" value="__EAS4TBSYNCMSG_pref.generaloptions__"/> + <vbox> + <checkbox class="lockIfConnected" id="tbsync.accountsettings.pref.provision" label="__EAS4TBSYNCMSG_pref.provision__" /> + </vbox> + + <label style="margin-left:0; margin-bottom: 1ex; margin-top: 3ex" class="header lockIfConnected" value="__EAS4TBSYNCMSG_pref.contactoptions__"/> + <vbox> + <checkbox class="lockIfConnected" id="tbsync.accountsettings.pref.displayoverride" label="__EAS4TBSYNCMSG_pref.displayoverride__" /> + <hbox align="center"> + <description id="separator.hook" class="lockIfConnected" flex="0">__EAS4TBSYNCMSG_pref.seperator.description__</description> + <menulist id="tbsync.accountsettings.pref.seperator" class="lockIfConnected"> + <menupopup id="separator.popup"> + <menuitem label="__EAS4TBSYNCMSG_pref.seperator.linebreak__" value="10" /> + <menuitem label="__EAS4TBSYNCMSG_pref.seperator.comma__" value="44" /> + </menupopup> + </menulist> + </hbox> + </vbox> + + <label style="margin-left:0; margin-bottom: 1ex; margin-top: 3ex" class="header lockIfConnected" value="__EAS4TBSYNCMSG_pref.calendaroptions__"/> + <vbox> + <hbox align="center"> + <description id="synclimit.hook" class="lockIfConnected" flex="0">__EAS4TBSYNCMSG_pref.synclimit.description__</description> + <menulist id="tbsync.accountsettings.pref.synclimit" class="lockIfConnected"> + <menupopup id="synclimit.popup"> + <menuitem label="__EAS4TBSYNCMSG_pref.synclimit.all__" value="0" /> + <menuitem label="__EAS4TBSYNCMSG_pref.synclimit.2weeks__" value="4" /> + <menuitem label="__EAS4TBSYNCMSG_pref.synclimit.1month__" value="5" /> + <menuitem label="__EAS4TBSYNCMSG_pref.synclimit.3month__" value="6" /> + <menuitem label="__EAS4TBSYNCMSG_pref.synclimit.6month__" value="7" /> + </menupopup> + </menulist> + </hbox> + </vbox> + + + <vbox flex="1" /> + <vbox class="showIfConnected"> + <hbox> + <vbox pack="center"><image src="chrome://tbsync/content/skin/info16.png" /></vbox> + <description flex="1">__EAS4TBSYNCMSG_manager.lockedsettings.description__</description> + </hbox> + </vbox> + </vbox> + </tabpanel> + +</overlay> diff --git a/content/provider.js b/content/provider.js new file mode 100644 index 0000000..de5af36 --- /dev/null +++ b/content/provider.js @@ -0,0 +1,729 @@ +/* + * This file is part of EAS-4-TbSync. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +// Every object in here will be loaded into TbSync.providers.<providername>. +const eas = TbSync.providers.eas; + +eas.prefs = Services.prefs.getBranch("extensions.eas4tbsync."); + +//use flags instead of strings to avoid errors due to spelling errors +eas.flags = Object.freeze({ + allowEmptyResponse: true, +}); + +eas.windowsToIanaTimezoneMap = {}; +eas.ianaToWindowsTimezoneMap = {}; +eas.cachedTimezoneData = null; +eas.defaultTimezoneInfo = null; +eas.defaultTimezone = null; +eas.utcTimezone = null; + + +/** + * Implementing the TbSync interface for external provider extensions. + */ +var Base = class { + /** + * Called during load of external provider extension to init provider. + */ + static async load() { + // Set default prefs + let branch = Services.prefs.getDefaultBranch("extensions.eas4tbsync."); + branch.setIntPref("timeout", 90000); + branch.setIntPref("maxitems", 50); + branch.setBoolPref("msTodoCompat", false); + branch.setCharPref("clientID.type", "TbSync"); + branch.setCharPref("clientID.useragent", "Thunderbird ActiveSync"); + branch.setCharPref("oauth.clientID", ""); + + eas.defaultTimezone = null; + eas.utcTimezone = null; + eas.defaultTimezoneInfo = null; + eas.windowsToIanaTimezoneMap = {}; + eas.openWindows = {}; + + try { + // Create a basic error info (no accountname or foldername, just the provider) + let eventLogInfo = new TbSync.EventLogInfo("eas"); + + //get timezone info of default timezone (old cal. without dtz are depricated) + eas.defaultTimezone = (TbSync.lightning.cal.dtz && TbSync.lightning.cal.dtz.defaultTimezone) ? TbSync.lightning.cal.dtz.defaultTimezone : TbSync.lightning.cal.calendarDefaultTimezone(); + eas.utcTimezone = (TbSync.lightning.cal.dtz && TbSync.lightning.cal.dtz.UTC) ? TbSync.lightning.cal.dtz.UTC : TbSync.lightning.cal.UTC(); + if (eas.defaultTimezone && eas.defaultTimezone.icalComponent) { + TbSync.eventlog.add("info", eventLogInfo, "Default timezone has been found."); + } else { + TbSync.eventlog.add("info", eventLogInfo, "Default timezone is not defined, using UTC!"); + eas.defaultTimezone = eas.utcTimezone; + } + + eas.defaultTimezoneInfo = eas.tools.getTimezoneInfo(eas.defaultTimezone); + if (!eas.defaultTimezoneInfo) { + TbSync.eventlog.add("info", eventLogInfo, "Could not create defaultTimezoneInfo"); + } + + //get windows timezone data from CSV + let aliasData = await eas.tools.fetchFile("chrome://eas4tbsync/content/timezonedata/Aliases.csv"); + let aliasNames = {}; + for (let i = 0; i<aliasData.length; i++) { + let lData = aliasData[i].split(","); + if (lData.length<2) continue; + aliasNames[lData[0].toString().trim()] = lData[1].toString().trim().split(" "); + } + + let csvData = await eas.tools.fetchFile("chrome://eas4tbsync/content/timezonedata/WindowsTimezone.csv"); + for (let i = 0; i<csvData.length; i++) { + let lData = csvData[i].split(","); + if (lData.length<3) continue; + + let windowsZoneName = lData[0].toString().trim(); + let zoneType = lData[1].toString().trim(); + let ianaZoneName = lData[2].toString().trim(); + + if (zoneType == "001") eas.windowsToIanaTimezoneMap[windowsZoneName] = ianaZoneName; + if (ianaZoneName == eas.defaultTimezoneInfo.std.id) eas.defaultTimezoneInfo.std.windowsZoneName = windowsZoneName; + + // build the revers map as well, which is many-to-one, grap iana aliases from the csvData and from the aliasData + // 1. multiple iana zones map to the same windows zone + let ianaZones = ianaZoneName.split(" "); + for (let ianaZone of ianaZones) { + eas.ianaToWindowsTimezoneMap[ianaZone] = windowsZoneName; + if (aliasNames.hasOwnProperty(ianaZone)) { + for (let aliasName of aliasNames[ianaZone]) { + // 2. multiple iana zonescan be an alias to a main iana zone + eas.ianaToWindowsTimezoneMap[aliasName] = windowsZoneName; + } + } + } + } + + let tzService = TbSync.lightning.cal.timezoneService; + for (let timezoneId of tzService.timezoneIds) { + if (!eas.ianaToWindowsTimezoneMap[timezoneId]) { + TbSync.eventlog.add("info", eventLogInfo, "The IANA timezone <"+timezoneId+"> cannot be mapped to any Exchange timezone."); + } + } + + //If an EAS calendar is currently NOT associated with an email identity, try to associate, + //but do not change any explicitly set association + // - A) find email identity and associate (which sets organizer to that user identity) + // - B) overwrite default organizer with current best guess + //TODO: Do this after email accounts changed, not only on restart? + let providerData = new TbSync.ProviderData("eas"); + let folders = providerData.getFolders({"selected": true, "type": ["8","13"]}); + for (let folder of folders) { + let manager = TbSync.lightning.cal.manager; + let calendar = manager.getCalendarById(folder.getFolderProperty("target")); + if (calendar && calendar.getProperty("imip.identity.key") == "") { + //is there an email identity for this eas account? + let authData = eas.network.getAuthData(folder.accountData); + + let key = eas.tools.getIdentityKey(authData.user); + if (key === "") { //TODO: Do this even after manually switching to NONE, not only on restart? + //set transient calendar organizer settings based on current best guess and + calendar.setProperty("organizerId", TbSync.lightning.cal.email.prependMailTo(authData.user)); + calendar.setProperty("organizerCN", calendar.getProperty("fallbackOrganizerName")); + } else { + //force switch to found identity + calendar.setProperty("imip.identity.key", key); + } + } + } + } catch(e) { + Components.utils.reportError(e); + } + } + + + /** + * Called during unload of external provider extension to unload provider. + */ + static async unload() { + // Close all open windows of this provider. + for (let id in eas.openWindows) { + if (eas.openWindows.hasOwnProperty(id)) { + try { + eas.openWindows[id].close(); + } catch(e) { + //NOOP + } + } + } + } + + + /** + * Returns string for the name of provider for the add account menu. + */ + static getProviderName() { + return "Exchange ActiveSync"; + } + + + /** + * Returns version of the TbSync API this provider is using + */ + static getApiVersion() { return "2.5"; } + + + /** + * Returns location of a provider icon. + */ + static getProviderIcon(size, accountData = null) { + let base = (accountData && accountData.getAccountProperty("servertype") == "office365") ? "365_" : "eas"; + + switch (size) { + case 16: + return "chrome://eas4tbsync/content/skin/" + base + "16.png"; + case 32: + return "chrome://eas4tbsync/content/skin/" + base + "32.png"; + default : + return "chrome://eas4tbsync/content/skin/" + base + "64.png"; + } + } + + + /** + * Returns a list of sponsors, they will be sorted by the index + */ + static getSponsors() { + return { + "Schiessl, Michael 1" : {name: "Michael Schiessl", description: "Tine 2.0", icon: "", link: "" }, + "Schiessl, Michael 2" : {name: "Michael Schiessl", description: " Exchange 2007", icon: "", link: "" }, + "netcup GmbH" : {name: "netcup GmbH", description : "SOGo", icon: "chrome://eas4tbsync/content/skin/sponsors/netcup.png", link: "http://www.netcup.de/" }, + "nethinks GmbH" : {name: "nethinks GmbH", description : "Zarafa", icon: "chrome://eas4tbsync/content/skin/sponsors/nethinks.png", link: "http://www.nethinks.com/" }, + "Jau, Stephan" : {name: "Stephan Jau", description: "Horde", icon: "", link: "" }, + "Zavar " : {name: "Zavar", description: "Zoho", icon: "", link: "" }, + }; + } + + + /** + * Returns the url of a page with details about contributors (used in the manager UI) + */ + static getContributorsUrl() { + return "https://github.com/jobisoft/EAS-4-TbSync/blob/master/CONTRIBUTORS.md"; + } + + + /** + * Returns the email address of the maintainer (used for bug reports). + */ + static getMaintainerEmail() { + return "john.bieling@gmx.de"; + } + + + /** + * Returns URL of the new account window. + * + * The URL will be opened via openDialog(), when the user wants to create a + * new account of this provider. + */ + static getCreateAccountWindowUrl() { + return "chrome://eas4tbsync/content/manager/createAccount.xhtml"; + } + + + /** + * Returns overlay XUL URL of the edit account dialog + * (chrome://tbsync/content/manager/editAccount.xhtml) + */ + static getEditAccountOverlayUrl() { + return "chrome://eas4tbsync/content/manager/editAccountOverlay.xhtml"; + } + + + /** + * Return object which contains all possible fields of a row in the + * accounts database with the default value if not yet stored in the + * database. + */ + static getDefaultAccountEntries() { + let row = { + "policykey" : "0", + "foldersynckey" : "0", + "deviceId" : eas.tools.getNewDeviceId(), + "asversionselected" : "auto", + "asversion" : "", + "host" : "", + "user" : "", + "servertype" : "", + "seperator" : "10", + "https" : true, + "provision" : false, + "displayoverride" : false, + "lastEasOptionsUpdate":"0", + "allowedEasVersions": "", + "allowedEasCommands": "", + "useragent": eas.prefs.getCharPref("clientID.useragent"), + "devicetype": eas.prefs.getCharPref("clientID.type"), + "synclimit" : "7", + }; + return row; + } + + + /** + * Return object which contains all possible fields of a row in the folder + * database with the default value if not yet stored in the database. + */ + static getDefaultFolderEntries() { + let folder = { + "type" : "", + "synckey" : "", + "target" : "", + "targetColor" : "", + "targetName" : "", + "parentID" : "0", + "serverID" : "", + }; + return folder; + } + + + /** + * Is called everytime an account of this provider is enabled in the + * manager UI. + */ + static onEnableAccount(accountData) { + accountData.resetAccountProperty("policykey"); + accountData.resetAccountProperty("foldersynckey"); + accountData.resetAccountProperty("lastEasOptionsUpdate"); + accountData.resetAccountProperty("lastsynctime"); + } + + + /** + * Is called everytime an account of this provider is disabled in the + * manager UI. + * + * @param accountData [in] AccountData + */ + static onDisableAccount(accountData) { + } + + + /** + * Is called everytime an account of this provider is deleted in the + * manager UI. + */ + static onDeleteAccount(accountData) { + eas.network.getAuthData(accountData).removeLoginData(); + } + + + /** + * Returns all folders of the account, sorted in the desired order. + * The most simple implementation is to return accountData.getAllFolders(); + */ + static getSortedFolders(accountData) { + let allowedTypesOrder = ["9","14","8","13","7","15"]; + + function getIdChain (aServerID) { + let serverID = aServerID; + let chain = []; + let folder; + let rootType = ""; + + // create sort string so that child folders are directly below their parent folders + do { + folder = accountData.getFolder("serverID", serverID); + if (folder) { + chain.unshift(folder.getFolderProperty("foldername")); + serverID = folder.getFolderProperty("parentID"); + rootType = folder.getFolderProperty("type"); + } + } while (folder && serverID != "0") + + // different folder types are grouped and trashed folders at the end + let pos = allowedTypesOrder.indexOf(rootType); + chain.unshift(pos == -1 ? "ZZZ" : pos.toString().padStart(3,"0")); + + return chain.join("."); + }; + + let toBeSorted = []; + let folders = accountData.getAllFolders(); + for (let f of folders) { + if (!allowedTypesOrder.includes(f.getFolderProperty("type"))) { + continue; + } + toBeSorted.push({"key": getIdChain(f.getFolderProperty("serverID")), "folder": f}); + } + + //sort + toBeSorted.sort(function(a,b) { + return a.key > b.key; + }); + + let sortedFolders = []; + for (let sortObj of toBeSorted) { + sortedFolders.push(sortObj.folder); + } + return sortedFolders; + } + + + /** + * Return the connection timeout for an active sync, so TbSync can append + * a countdown to the connection timeout, while waiting for an answer from + * the server. Only syncstates which start with "send." will trigger this. + */ + static getConnectionTimeout(accountData) { + return eas.prefs.getIntPref("timeout"); + } + + + /** + * Is called if TbSync needs to synchronize the folder list. + */ + static async syncFolderList(syncData, syncJob, syncRunNr) { + // Recommendation: Put the actual function call inside a try catch, to + // ensure returning a proper StatusData object, regardless of what + // happens inside that function. You may also throw custom errors + // in that function, which have the StatusData obj attached, which + // should be returned. + + try { + await eas.sync.folderList(syncData); + } catch (e) { + if (e.name == "eas4tbsync") { + return e.statusData; + } else { + Components.utils.reportError(e); + // re-throw any other error and let TbSync handle it + throw (e); + } + } + + // Fall through, if there was no error. + return new TbSync.StatusData(); + } + + + /** + * Is called if TbSync needs to synchronize a folder. + */ + static async syncFolder(syncData, syncJob, syncRunNr) { + // Recommendation: Put the actual function call inside a try catch, to + // ensure returning a proper StatusData object, regardless of what + // happens inside that function. You may also throw custom errors + // in that function, which have the StatusData obj attached, which + // should be returned. + + try { + switch (syncJob) { + case "deletefolder": + await eas.sync.deleteFolder(syncData); + break; + default: + await eas.sync.singleFolder(syncData); + } + } catch (e) { + if (e.name == "eas4tbsync") { + return e.statusData; + } else { + Components.utils.reportError(e); + // re-throw any other error and let TbSync handle it + throw (e); + } + } + + // Fall through, if there was no error. + return new TbSync.StatusData(); + } + + + /** + * Return the custom OAuth2 ClientID. + */ + static getCustomeOauthClientID() { + return eas.prefs.getCharPref("oauth.clientID"); + } +} + + + + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +// * TargetData implementation +// * Using TbSyncs advanced address book TargetData +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +var TargetData_addressbook = class extends TbSync.addressbook.AdvancedTargetData { + constructor(folderData) { + super(folderData); + } + + get primaryKeyField() { + return "X-EAS-SERVERID"; + } + + generatePrimaryKey() { + return TbSync.generateUUID(); + } + + // enable or disable changelog + get logUserChanges() { + return true; + } + + directoryObserver(aTopic) { + switch (aTopic) { + case "addrbook-removed": + case "addrbook-updated": + //Services.console.logStringMessage("["+ aTopic + "] " + this.folderData.getFolderProperty("foldername")); + break; + } + } + + cardObserver(aTopic, abCardItem) { + switch (aTopic) { + case "addrbook-contact-updated": + case "addrbook-contact-removed": + //Services.console.logStringMessage("["+ aTopic + "] " + abCardItem.getProperty("DisplayName")); + break; + + case "addrbook-contact-created": + { + //Services.console.logStringMessage("["+ aTopic + "] "+ abCardItem.getProperty("DisplayName")+">"); + break; + } + } + } + + listObserver(aTopic, abListItem, abListMember) { + switch (aTopic) { + case "addrbook-list-member-added": + case "addrbook-list-member-removed": + //Services.console.logStringMessage("["+ aTopic + "] MemberName: " + abListMember.getProperty("DisplayName")); + break; + + case "addrbook-list-removed": + case "addrbook-list-updated": + //Services.console.logStringMessage("["+ aTopic + "] ListName: " + abListItem.getProperty("ListName")); + break; + + case "addrbook-list-created": + //Services.console.logStringMessage("["+ aTopic + "] ListName: "+abListItem.getProperty("ListName")+">"); + break; + } + } + + async createAddressbook(newname) { + // https://searchfox.org/comm-central/source/mailnews/addrbook/src/nsDirPrefs.h + let dirPrefId = MailServices.ab.newAddressBook(newname, "", 101); + let directory = MailServices.ab.getDirectoryFromId(dirPrefId); + + eas.sync.resetFolderSyncInfo(this.folderData); + + if (directory && directory instanceof Components.interfaces.nsIAbDirectory && directory.dirPrefId == dirPrefId) { + directory.setStringValue("tbSyncIcon", "eas" + (this.folderData.accountData.getAccountProperty("servertype") == "office365" ? "_365" : "")); + return directory; + } + return null; + } +} + + + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +// * TargetData implementation +// * Using TbSyncs advanced calendar TargetData +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +var TargetData_calendar = class extends TbSync.lightning.AdvancedTargetData { + constructor(folderData) { + super(folderData); + } + + // The calendar target does not support a custom primaryKeyField, because + // the lightning implementation only allows to search for items via UID. + // Like the addressbook target, the calendar target item element has a + // primaryKey getter/setter which - however - only works on the UID. + + // enable or disable changelog + get logUserChanges() { + return true; + } + + calendarObserver(aTopic, tbCalendar, aPropertyName, aPropertyValue, aOldPropertyValue) { + switch (aTopic) { + case "onCalendarPropertyChanged": + //Services.console.logStringMessage("["+ aTopic + "] " + tbCalendar.calendar.name + " : " + aPropertyName); + break; + + case "onCalendarDeleted": + case "onCalendarPropertyDeleted": + //Services.console.logStringMessage("["+ aTopic + "] " +tbCalendar.calendar.name); + break; + } + } + + itemObserver(aTopic, tbItem, tbOldItem) { + switch (aTopic) { + case "onAddItem": + case "onModifyItem": + case "onDeleteItem": + //Services.console.logStringMessage("["+ aTopic + "] " + tbItem.nativeItem.title); + break; + } + } + + async createCalendar(newname) { + let calManager = TbSync.lightning.cal.manager; + + //Create the new standard calendar with a unique name + let newCalendar = calManager.createCalendar("storage", Services.io.newURI("moz-storage-calendar://")); + newCalendar.id = TbSync.lightning.cal.getUUID(); + newCalendar.name = newname; + + eas.sync.resetFolderSyncInfo(this.folderData); + + newCalendar.setProperty("color", this.folderData.getFolderProperty("targetColor")); + newCalendar.setProperty("relaxedMode", true); //sometimes we get "generation too old for modifyItem", check can be disabled with relaxedMode + // removed in TB78, as it seems to not fully enable the calendar, if present before registering + // https://searchfox.org/comm-central/source/calendar/base/content/calendar-management.js#385 + //newCalendar.setProperty("calendar-main-in-composite",true); + newCalendar.setProperty("readOnly", this.folderData.getFolderProperty("downloadonly")); + + switch (this.folderData.getFolderProperty("type")) { + case "8": //event + case "13": + newCalendar.setProperty("capabilities.tasks.supported", false); + newCalendar.setProperty("capabilities.events.supported", true); + break; + case "7": //todo + case "15": + newCalendar.setProperty("capabilities.tasks.supported", true); + newCalendar.setProperty("capabilities.events.supported", false); + break; + default: + newCalendar.setProperty("capabilities.tasks.supported", false); + newCalendar.setProperty("capabilities.events.supported", false); + } + + calManager.registerCalendar(newCalendar); + + let authData = eas.network.getAuthData(this.folderData.accountData); + + //is there an email identity we can associate this calendar to? + //getIdentityKey returns "" if none found, which removes any association + let key = eas.tools.getIdentityKey(authData.user); + newCalendar.setProperty("fallbackOrganizerName", newCalendar.getProperty("organizerCN")); + newCalendar.setProperty("imip.identity.key", key); + if (key === "") { + //there is no matching email identity - use current default value as best guess and remove association + //use current best guess + newCalendar.setProperty("organizerCN", newCalendar.getProperty("fallbackOrganizerName")); + newCalendar.setProperty("organizerId", TbSync.lightning.cal.email.prependMailTo(authData.user)); + } + + return newCalendar; + } +} + + + + + +/** + * This provider is implementing the StandardFolderList class instead of + * the FolderList class. + */ +var StandardFolderList = class { + /** + * Is called before the context menu of the folderlist is shown, allows to + * show/hide custom menu options based on selected folder. During an active + * sync, folderData will be null. + */ + static onContextMenuShowing(window, folderData) { + let hideContextMenuDelete = true; + if (folderData !== null) { + //if a folder in trash is selected, also show ContextMenuDelete (but only if FolderDelete is allowed) + if (eas.tools.parentIsTrash(folderData) && folderData.accountData.getAccountProperty("allowedEasCommands").split(",").includes("FolderDelete")) { + hideContextMenuDelete = false; + window.document.getElementById("TbSync.eas.FolderListContextMenuDelete").label = TbSync.getString("deletefolder.menuentry::" + folderData.getFolderProperty("foldername"), "eas"); + } + } + window.document.getElementById("TbSync.eas.FolderListContextMenuDelete").hidden = hideContextMenuDelete; + } + + + /** + * Return the icon used in the folderlist to represent the different folder + * types. + */ + static getTypeImage(folderData) { + let src = ""; + switch (folderData.getFolderProperty("type")) { + case "9": + case "14": + src = "contacts16.png"; + break; + case "8": + case "13": + src = "calendar16.png"; + break; + case "7": + case "15": + src = "todo16.png"; + break; + } + return "chrome://tbsync/content/skin/" + src; + } + + + /** + * Return the name of the folder shown in the folderlist. + */ + static getFolderDisplayName(folderData) { + let folderName = folderData.getFolderProperty("foldername"); + if (eas.tools.parentIsTrash(folderData)) folderName = TbSync.getString("recyclebin", "eas") + " | " + folderName; + return folderName; + } + + + /** + * Return the attributes for the ACL RO (readonly menu element per folder. + * (label, disabled, hidden, style, ...) + * + * Return a list of attributes and their values If both (RO+RW) do + * not return any attributes, the ACL menu is not displayed at all. + */ + static getAttributesRoAcl(folderData) { + return { + label: TbSync.getString("acl.readonly", "eas"), + }; + } + + + /** + * Return the attributes for the ACL RW (readwrite) menu element per folder. + * (label, disabled, hidden, style, ...) + * + * Return a list of attributes and their values. If both (RO+RW) do + * not return any attributes, the ACL menu is not displayed at all. + */ + static getAttributesRwAcl(folderData) { + return { + label: TbSync.getString("acl.readwrite", "eas"), + } + } +} + +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/network.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/wbxmltools.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/xmltools.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/tools.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/sync.js", this, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/contactsync.js", this.sync, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/calendarsync.js", this.sync, "UTF-8"); +Services.scriptloader.loadSubScript("chrome://eas4tbsync/content/includes/tasksync.js", this.sync, "UTF-8"); diff --git a/content/skin/365.svg b/content/skin/365.svg new file mode 100644 index 0000000..e2e9273 --- /dev/null +++ b/content/skin/365.svg @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 53.59 64.356" width="53.589577" height="64.356148"> + <g transform="translate(-216.07358,-549.28882)"> + <g transform="matrix(1.8232952,0,0,1.8232952,-597.71681,-124.12247)"> + <g transform="translate(0,-91.137241)"> + <g fill="#eb3c00" transform="matrix(0.74069815,0,0,0.74069815,98.5698,-8.2505871)"> + <path d="m469.87,671.03,0-28.52,25.229-9.3238,13.711,4.3877,0,38.392-13.711,4.133-25.229-9.0691,25.229,3.0361,0-33.201-16.454,3.8392,0,22.487z"/> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/content/skin/365_16.png b/content/skin/365_16.png Binary files differnew file mode 100644 index 0000000..2dda541 --- /dev/null +++ b/content/skin/365_16.png diff --git a/content/skin/365_32.png b/content/skin/365_32.png Binary files differnew file mode 100644 index 0000000..8022230 --- /dev/null +++ b/content/skin/365_32.png diff --git a/content/skin/365_48.png b/content/skin/365_48.png Binary files differnew file mode 100644 index 0000000..84a664f --- /dev/null +++ b/content/skin/365_48.png diff --git a/content/skin/eas16.png b/content/skin/eas16.png Binary files differnew file mode 100644 index 0000000..072d5f0 --- /dev/null +++ b/content/skin/eas16.png diff --git a/content/skin/eas32.png b/content/skin/eas32.png Binary files differnew file mode 100644 index 0000000..f85ff8f --- /dev/null +++ b/content/skin/eas32.png diff --git a/content/skin/eas64.png b/content/skin/eas64.png Binary files differnew file mode 100644 index 0000000..266afa1 --- /dev/null +++ b/content/skin/eas64.png diff --git a/content/skin/eas_300.png b/content/skin/eas_300.png Binary files differnew file mode 100644 index 0000000..0aaec3a --- /dev/null +++ b/content/skin/eas_300.png diff --git a/content/skin/fieldset.css b/content/skin/fieldset.css new file mode 100644 index 0000000..6a4c0e0 --- /dev/null +++ b/content/skin/fieldset.css @@ -0,0 +1,6 @@ + +vbox[OS=WINNT] legend.insideTab { + padding-right: 5px; + padding-left: 5px; + margin-bottom:5px; +} diff --git a/content/skin/sponsors/netcup.png b/content/skin/sponsors/netcup.png Binary files differnew file mode 100644 index 0000000..c4cff7f --- /dev/null +++ b/content/skin/sponsors/netcup.png diff --git a/content/skin/sponsors/nethinks.png b/content/skin/sponsors/nethinks.png Binary files differnew file mode 100644 index 0000000..82356fc --- /dev/null +++ b/content/skin/sponsors/nethinks.png diff --git a/content/timezonedata/Aliases.csv b/content/timezonedata/Aliases.csv new file mode 100644 index 0000000..5047c5a --- /dev/null +++ b/content/timezonedata/Aliases.csv @@ -0,0 +1,115 @@ +Africa/Abidjan,Iceland Africa/Timbuktu Africa/Accra Africa/Bamako Africa/Banjul Africa/Conakry Africa/Dakar Africa/Freetown Africa/Lome Africa/Nouakchott Africa/Ouagadougou Atlantic/Reykjavik Atlantic/St_Helena +Africa/Cairo,Egypt +Africa/Johannesburg,Africa/Maseru Africa/Mbabane +Africa/Lagos,Africa/Bangui Africa/Brazzaville Africa/Douala Africa/Kinshasa Africa/Libreville Africa/Luanda Africa/Malabo Africa/Niamey Africa/Porto-Novo +Africa/Maputo,Africa/Blantyre Africa/Bujumbura Africa/Gaborone Africa/Harare Africa/Kigali Africa/Lubumbashi Africa/Lusaka +Africa/Nairobi,Africa/Asmara Africa/Addis_Ababa Africa/Dar_es_Salaam Africa/Djibouti Africa/Kampala Africa/Mogadishu Indian/Antananarivo Indian/Comoro Indian/Mayotte Africa/Asmera +Africa/Tripoli,Libya +America/Adak,America/Atka US/Aleutian +America/Anchorage,US/Alaska +America/Argentina/Buenos_Aires,America/Buenos_Aires +America/Argentina/Catamarca,America/Catamarca America/Argentina/ComodRivadavia +America/Argentina/Cordoba,America/Cordoba America/Rosario +America/Argentina/Jujuy,America/Jujuy +America/Argentina/Mendoza,America/Mendoza +America/Chicago,US/Central +America/Denver,America/Shiprock Navajo US/Mountain +America/Detroit,US/Michigan +America/Edmonton,Canada/Mountain +America/Halifax,Canada/Atlantic +America/Havana,Cuba +America/Indiana/Indianapolis,America/Fort_Wayne America/Indianapolis US/East-Indiana +America/Indiana/Knox,America/Knox_IN US/Indiana-Starke +America/Jamaica,Jamaica +America/Kentucky/Louisville,America/Louisville +America/Los_Angeles,US/Pacific US/Pacific-New +America/Manaus,Brazil/West +America/Mazatlan,Mexico/BajaSur +America/Mexico_City,Mexico/General +America/New_York,US/Eastern +America/Noronha,Brazil/DeNoronha +America/Nuuk,America/Godthab +America/Panama,America/Atikokan America/Coral_Harbour America/Cayman +America/Phoenix,US/Arizona America/Creston +America/Puerto_Rico,America/Virgin America/Anguilla America/Antigua America/Aruba America/Curacao America/Blanc-Sablon America/Dominica America/Grenada America/Guadeloupe America/Kralendijk America/Lower_Princes America/Marigot America/Montserrat America/Port_of_Spain America/St_Barthelemy America/St_Kitts America/St_Lucia America/St_Thomas America/St_Vincent America/Tortola +America/Regina,Canada/East-Saskatchewan Canada/Saskatchewan +America/Rio_Branco,America/Porto_Acre Brazil/Acre +America/Santiago,Chile/Continental +America/Sao_Paulo,Brazil/East +America/St_Johns,Canada/Newfoundland +America/Tijuana,America/Ensenada Mexico/BajaNorte America/Santa_Isabel +America/Toronto,Canada/Eastern America/Montreal America/Nassau +America/Vancouver,Canada/Pacific +America/Whitehorse,Canada/Yukon +America/Winnipeg,Canada/Central +Asia/Ashgabat,Asia/Ashkhabad +Asia/Bangkok,Asia/Phnom_Penh Asia/Vientiane Indian/Christmas +Asia/Dhaka,Asia/Dacca +Asia/Dubai,Asia/Muscat Indian/Mahe Indian/Reunion +Asia/Ho_Chi_Minh,Asia/Saigon +Asia/Hong_Kong,Hongkong +Asia/Jerusalem,Asia/Tel_Aviv Israel +Asia/Kathmandu,Asia/Katmandu +Asia/Kolkata,Asia/Calcutta +Asia/Kuching,Asia/Brunei +Asia/Macau,Asia/Macao +Asia/Makassar,Asia/Ujung_Pandang +Asia/Nicosia,Europe/Nicosia +Asia/Qatar,Asia/Bahrain +Asia/Riyadh,Antarctica/Syowa Asia/Aden Asia/Kuwait +Asia/Seoul,ROK +Asia/Shanghai,Asia/Chongqing Asia/Chungking Asia/Harbin PRC +Asia/Singapore,Singapore Asia/Kuala_Lumpur +Asia/Taipei,ROC +Asia/Tehran,Iran +Asia/Thimphu,Asia/Thimbu +Asia/Tokyo,Japan +Asia/Ulaanbaatar,Asia/Ulan_Bator +Asia/Urumqi,Asia/Kashgar Antarctica/Vostok +Asia/Yangon,Asia/Rangoon Indian/Cocos +Atlantic/Canary,WET +Atlantic/Faroe,Atlantic/Faeroe +Australia/Adelaide,Australia/South +Australia/Brisbane,Australia/Queensland +Australia/Broken_Hill,Australia/Yancowinna +Australia/Darwin,Australia/North +Australia/Hobart,Australia/Tasmania Australia/Currie +Australia/Lord_Howe,Australia/LHI +Australia/Melbourne,Australia/Victoria +Australia/Perth,Australia/West +Australia/Sydney,Australia/ACT Australia/Canberra Australia/NSW +Etc/GMT+10,HST +Etc/GMT+5,EST +Etc/GMT+7,MST +Etc/UTC,Etc/GMT+0 Etc/GMT-0 Etc/GMT0 Etc/Greenwich GMT GMT+0 GMT-0 GMT0 Greenwich Etc/UCT Etc/Universal Etc/Zulu UCT UTC Universal Zulu Etc/GMT +Europe/Belgrade,Europe/Ljubljana Europe/Podgorica Europe/Sarajevo Europe/Skopje Europe/Zagreb +Europe/Berlin,Atlantic/Jan_Mayen Arctic/Longyearbyen Europe/Copenhagen Europe/Oslo Europe/Stockholm +Europe/Berlin,MET +Europe/Brussels,Europe/Amsterdam Europe/Luxembourg +Europe/Bucharest,EET +Europe/Chisinau,Europe/Tiraspol +Europe/Dublin,Eire +Europe/Helsinki,Europe/Mariehamn +Europe/Istanbul,Asia/Istanbul Turkey +Europe/Kyiv,Europe/Kiev +Europe/Lisbon,Portugal +Europe/London,Europe/Belfast GB GB-Eire Europe/Jersey Europe/Guernsey Europe/Isle_of_Man +Europe/Moscow,W-SU +Europe/Paris,CET +Europe/Paris,Europe/Monaco +Europe/Prague,Europe/Bratislava +Europe/Rome,Europe/Vatican Europe/San_Marino +Europe/Warsaw,Poland +Europe/Zurich,Europe/Busingen Europe/Vaduz +Indian/Maldives,Indian/Kerguelen +Pacific/Auckland,Antarctica/South_Pole NZ Antarctica/McMurdo +Pacific/Chatham,NZ-CHAT +Pacific/Easter,Chile/EasterIsland +Pacific/Guadalcanal,Pacific/Pohnpei Pacific/Ponape +Pacific/Guam,Pacific/Saipan +Pacific/Honolulu,US/Hawaii Pacific/Johnston +Pacific/Kanton,Pacific/Enderbury +Pacific/Kwajalein,Kwajalein +Pacific/Pago_Pago,Pacific/Samoa US/Samoa Pacific/Midway +Pacific/Port_Moresby,Pacific/Chuuk Pacific/Yap Antarctica/DumontDUrville Pacific/Truk +Pacific/Tarawa,Pacific/Funafuti Pacific/Majuro Pacific/Wake Pacific/Wallis diff --git a/content/timezonedata/README b/content/timezonedata/README new file mode 100644 index 0000000..dcf5176 --- /dev/null +++ b/content/timezonedata/README @@ -0,0 +1,4 @@ +Taken from TimeZoneConverter: +https://github.com/mj1856/TimeZoneConverter/blob/master/src/TimeZoneConverter/Data/Mapping.csv.gz + + 12 Jan 2022 diff --git a/content/timezonedata/WindowsTimezone.csv b/content/timezonedata/WindowsTimezone.csv new file mode 100644 index 0000000..8e1ec5f --- /dev/null +++ b/content/timezonedata/WindowsTimezone.csv @@ -0,0 +1,514 @@ +AUS Central Standard Time,001,Australia/Darwin +AUS Central Standard Time,AU,Australia/Darwin +AUS Eastern Standard Time,001,Australia/Sydney +AUS Eastern Standard Time,AU,Australia/Sydney Australia/Melbourne +Afghanistan Standard Time,001,Asia/Kabul +Afghanistan Standard Time,AF,Asia/Kabul +Alaskan Standard Time,001,America/Anchorage +Alaskan Standard Time,US,America/Anchorage America/Juneau America/Metlakatla America/Nome America/Sitka America/Yakutat +Aleutian Standard Time,001,America/Adak +Aleutian Standard Time,US,America/Adak +Altai Standard Time,001,Asia/Barnaul +Altai Standard Time,RU,Asia/Barnaul +Arab Standard Time,001,Asia/Riyadh +Arab Standard Time,BH,Asia/Bahrain +Arab Standard Time,KW,Asia/Kuwait +Arab Standard Time,QA,Asia/Qatar +Arab Standard Time,SA,Asia/Riyadh +Arab Standard Time,YE,Asia/Aden +Arabian Standard Time,001,Asia/Dubai +Arabian Standard Time,AE,Asia/Dubai +Arabian Standard Time,OM,Asia/Muscat +Arabian Standard Time,ZZ,Etc/GMT-4 +Arabic Standard Time,001,Asia/Baghdad +Arabic Standard Time,IQ,Asia/Baghdad +Argentina Standard Time,001,America/Buenos_Aires +Argentina Standard Time,AR,America/Buenos_Aires America/Argentina/La_Rioja America/Argentina/Rio_Gallegos America/Argentina/Salta America/Argentina/San_Juan America/Argentina/San_Luis America/Argentina/Tucuman America/Argentina/Ushuaia America/Catamarca America/Cordoba America/Jujuy America/Mendoza +Astrakhan Standard Time,001,Europe/Astrakhan +Astrakhan Standard Time,RU,Europe/Astrakhan Europe/Ulyanovsk +Atlantic Standard Time,001,America/Halifax +Atlantic Standard Time,BM,Atlantic/Bermuda +Atlantic Standard Time,CA,America/Halifax America/Glace_Bay America/Goose_Bay America/Moncton +Atlantic Standard Time,GL,America/Thule +Aus Central W. Standard Time,001,Australia/Eucla +Aus Central W. Standard Time,AU,Australia/Eucla +Azerbaijan Standard Time,001,Asia/Baku +Azerbaijan Standard Time,AZ,Asia/Baku +Azores Standard Time,001,Atlantic/Azores +Azores Standard Time,GL,America/Scoresbysund +Azores Standard Time,PT,Atlantic/Azores +Bahia Standard Time,001,America/Bahia +Bahia Standard Time,BR,America/Bahia +Bangladesh Standard Time,001,Asia/Dhaka +Bangladesh Standard Time,BD,Asia/Dhaka +Bangladesh Standard Time,BT,Asia/Thimphu +Belarus Standard Time,001,Europe/Minsk +Belarus Standard Time,BY,Europe/Minsk +Bougainville Standard Time,001,Pacific/Bougainville +Bougainville Standard Time,PG,Pacific/Bougainville +Canada Central Standard Time,001,America/Regina +Canada Central Standard Time,CA,America/Regina America/Swift_Current +Cape Verde Standard Time,001,Atlantic/Cape_Verde +Cape Verde Standard Time,CV,Atlantic/Cape_Verde +Cape Verde Standard Time,ZZ,Etc/GMT+1 +Caucasus Standard Time,001,Asia/Yerevan +Caucasus Standard Time,AM,Asia/Yerevan +Cen. Australia Standard Time,001,Australia/Adelaide +Cen. Australia Standard Time,AU,Australia/Adelaide Australia/Broken_Hill +Central America Standard Time,001,America/Guatemala +Central America Standard Time,BZ,America/Belize +Central America Standard Time,CR,America/Costa_Rica +Central America Standard Time,EC,Pacific/Galapagos +Central America Standard Time,GT,America/Guatemala +Central America Standard Time,HN,America/Tegucigalpa +Central America Standard Time,NI,America/Managua +Central America Standard Time,SV,America/El_Salvador +Central America Standard Time,ZZ,Etc/GMT+6 +Central Asia Standard Time,001,Asia/Almaty +Central Asia Standard Time,AQ,Antarctica/Vostok +Central Asia Standard Time,CN,Asia/Urumqi +Central Asia Standard Time,DG,Indian/Chagos +Central Asia Standard Time,IO,Indian/Chagos +Central Asia Standard Time,KG,Asia/Bishkek +Central Asia Standard Time,KZ,Asia/Almaty Asia/Qostanay +Central Asia Standard Time,ZZ,Etc/GMT-6 +Central Brazilian Standard Time,001,America/Cuiaba +Central Brazilian Standard Time,BR,America/Cuiaba America/Campo_Grande +Central Europe Standard Time,001,Europe/Budapest +Central Europe Standard Time,AL,Europe/Tirane +Central Europe Standard Time,CZ,Europe/Prague +Central Europe Standard Time,HU,Europe/Budapest +Central Europe Standard Time,ME,Europe/Podgorica +Central Europe Standard Time,RS,Europe/Belgrade +Central Europe Standard Time,SI,Europe/Ljubljana +Central Europe Standard Time,SK,Europe/Bratislava +Central Europe Standard Time,XK,Europe/Belgrade +Central European Standard Time,001,Europe/Warsaw +Central European Standard Time,BA,Europe/Sarajevo +Central European Standard Time,HR,Europe/Zagreb +Central European Standard Time,MK,Europe/Skopje +Central European Standard Time,PL,Europe/Warsaw +Central Pacific Standard Time,001,Pacific/Guadalcanal +Central Pacific Standard Time,AQ,Antarctica/Casey +Central Pacific Standard Time,FM,Pacific/Ponape Pacific/Kosrae +Central Pacific Standard Time,NC,Pacific/Noumea +Central Pacific Standard Time,SB,Pacific/Guadalcanal +Central Pacific Standard Time,VU,Pacific/Efate +Central Pacific Standard Time,ZZ,Etc/GMT-11 +Central Standard Time (Mexico),001,America/Mexico_City +Central Standard Time (Mexico),MX,America/Mexico_City America/Bahia_Banderas America/Merida America/Monterrey +Central Standard Time,001,America/Chicago +Central Standard Time,CA,America/Winnipeg America/Rainy_River America/Rankin_Inlet America/Resolute +Central Standard Time,MX,America/Matamoros +Central Standard Time,US,America/Chicago America/Indiana/Knox America/Indiana/Tell_City America/Menominee America/North_Dakota/Beulah America/North_Dakota/Center America/North_Dakota/New_Salem +Central Standard Time,ZZ,CST6CDT +Chatham Islands Standard Time,001,Pacific/Chatham +Chatham Islands Standard Time,NZ,Pacific/Chatham +China Standard Time,001,Asia/Shanghai +China Standard Time,CN,Asia/Shanghai +China Standard Time,HK,Asia/Hong_Kong +China Standard Time,MO,Asia/Macau +Cuba Standard Time,001,America/Havana +Cuba Standard Time,CU,America/Havana +Dateline Standard Time,001,Etc/GMT+12 +Dateline Standard Time,ZZ,Etc/GMT+12 +E. Africa Standard Time,001,Africa/Nairobi +E. Africa Standard Time,AQ,Antarctica/Syowa +E. Africa Standard Time,DJ,Africa/Djibouti +E. Africa Standard Time,ER,Africa/Asmera +E. Africa Standard Time,ET,Africa/Addis_Ababa +E. Africa Standard Time,KE,Africa/Nairobi +E. Africa Standard Time,KM,Indian/Comoro +E. Africa Standard Time,MG,Indian/Antananarivo +E. Africa Standard Time,SO,Africa/Mogadishu +E. Africa Standard Time,TZ,Africa/Dar_es_Salaam +E. Africa Standard Time,UG,Africa/Kampala +E. Africa Standard Time,YT,Indian/Mayotte +E. Africa Standard Time,ZZ,Etc/GMT-3 +E. Australia Standard Time,001,Australia/Brisbane +E. Australia Standard Time,AU,Australia/Brisbane Australia/Lindeman +E. Europe Standard Time,001,Europe/Chisinau +E. Europe Standard Time,MD,Europe/Chisinau +E. South America Standard Time,001,America/Sao_Paulo +E. South America Standard Time,BR,America/Sao_Paulo +Easter Island Standard Time,001,Pacific/Easter +Easter Island Standard Time,CL,Pacific/Easter +Eastern Standard Time (Mexico),001,America/Cancun +Eastern Standard Time (Mexico),MX,America/Cancun +Eastern Standard Time,001,America/New_York +Eastern Standard Time,BS,America/Nassau +Eastern Standard Time,CA,America/Toronto America/Iqaluit America/Montreal America/Nipigon America/Pangnirtung America/Thunder_Bay +Eastern Standard Time,US,America/New_York America/Detroit America/Indiana/Petersburg America/Indiana/Vincennes America/Indiana/Winamac America/Kentucky/Monticello America/Louisville +Eastern Standard Time,ZZ,EST5EDT +Egypt Standard Time,001,Africa/Cairo +Egypt Standard Time,EG,Africa/Cairo +Ekaterinburg Standard Time,001,Asia/Yekaterinburg +Ekaterinburg Standard Time,RU,Asia/Yekaterinburg +FLE Standard Time,001,Europe/Kiev +FLE Standard Time,AX,Europe/Mariehamn +FLE Standard Time,BG,Europe/Sofia +FLE Standard Time,EE,Europe/Tallinn +FLE Standard Time,FI,Europe/Helsinki +FLE Standard Time,LT,Europe/Vilnius +FLE Standard Time,LV,Europe/Riga +FLE Standard Time,UA,Europe/Kiev Europe/Uzhgorod Europe/Zaporozhye +Fiji Standard Time,001,Pacific/Fiji +Fiji Standard Time,FJ,Pacific/Fiji +GMT Standard Time,001,Europe/London +GMT Standard Time,ES,Atlantic/Canary +GMT Standard Time,FO,Atlantic/Faeroe +GMT Standard Time,GB,Europe/London +GMT Standard Time,GG,Europe/Guernsey +GMT Standard Time,IC,Atlantic/Canary +GMT Standard Time,IE,Europe/Dublin +GMT Standard Time,IM,Europe/Isle_of_Man +GMT Standard Time,JE,Europe/Jersey +GMT Standard Time,PT,Europe/Lisbon Atlantic/Madeira +GTB Standard Time,001,Europe/Bucharest +GTB Standard Time,CY,Asia/Nicosia Asia/Famagusta +GTB Standard Time,GR,Europe/Athens +GTB Standard Time,RO,Europe/Bucharest +Georgian Standard Time,001,Asia/Tbilisi +Georgian Standard Time,GE,Asia/Tbilisi +Greenland Standard Time,001,America/Godthab +Greenland Standard Time,GL,America/Godthab +Greenwich Standard Time,001,Atlantic/Reykjavik +Greenwich Standard Time,AC,Atlantic/St_Helena +Greenwich Standard Time,BF,Africa/Ouagadougou +Greenwich Standard Time,CI,Africa/Abidjan +Greenwich Standard Time,GH,Africa/Accra +Greenwich Standard Time,GL,America/Danmarkshavn +Greenwich Standard Time,GM,Africa/Banjul +Greenwich Standard Time,GN,Africa/Conakry +Greenwich Standard Time,GW,Africa/Bissau +Greenwich Standard Time,IS,Atlantic/Reykjavik +Greenwich Standard Time,LR,Africa/Monrovia +Greenwich Standard Time,ML,Africa/Bamako +Greenwich Standard Time,MR,Africa/Nouakchott +Greenwich Standard Time,SH,Atlantic/St_Helena +Greenwich Standard Time,SL,Africa/Freetown +Greenwich Standard Time,SN,Africa/Dakar +Greenwich Standard Time,TA,Atlantic/St_Helena +Greenwich Standard Time,TG,Africa/Lome +Haiti Standard Time,001,America/Port-au-Prince +Haiti Standard Time,HT,America/Port-au-Prince +Hawaiian Standard Time,001,Pacific/Honolulu +Hawaiian Standard Time,CK,Pacific/Rarotonga +Hawaiian Standard Time,PF,Pacific/Tahiti +Hawaiian Standard Time,UM,Pacific/Johnston +Hawaiian Standard Time,US,Pacific/Honolulu +Hawaiian Standard Time,ZZ,Etc/GMT+10 +India Standard Time,001,Asia/Calcutta +India Standard Time,IN,Asia/Calcutta +Iran Standard Time,001,Asia/Tehran +Iran Standard Time,IR,Asia/Tehran +Israel Standard Time,001,Asia/Jerusalem +Israel Standard Time,IL,Asia/Jerusalem +Jordan Standard Time,001,Asia/Amman +Jordan Standard Time,JO,Asia/Amman +Kaliningrad Standard Time,001,Europe/Kaliningrad +Kaliningrad Standard Time,RU,Europe/Kaliningrad +Korea Standard Time,001,Asia/Seoul +Korea Standard Time,KR,Asia/Seoul +Libya Standard Time,001,Africa/Tripoli +Libya Standard Time,LY,Africa/Tripoli +Line Islands Standard Time,001,Pacific/Kiritimati +Line Islands Standard Time,KI,Pacific/Kiritimati +Line Islands Standard Time,ZZ,Etc/GMT-14 +Lord Howe Standard Time,001,Australia/Lord_Howe +Lord Howe Standard Time,AU,Australia/Lord_Howe +Magadan Standard Time,001,Asia/Magadan +Magadan Standard Time,RU,Asia/Magadan +Magallanes Standard Time,001,America/Punta_Arenas +Magallanes Standard Time,CL,America/Punta_Arenas +Marquesas Standard Time,001,Pacific/Marquesas +Marquesas Standard Time,PF,Pacific/Marquesas +Mauritius Standard Time,001,Indian/Mauritius +Mauritius Standard Time,MU,Indian/Mauritius +Mauritius Standard Time,RE,Indian/Reunion +Mauritius Standard Time,SC,Indian/Mahe +Middle East Standard Time,001,Asia/Beirut +Middle East Standard Time,LB,Asia/Beirut +Montevideo Standard Time,001,America/Montevideo +Montevideo Standard Time,UY,America/Montevideo +Morocco Standard Time,001,Africa/Casablanca +Morocco Standard Time,EH,Africa/El_Aaiun +Morocco Standard Time,MA,Africa/Casablanca +Mountain Standard Time (Mexico),001,America/Chihuahua +Mountain Standard Time (Mexico),MX,America/Chihuahua America/Mazatlan +Mountain Standard Time,001,America/Denver +Mountain Standard Time,CA,America/Edmonton America/Cambridge_Bay America/Inuvik America/Yellowknife +Mountain Standard Time,MX,America/Ojinaga +Mountain Standard Time,US,America/Denver America/Boise +Mountain Standard Time,ZZ,MST7MDT +Myanmar Standard Time,001,Asia/Rangoon +Myanmar Standard Time,CC,Indian/Cocos +Myanmar Standard Time,MM,Asia/Rangoon +N. Central Asia Standard Time,001,Asia/Novosibirsk +N. Central Asia Standard Time,RU,Asia/Novosibirsk +Namibia Standard Time,001,Africa/Windhoek +Namibia Standard Time,NA,Africa/Windhoek +Nepal Standard Time,001,Asia/Katmandu +Nepal Standard Time,NP,Asia/Katmandu +New Zealand Standard Time,001,Pacific/Auckland +New Zealand Standard Time,AQ,Antarctica/McMurdo +New Zealand Standard Time,NZ,Pacific/Auckland +Newfoundland Standard Time,001,America/St_Johns +Newfoundland Standard Time,CA,America/St_Johns +Norfolk Standard Time,001,Pacific/Norfolk +Norfolk Standard Time,NF,Pacific/Norfolk +North Asia East Standard Time,001,Asia/Irkutsk +North Asia East Standard Time,RU,Asia/Irkutsk +North Asia Standard Time,001,Asia/Krasnoyarsk +North Asia Standard Time,RU,Asia/Krasnoyarsk Asia/Novokuznetsk +North Korea Standard Time,001,Asia/Pyongyang +North Korea Standard Time,KP,Asia/Pyongyang +Omsk Standard Time,001,Asia/Omsk +Omsk Standard Time,RU,Asia/Omsk +Pacific SA Standard Time,001,America/Santiago +Pacific SA Standard Time,CL,America/Santiago +Pacific Standard Time (Mexico),001,America/Tijuana +Pacific Standard Time (Mexico),MX,America/Tijuana America/Santa_Isabel +Pacific Standard Time,001,America/Los_Angeles +Pacific Standard Time,CA,America/Vancouver +Pacific Standard Time,US,America/Los_Angeles +Pacific Standard Time,ZZ,PST8PDT +Pakistan Standard Time,001,Asia/Karachi +Pakistan Standard Time,PK,Asia/Karachi +Paraguay Standard Time,001,America/Asuncion +Paraguay Standard Time,PY,America/Asuncion +Qyzylorda Standard Time,001,Asia/Qyzylorda +Qyzylorda Standard Time,KZ,Asia/Qyzylorda +Romance Standard Time,001,Europe/Paris +Romance Standard Time,BE,Europe/Brussels +Romance Standard Time,DK,Europe/Copenhagen +Romance Standard Time,EA,Africa/Ceuta +Romance Standard Time,ES,Europe/Madrid Africa/Ceuta +Romance Standard Time,FR,Europe/Paris +Russia Time Zone 10,001,Asia/Srednekolymsk +Russia Time Zone 10,RU,Asia/Srednekolymsk +Russia Time Zone 11,001,Asia/Kamchatka +Russia Time Zone 11,RU,Asia/Kamchatka Asia/Anadyr +Russia Time Zone 3,001,Europe/Samara +Russia Time Zone 3,RU,Europe/Samara +Russian Standard Time,001,Europe/Moscow +Russian Standard Time,RU,Europe/Moscow Europe/Kirov +Russian Standard Time,UA,Europe/Simferopol +SA Eastern Standard Time,001,America/Cayenne +SA Eastern Standard Time,AQ,Antarctica/Rothera Antarctica/Palmer +SA Eastern Standard Time,BR,America/Fortaleza America/Belem America/Maceio America/Recife America/Santarem +SA Eastern Standard Time,FK,Atlantic/Stanley +SA Eastern Standard Time,GF,America/Cayenne +SA Eastern Standard Time,SR,America/Paramaribo +SA Eastern Standard Time,ZZ,Etc/GMT+3 +SA Pacific Standard Time,001,America/Bogota +SA Pacific Standard Time,BR,America/Rio_Branco America/Eirunepe +SA Pacific Standard Time,CA,America/Coral_Harbour +SA Pacific Standard Time,CO,America/Bogota +SA Pacific Standard Time,EC,America/Guayaquil +SA Pacific Standard Time,JM,America/Jamaica +SA Pacific Standard Time,KY,America/Cayman +SA Pacific Standard Time,PA,America/Panama +SA Pacific Standard Time,PE,America/Lima +SA Pacific Standard Time,ZZ,Etc/GMT+5 +SA Western Standard Time,001,America/La_Paz +SA Western Standard Time,AG,America/Antigua +SA Western Standard Time,AI,America/Anguilla +SA Western Standard Time,AW,America/Aruba +SA Western Standard Time,BB,America/Barbados +SA Western Standard Time,BL,America/St_Barthelemy +SA Western Standard Time,BO,America/La_Paz +SA Western Standard Time,BQ,America/Kralendijk +SA Western Standard Time,BR,America/Manaus America/Boa_Vista America/Porto_Velho +SA Western Standard Time,CA,America/Blanc-Sablon +SA Western Standard Time,CW,America/Curacao +SA Western Standard Time,DM,America/Dominica +SA Western Standard Time,DO,America/Santo_Domingo +SA Western Standard Time,GD,America/Grenada +SA Western Standard Time,GP,America/Guadeloupe +SA Western Standard Time,GY,America/Guyana +SA Western Standard Time,KN,America/St_Kitts +SA Western Standard Time,LC,America/St_Lucia +SA Western Standard Time,MF,America/Marigot +SA Western Standard Time,MQ,America/Martinique +SA Western Standard Time,MS,America/Montserrat +SA Western Standard Time,PR,America/Puerto_Rico +SA Western Standard Time,SX,America/Lower_Princes +SA Western Standard Time,TT,America/Port_of_Spain +SA Western Standard Time,VC,America/St_Vincent +SA Western Standard Time,VG,America/Tortola +SA Western Standard Time,VI,America/St_Thomas +SA Western Standard Time,ZZ,Etc/GMT+4 +SE Asia Standard Time,001,Asia/Bangkok +SE Asia Standard Time,AQ,Antarctica/Davis +SE Asia Standard Time,CX,Indian/Christmas +SE Asia Standard Time,ID,Asia/Jakarta Asia/Pontianak +SE Asia Standard Time,KH,Asia/Phnom_Penh +SE Asia Standard Time,LA,Asia/Vientiane +SE Asia Standard Time,TH,Asia/Bangkok +SE Asia Standard Time,VN,Asia/Saigon +SE Asia Standard Time,ZZ,Etc/GMT-7 +Saint Pierre Standard Time,001,America/Miquelon +Saint Pierre Standard Time,PM,America/Miquelon +Sakhalin Standard Time,001,Asia/Sakhalin +Sakhalin Standard Time,RU,Asia/Sakhalin +Samoa Standard Time,001,Pacific/Apia +Samoa Standard Time,WS,Pacific/Apia +Sao Tome Standard Time,001,Africa/Sao_Tome +Sao Tome Standard Time,ST,Africa/Sao_Tome +Saratov Standard Time,001,Europe/Saratov +Saratov Standard Time,RU,Europe/Saratov +Singapore Standard Time,001,Asia/Singapore +Singapore Standard Time,BN,Asia/Brunei +Singapore Standard Time,ID,Asia/Makassar +Singapore Standard Time,MY,Asia/Kuala_Lumpur Asia/Kuching +Singapore Standard Time,PH,Asia/Manila +Singapore Standard Time,SG,Asia/Singapore +Singapore Standard Time,ZZ,Etc/GMT-8 +South Africa Standard Time,001,Africa/Johannesburg +South Africa Standard Time,BI,Africa/Bujumbura +South Africa Standard Time,BW,Africa/Gaborone +South Africa Standard Time,CD,Africa/Lubumbashi +South Africa Standard Time,LS,Africa/Maseru +South Africa Standard Time,MW,Africa/Blantyre +South Africa Standard Time,MZ,Africa/Maputo +South Africa Standard Time,RW,Africa/Kigali +South Africa Standard Time,SZ,Africa/Mbabane +South Africa Standard Time,ZA,Africa/Johannesburg +South Africa Standard Time,ZM,Africa/Lusaka +South Africa Standard Time,ZW,Africa/Harare +South Africa Standard Time,ZZ,Etc/GMT-2 +South Sudan Standard Time,001,Africa/Juba +South Sudan Standard Time,SS,Africa/Juba +Sri Lanka Standard Time,001,Asia/Colombo +Sri Lanka Standard Time,LK,Asia/Colombo +Sudan Standard Time,001,Africa/Khartoum +Sudan Standard Time,SD,Africa/Khartoum +Syria Standard Time,001,Asia/Damascus +Syria Standard Time,SY,Asia/Damascus +Taipei Standard Time,001,Asia/Taipei +Taipei Standard Time,TW,Asia/Taipei +Tasmania Standard Time,001,Australia/Hobart +Tasmania Standard Time,AU,Australia/Hobart Australia/Currie Antarctica/Macquarie +Tocantins Standard Time,001,America/Araguaina +Tocantins Standard Time,BR,America/Araguaina +Tokyo Standard Time,001,Asia/Tokyo +Tokyo Standard Time,ID,Asia/Jayapura +Tokyo Standard Time,JP,Asia/Tokyo +Tokyo Standard Time,PW,Pacific/Palau +Tokyo Standard Time,TL,Asia/Dili +Tokyo Standard Time,ZZ,Etc/GMT-9 +Tomsk Standard Time,001,Asia/Tomsk +Tomsk Standard Time,RU,Asia/Tomsk +Tonga Standard Time,001,Pacific/Tongatapu +Tonga Standard Time,TO,Pacific/Tongatapu +Transbaikal Standard Time,001,Asia/Chita +Transbaikal Standard Time,RU,Asia/Chita +Turkey Standard Time,001,Europe/Istanbul +Turkey Standard Time,TR,Europe/Istanbul +Turks And Caicos Standard Time,001,America/Grand_Turk +Turks And Caicos Standard Time,TC,America/Grand_Turk +US Eastern Standard Time,001,America/Indianapolis +US Eastern Standard Time,US,America/Indianapolis America/Indiana/Marengo America/Indiana/Vevay +US Mountain Standard Time,001,America/Phoenix +US Mountain Standard Time,CA,America/Creston America/Dawson_Creek America/Fort_Nelson +US Mountain Standard Time,MX,America/Hermosillo +US Mountain Standard Time,US,America/Phoenix +US Mountain Standard Time,ZZ,Etc/GMT+7 +UTC+12,001,Etc/GMT-12 +UTC+12,KI,Pacific/Tarawa +UTC+12,MH,Pacific/Majuro Pacific/Kwajalein +UTC+12,NR,Pacific/Nauru +UTC+12,TV,Pacific/Funafuti +UTC+12,UM,Pacific/Wake +UTC+12,WF,Pacific/Wallis +UTC+12,ZZ,Etc/GMT-12 +UTC+13,001,Etc/GMT-13 +UTC+13,KI,Pacific/Enderbury +UTC+13,TK,Pacific/Fakaofo +UTC+13,ZZ,Etc/GMT-13 +UTC,001,Etc/UTC +UTC,ZZ,Etc/UTC Etc/GMT +UTC-02,001,Etc/GMT+2 +UTC-02,BR,America/Noronha +UTC-02,GS,Atlantic/South_Georgia +UTC-02,ZZ,Etc/GMT+2 +UTC-08,001,Etc/GMT+8 +UTC-08,PN,Pacific/Pitcairn +UTC-08,ZZ,Etc/GMT+8 +UTC-09,001,Etc/GMT+9 +UTC-09,PF,Pacific/Gambier +UTC-09,ZZ,Etc/GMT+9 +UTC-11,001,Etc/GMT+11 +UTC-11,AS,Pacific/Pago_Pago +UTC-11,NU,Pacific/Niue +UTC-11,UM,Pacific/Midway +UTC-11,ZZ,Etc/GMT+11 +Ulaanbaatar Standard Time,001,Asia/Ulaanbaatar +Ulaanbaatar Standard Time,MN,Asia/Ulaanbaatar Asia/Choibalsan +Venezuela Standard Time,001,America/Caracas +Venezuela Standard Time,VE,America/Caracas +Vladivostok Standard Time,001,Asia/Vladivostok +Vladivostok Standard Time,RU,Asia/Vladivostok Asia/Ust-Nera +Volgograd Standard Time,001,Europe/Volgograd +Volgograd Standard Time,RU,Europe/Volgograd +W. Australia Standard Time,001,Australia/Perth +W. Australia Standard Time,AU,Australia/Perth +W. Central Africa Standard Time,001,Africa/Lagos +W. Central Africa Standard Time,AO,Africa/Luanda +W. Central Africa Standard Time,BJ,Africa/Porto-Novo +W. Central Africa Standard Time,CD,Africa/Kinshasa +W. Central Africa Standard Time,CF,Africa/Bangui +W. Central Africa Standard Time,CG,Africa/Brazzaville +W. Central Africa Standard Time,CM,Africa/Douala +W. Central Africa Standard Time,DZ,Africa/Algiers +W. Central Africa Standard Time,GA,Africa/Libreville +W. Central Africa Standard Time,GQ,Africa/Malabo +W. Central Africa Standard Time,NE,Africa/Niamey +W. Central Africa Standard Time,NG,Africa/Lagos +W. Central Africa Standard Time,TD,Africa/Ndjamena +W. Central Africa Standard Time,TN,Africa/Tunis +W. Central Africa Standard Time,ZZ,Etc/GMT-1 +W. Europe Standard Time,001,Europe/Berlin +W. Europe Standard Time,AD,Europe/Andorra +W. Europe Standard Time,AT,Europe/Vienna +W. Europe Standard Time,CH,Europe/Zurich +W. Europe Standard Time,DE,Europe/Berlin Europe/Busingen +W. Europe Standard Time,GI,Europe/Gibraltar +W. Europe Standard Time,IT,Europe/Rome +W. Europe Standard Time,LI,Europe/Vaduz +W. Europe Standard Time,LU,Europe/Luxembourg +W. Europe Standard Time,MC,Europe/Monaco +W. Europe Standard Time,MT,Europe/Malta +W. Europe Standard Time,NL,Europe/Amsterdam +W. Europe Standard Time,NO,Europe/Oslo +W. Europe Standard Time,SE,Europe/Stockholm +W. Europe Standard Time,SJ,Arctic/Longyearbyen +W. Europe Standard Time,SM,Europe/San_Marino +W. Europe Standard Time,VA,Europe/Vatican +W. Mongolia Standard Time,001,Asia/Hovd +W. Mongolia Standard Time,MN,Asia/Hovd +West Asia Standard Time,001,Asia/Tashkent +West Asia Standard Time,AQ,Antarctica/Mawson +West Asia Standard Time,KZ,Asia/Oral Asia/Aqtau Asia/Aqtobe Asia/Atyrau +West Asia Standard Time,MV,Indian/Maldives +West Asia Standard Time,TF,Indian/Kerguelen +West Asia Standard Time,TJ,Asia/Dushanbe +West Asia Standard Time,TM,Asia/Ashgabat +West Asia Standard Time,UZ,Asia/Tashkent Asia/Samarkand +West Asia Standard Time,ZZ,Etc/GMT-5 +West Bank Standard Time,001,Asia/Hebron +West Bank Standard Time,PS,Asia/Hebron Asia/Gaza +West Pacific Standard Time,001,Pacific/Port_Moresby +West Pacific Standard Time,AQ,Antarctica/DumontDUrville +West Pacific Standard Time,FM,Pacific/Truk +West Pacific Standard Time,GU,Pacific/Guam +West Pacific Standard Time,MP,Pacific/Saipan +West Pacific Standard Time,PG,Pacific/Port_Moresby +West Pacific Standard Time,ZZ,Etc/GMT-10 +Yakutsk Standard Time,001,Asia/Yakutsk +Yakutsk Standard Time,RU,Asia/Yakutsk Asia/Khandyga +Yukon Standard Time,001,America/Whitehorse +Yukon Standard Time,CA,America/Whitehorse America/Dawson +Kamchatka Standard Time,001,Asia/Kamchatka +Mid-Atlantic Standard Time,001,Etc/GMT+2 |