diff options
Diffstat (limited to '')
-rw-r--r-- | comm/calendar/base/content/dialogs/calendar-creation.js | 836 |
1 files changed, 836 insertions, 0 deletions
diff --git a/comm/calendar/base/content/dialogs/calendar-creation.js b/comm/calendar/base/content/dialogs/calendar-creation.js new file mode 100644 index 0000000000..b4d2a7c2e4 --- /dev/null +++ b/comm/calendar/base/content/dialogs/calendar-creation.js @@ -0,0 +1,836 @@ +/* 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/. */ + +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +ChromeUtils.defineModuleGetter(this, "MsgAuthPrompt", "resource:///modules/MsgAsyncPrompter.jsm"); + +/* exported checkRequired, fillLocationPlaceholder, selectProvider, updateNoCredentials, */ + +/* import-globals-from calendar-identity-utils.js */ + +/** + * For managing dialog button handler state. Stores the current handlers so we + * can remove them with removeEventListener. Provides a way to look up the + * button handler functions to be used with a given panel. + */ +var gButtonHandlers = { + accept: null, + extra2: null, + + // Maps a panel DOM node ID to the button handlers to use for that panel. + forNodeId: { + "panel-select-calendar-type": { + accept: selectCalendarType, + }, + "panel-local-calendar-settings": { + accept: registerLocalCalendar, + extra2: () => selectPanel("panel-select-calendar-type"), + }, + "panel-network-calendar-settings": { + accept: event => { + event.preventDefault(); + event.stopPropagation(); + findCalendars(); + }, + extra2: () => selectPanel("panel-select-calendar-type"), + }, + "panel-select-calendars": { + accept: createNetworkCalendars, + extra2: () => selectPanel("panel-network-calendar-settings"), + }, + "panel-addon-calendar-settings": { + extra2: () => selectPanel("panel-select-calendar-type"), + // This 'accept' is set dynamically when the calendar type is selected. + accept: null, + }, + }, +}; + +/** @type {calICalendar | null} */ +var gLocalCalendar = null; + +/** + * A type of calendar that can be created with this dialog. + * + * @typedef {CalendarType} + * @property {string} id A unique ID for this type, e.g. "local" or + * "network" for built-in types and + * "3" or "4" for add-on types. + * @property {boolean} builtIn Whether this is a built in type. + * @property {Function} onSelected The "accept" button handler to call when + * the type is selected. + * @property {string} [label] Text to use in calendar type selection UI. + * @property {string} [panelSrc] The "src" property for the <browser> for + * this type's settings panel, typically a + * path to an html document. Only needed + * for types registered by add-ons. + * @property {Function} [onCreated] The "accept" button handler for this + * type's settings panel. Only needed for + * types registered by add-ons. + */ + +/** + * Registry of calendar types. The key should match the type's `id` property. + * Add-ons may register additional types. + * + * @type {Map<string, CalendarType>} + */ +var gCalendarTypes = new Map([ + [ + "local", + { + id: "local", + builtIn: true, + onSelected: () => { + // Create a local calendar to use, so we can share code with the calendar + // preferences dialog. + if (!gLocalCalendar) { + gLocalCalendar = cal.manager.createCalendar( + "storage", + Services.io.newURI("moz-storage-calendar://") + ); + + initMailIdentitiesRow(gLocalCalendar); + notifyOnIdentitySelection(gLocalCalendar); + } + selectPanel("panel-local-calendar-settings"); + }, + }, + ], + [ + "network", + { + id: "network", + builtIn: true, + onSelected: () => selectPanel("panel-network-calendar-settings"), + }, + ], +]); + +/** @type {CalendarType | null} */ +var gSelectedCalendarType = null; + +/** + * Register a calendar type to offer in the dialog. For add-ons to use. Add-on + * code should store the returned ID and use it for unregistering the type. + * + * @param {CalendarType} type - The type object to register. + * @returns {string} The generated ID for the type. + */ +function registerCalendarType(type) { + type.id = String(gCalendarTypes.size + 1); + type.builtIn = false; + + if (!type.onSelected) { + type.onSelected = () => selectPanel("panel-addon-calendar-settings"); + } + gCalendarTypes.set(type.id, type); + + // Add an option for this type to the "select calendar type" panel. + let radiogroup = document.getElementById("calendar-type"); + let radio = document.createXULElement("radio"); + radio.setAttribute("value", type.id); + radio.setAttribute("label", type.label); + radiogroup.appendChild(radio); + + return type.id; +} + +/** + * Unregister a calendar type. For add-ons to use. + * + * @param {string} id - The ID of the type to unregister. + */ +function unregisterCalendarType(id) { + // Don't allow unregistration of built-in types. + if (gCalendarTypes.get(id)?.builtIn) { + cal.WARN( + `calendar creation dialog: unregistering calendar type "${id}"` + + " failed because it is a built in type" + ); + return; + } + // We are using the size of gCalendarTypes to generate unique IDs for + // registered types, so don't fully remove the type. + gCalendarTypes.set(id, undefined); + + // Remove the option for this type from the "select calendar type" panel. + let radiogroup = document.getElementById("calendar-type"); + let radio = radiogroup.querySelector(`[value="${id}"]`); + if (radio) { + radiogroup.removeChild(radio); + } +} + +/** + * Tools for managing how providers are used for calendar detection. May be used + * by add-ons to modify which providers are used and which results are preferred. + */ +var gProviderUsage = { + /** + * A function that returns a list of provider types to filter out and not use + * to detect calendars, for a given location and username. The providers are + * filtered out before calendar detection. For example, the "Provider for + * Google Calendar" add-on might filter out the "caldav" provider: + * + * (providers, location, username) => { + * domain = username.split("@")[1]; + * if (providers.includes("gdata") && (domain == "googlemail.com" || domain == "gmail.com")) { + * return ["caldav"]; + * } + * return []; + * } + * + * @callback ProviderFilter + * @param {string[]} providers - Array of provider types to be used (if not filtered out). + * @param {string} location - Location to use for calendar detection. + * @param {string} username - Username to use for calendar detection. + * @returns {string[]} Array of provider types to be filtered out. + */ + + /** @type {ProviderFilter[]} */ + _preDetectFilters: [], + + /** + * A mapping from a less preferred provider type to a set of more preferred + * provider types. Used after calendar detection to default to a more + * preferred provider when there are results from more than one provider. + * + * @typedef {Map<string, Set<string>>} ProviderPreferences + */ + + /** + * @type {ProviderPreferences} + */ + _postDetectPreferences: new Map(), + + get preDetectFilters() { + return this._preDetectFilters; + }, + + get postDetectPreferences() { + return this._postDetectPreferences; + }, + + /** + * Add a new provider filter function. + * + * @param {ProviderFilter} providerFilter + */ + addPreDetectFilter(providerFilter) { + this._preDetectFilters.push(providerFilter); + }, + + /** + * Add a preference for one provider type over another provider type. + * + * @param {string} preferredType - The preferred provider type. + * @param {string} nonPreferredType - The non-preferred provider type. + */ + addPostDetectPreference(preferredType, nonPreferredType) { + let prefs = this._postDetectPreferences; + + if (this.detectPreferenceCycle(prefs, preferredType, nonPreferredType)) { + cal.WARN( + `Adding a preference for provider type "${preferredType}" over ` + + `type "${nonPreferredType}" would cause a preference cycle, ` + + `not adding this preference to prevent a cycle` + ); + } else { + let current = prefs.get(nonPreferredType); + if (current) { + current.add(preferredType); + } else { + prefs.set(nonPreferredType, new Set([preferredType])); + } + } + }, + + /** + * Check whether adding a preference for one provider type over another would + * cause a cycle in the order of preferences. We assume that the preferences + * do not contain any cycles already. + * + * @param {ProviderPreferences} prefs - The current preferences. + * @param {string} preferred - Potential preferred provider. + * @param {string} nonPreferred - Potential non-preferred provider. + * @returns {boolean} True if it would cause a cycle. + */ + detectPreferenceCycle(prefs, preferred, nonPreferred) { + let cycle = false; + + let innerDetect = preferredSet => { + if (cycle) { + // Bail out, a cycle has already been detected. + return; + } else if (preferredSet.has(nonPreferred)) { + // A cycle! We have arrived back at the nonPreferred provider type. + cycle = true; + return; + } + // Recursively check each preferred type. + for (let item of preferredSet) { + let nextPreferredSet = prefs.get(item); + if (nextPreferredSet) { + innerDetect(nextPreferredSet); + } + } + }; + + innerDetect(new Set([preferred])); + return cycle; + }, +}; + +// If both ics and caldav results exist, default to the caldav results. +gProviderUsage.addPostDetectPreference("caldav", "ics"); + +/** + * Select a specific panel in the dialog. Used to move from one panel to another. + * + * @param {string} id - The id of the panel node to select. + */ +function selectPanel(id) { + for (let element of document.getElementById("calendar-creation-dialog").children) { + element.hidden = element.id != id; + } + let panel = document.getElementById(id); + updateButton("accept", panel); + updateButton("extra2", panel); + selectNetworkStatus("none"); + checkRequired(); + + let firstInput = panel.querySelector("input"); + if (firstInput) { + firstInput.focus(); + } +} + +/** + * Set a specific network loading status for the network settings panel. + * See the CSS file for appropriate values to set. + * + * @param {string} status - The status to set. + */ +function selectNetworkStatus(status) { + for (let row of document.querySelectorAll(".network-status-row")) { + row.setAttribute("status", status); + } +} + +/** + * Update the label, accesskey, and event listener for a dialog button. + * + * @param {string} name - The dialog button name, e.g. 'accept', 'extra2'. + * @param {Element} sourceNode - The source node to take attribute values from. + */ +function updateButton(name, sourceNode) { + let dialog = document.getElementById("calendar-creation-dialog"); + let button = dialog.getButton(name); + let label = sourceNode.getAttribute("buttonlabel" + name); + let accesskey = sourceNode.getAttribute("buttonaccesskey" + name); + + let handler = gButtonHandlers.forNodeId[sourceNode.id][name]; + + if (label) { + button.setAttribute("label", label); + button.hidden = false; + } else { + button.hidden = true; + } + + button.setAttribute("accesskey", accesskey || ""); + + // 'dialogaccept', 'dialogextra2', etc. + let eventName = "dialog" + name; + + document.removeEventListener(eventName, gButtonHandlers[name]); + if (handler) { + document.addEventListener(eventName, handler); + // Store a reference to the current handler, to allow removing it later. + gButtonHandlers[name] = handler; + } +} + +/** + * Update the disabled state of the accept button by checking the values of + * required fields, based on the current panel. + */ +function checkRequired() { + let dialog = document.getElementById("calendar-creation-dialog"); + let selectedPanel = null; + for (let element of dialog.children) { + if (!element.hidden) { + selectedPanel = element; + } + } + if (!selectedPanel) { + dialog.setAttribute("buttondisabledaccept", "true"); + return; + } + + let disabled = false; + switch (selectedPanel.id) { + case "panel-local-calendar-settings": + disabled = !selectedPanel.querySelector("form").checkValidity(); + break; + case "panel-network-calendar-settings": { + let location = document.getElementById("network-location-input"); + let username = document.getElementById("network-username-input"); + + disabled = !location.value && !username.value.split("@")[1]; + break; + } + } + + if (disabled) { + dialog.setAttribute("buttondisabledaccept", "true"); + } else { + dialog.removeAttribute("buttondisabledaccept"); + } +} + +/** + * Update the placeholder text for the network location field. If the username + * is a valid email address use the domain part of the username, otherwise use + * the default placeholder. + */ +function fillLocationPlaceholder() { + let location = document.getElementById("network-location-input"); + let userval = document.getElementById("network-username-input").value; + let parts = userval.split("@"); + let domain = parts.length == 2 && parts[1] ? parts[1] : null; + + if (domain) { + location.setAttribute("placeholder", domain); + } else { + location.setAttribute("placeholder", location.getAttribute("default-placeholder")); + } +} + +/** + * Update the select network calendar panel to show or hide the provider + * selection dropdown. + * + * @param {boolean} isSingle - If true, there is just one matching provider. + */ +function setSingleProvider(isSingle) { + document.getElementById("network-selectcalendar-description-single").hidden = !isSingle; + document.getElementById("network-selectcalendar-description-multiple").hidden = isSingle; + document.getElementById("network-selectcalendar-providertype-box").hidden = isSingle; +} + +/** + * Fill the providers menulist with the given provider types. The types must + * correspond to the providers that detected calendars. + * + * @param {string[]} providerTypes - An array of provider types. + * @returns {Element} The selected menuitem. + */ +function fillProviders(providerTypes) { + let menulist = document.getElementById("network-selectcalendar-providertype-menulist"); + let popup = menulist.menupopup; + while (popup.lastChild) { + popup.removeChild(popup.lastChild); + } + + let providers = cal.provider.detection.providers; + + for (let type of providerTypes) { + let provider = providers.get(type); + let menuitem = document.createXULElement("menuitem"); + menuitem.value = type; + menuitem.setAttribute("label", provider.displayName || type); + popup.appendChild(menuitem); + } + + // Select a provider menu item based on provider preferences. + let preferredTypes = new Set(providerTypes); + + for (let [nonPreferred, preferredSet] of gProviderUsage.postDetectPreferences) { + if (preferredTypes.has(nonPreferred) && setsIntersect(preferredSet, preferredTypes)) { + preferredTypes.delete(nonPreferred); + } + } + let preferredIndex = providerTypes.findIndex(type => preferredTypes.has(type)); + menulist.selectedIndex = preferredIndex == -1 ? 0 : preferredIndex; + + return menulist.selectedItem; +} + +/** + * Return true if the intersection of two sets contains at least one item. + * + * @param {Set} setA - A set. + * @param {Set} setB - A set. + * @returns {boolean} + */ +function setsIntersect(setA, setB) { + for (let item of setA) { + if (setB.has(item)) { + return true; + } + } + return false; +} + +/** + * Select the given provider and update the calendar list to fill the + * corresponding calendars. Will use the results from the last findCalendars + * response. + * + * @param {string} type - The provider type to select. + */ +function selectProvider(type) { + let providerMap = findCalendars.lastResult; + let calendarList = document.getElementById("network-calendar-list"); + + let calendars = providerMap.get(type) || []; + renderCalendarList(calendarList, calendars); +} + +/** + * Empty a calendar list and then fill it with calendars. + * + * @param {Element} calendarList - A richlistbox element for listing calendars. + * @param {calICalendar[]} calendars - An array of calendars to display in the list. + */ +function renderCalendarList(calendarList, calendars) { + while (calendarList.hasChildNodes()) { + calendarList.lastChild.remove(); + } + let propertiesButtonLabel = calendarList.getAttribute("propertiesbuttonlabel"); + calendars.forEach((calendar, index) => { + let item = document.createXULElement("richlistitem"); + item.calendar = calendar; + + let checkbox = document.createXULElement("checkbox"); + let checkboxId = "checkbox" + index; + checkbox.id = checkboxId; + checkbox.classList.add("calendar-selected"); + item.appendChild(checkbox); + + let colorMarker = document.createElement("div"); + colorMarker.classList.add("calendar-color"); + colorMarker.style.backgroundColor = calendar.getProperty("color"); + item.appendChild(colorMarker); + + let label = document.createXULElement("label"); + label.classList.add("calendar-name"); + label.value = calendar.name; + label.control = checkboxId; + item.appendChild(label); + + let propertiesButton = document.createXULElement("button"); + propertiesButton.classList.add("calendar-edit-button"); + propertiesButton.label = propertiesButtonLabel; + propertiesButton.addEventListener("command", openCalendarPropertiesFromEvent); + item.appendChild(propertiesButton); + + if (calendar.getProperty("disabled")) { + item.disabled = true; + item.toggleAttribute("calendar-disabled", true); + checkbox.disabled = true; + propertiesButton.disabled = true; + } else { + checkbox.checked = true; + } + calendarList.appendChild(item); + }); +} + +/** + * Update dialog fields based on the value of the "no credentials" checkbox. + * + * @param {boolean} noCredentials - True, if "no credentials" is checked. + */ +function updateNoCredentials(noCredentials) { + if (noCredentials) { + document.getElementById("network-username-input").setAttribute("disabled", "true"); + document.getElementById("network-username-input").value = ""; + } else { + document.getElementById("network-username-input").removeAttribute("disabled"); + } +} + +/** + * The accept button event listener for the "select calendar type" panel. + * + * @param {Event} event + */ +function selectCalendarType(event) { + event.preventDefault(); + event.stopPropagation(); + let radiogroup = document.getElementById("calendar-type"); + let calendarType = gCalendarTypes.get(radiogroup.value); + + if (!calendarType.builtIn && calendarType !== gSelectedCalendarType) { + setUpAddonCalendarSettingsPanel(calendarType); + } + gSelectedCalendarType = calendarType; + calendarType.onSelected(); +} + +/** + * Set up the settings panel for calendar types registered by addons. + * + * @param {CalendarType} calendarType - The calendar type. + */ +function setUpAddonCalendarSettingsPanel(calendarType) { + function setUpBrowser(browser, src) { + // Allow keeping dialog background color without jumping through hoops. + browser.setAttribute("transparent", "true"); + browser.setAttribute("flex", "1"); + browser.setAttribute("type", "content"); + browser.setAttribute("src", src); + } + let panel = document.getElementById("panel-addon-calendar-settings"); + let browser = panel.lastElementChild; + + if (browser) { + setUpBrowser(browser, calendarType.panelSrc); + } else { + browser = document.createXULElement("browser"); + setUpBrowser(browser, calendarType.panelSrc); + + panel.appendChild(browser); + // The following emit is needed for the browser to work with addon content. + ExtensionParent.apiManager.emit("extension-browser-inserted", browser); + } + + // Set up the accept button handler for the panel. + gButtonHandlers.forNodeId["panel-addon-calendar-settings"].accept = calendarType.onCreated; +} + +/** + * Handle change of the email (identity) menu for local calendar creation. + * Show a notification when "none" is selected. + * + * @param {Event} event - The menu selection event. + */ +function onChangeIdentity(event) { + notifyOnIdentitySelection(gLocalCalendar); +} + +/** + * Prepare the local storage calendar with the information from the dialog. + * This can be monkeypatched to add additional values. + * + * @param {calICalendar} calendar - The calendar to prepare. + * @returns {calICalendar} The same calendar, prepared with any + * extra values. + */ +function prepareLocalCalendar(calendar) { + calendar.name = document.getElementById("local-calendar-name-input").value; + calendar.setProperty("color", document.getElementById("local-calendar-color-picker").value); + + if (!document.getElementById("local-fire-alarms-checkbox").checked) { + calendar.setProperty("suppressAlarms", true); + } + + saveMailIdentitySelection(calendar); + return calendar; +} + +/** + * The accept button event listener for the "local calendar settings" panel. + * Registers the local storage calendar and closes the dialog. + */ +function registerLocalCalendar() { + cal.manager.registerCalendar(prepareLocalCalendar(gLocalCalendar)); +} + +/** + * Start detection and find any calendars using the information from the + * network settings panel. + * + * @param {string} [password] - The password for this attempt, if any. + * @param {boolean} [savePassword] - Whether to save the password in the + * password manager. + */ +function findCalendars(password, savePassword = false) { + selectNetworkStatus("loading"); + let username = document.getElementById("network-username-input"); + let location = document.getElementById("network-location-input"); + let locationValue = location.value || username.value.split("@")[1] || ""; + + // webcal(s): doesn't work with content principal. + locationValue = locationValue.replace(/^webcal(s)?(:.*)/, "http$1$2").trim(); + cal.provider.detection + .detect( + username.value, + password, + locationValue, + savePassword, + gProviderUsage.preDetectFilters, + {} + ) + .then(onDetectionSuccess, onDetectionError.bind(null, password, locationValue)); +} + +/** + * Called when detection successfully finds calendars. Displays the UI for + * selecting calendars to subscribe to. + * + * @param {Map<string, calICalendar[]>} providerMap Map from provider type + * (e.g. "ics", "caldav") + * to an array of calendars. + */ +function onDetectionSuccess(providerMap) { + // Disable the calendars the user has already subscribed to. In the future + // we should show a string when all calendars are already subscribed. + let existing = new Set(cal.manager.getCalendars({}).map(calendar => calendar.uri.spec)); + + let calendarsMap = new Map(); + for (let [provider, calendars] of providerMap.entries()) { + let newCalendars = calendars.map(calendar => { + let newCalendar = prepareNetworkCalendar(calendar); + if (existing.has(calendar.uri.spec)) { + newCalendar.setProperty("disabled", true); + } + return newCalendar; + }); + + calendarsMap.set(provider.type, newCalendars); + } + + if (!calendarsMap.size) { + selectNetworkStatus("notfound"); + return; + } + + // Update the panel with the results from the provider map. + setSingleProvider(calendarsMap.size <= 1); + findCalendars.lastResult = calendarsMap; + + let selectedItem = fillProviders([...calendarsMap.keys()]); + selectProvider(selectedItem.value); + + // Select the panel and validate the fields. + selectPanel("panel-select-calendars"); + checkRequired(); +} + +/** + * Called when detection fails to find any calendars. Show an appropriate + * error message, or if the error is an authentication error and no password + * was entered for this attempt, prompt the user to enter a password. + * + * @param {string} [password] - The password entered, if any. + * @param {string} [location] - The location input from the dialog. + * @param {Error} error - An error object. + */ +function onDetectionError(password, location, error) { + if (error instanceof cal.provider.detection.AuthFailedError) { + if (password) { + selectNetworkStatus("authfail"); + } else { + findCalendarsWithPassword(location); + return; + } + } else if (error instanceof cal.provider.detection.CanceledError) { + selectNetworkStatus("none"); + } else { + selectNetworkStatus("notfound"); + } + cal.ERROR( + "Error during calendar detection: " + + `${error.fileName || error.filename}:${error.lineNumber}: ${error}\n${error.stack}` + ); +} + +/** + * Prompt the user for a password and attempt to find calendars with it. + * + * @param {string} location - The location input from the dialog. + */ +function findCalendarsWithPassword(location) { + let password = { value: "" }; + let savePassword = { value: 1 }; + + let okWasClicked = new MsgAuthPrompt().promptPassword2( + null, + cal.l10n.getAnyString("messenger-mapi", "mapi", "loginText", [location]), + password, + cal.l10n.getAnyString("passwordmgr", "passwordmgr", "rememberPassword"), + savePassword + ); + + if (okWasClicked) { + findCalendars(password.value, savePassword.value); + } else { + selectNetworkStatus("authfail"); + } +} + +/** + * Make preparations on the given calendar (a detected calendar). This + * function can be monkeypatched to make general preparations, e.g. for values + * from additional form fields. + * + * @param {calICalendar} calendar - The calendar to prepare. + * @returns {calICalendar} The same calendar, prepared with + * any extra values. + */ +function prepareNetworkCalendar(calendar) { + let cached = document.getElementById("network-cache-checkbox").checked; + + if (!calendar.getProperty("cache.always")) { + let cacheSupported = calendar.getProperty("cache.supported") !== false; + calendar.setProperty("cache.enabled", cacheSupported ? cached : false); + } + + return calendar; +} + +/** + * The accept button handler for the 'select network calendars' panel. + * Subscribes to all of the selected network calendars and allows the dialog to + * close. + */ +function createNetworkCalendars() { + for (let listItem of document.getElementById("network-calendar-list").children) { + if (listItem.querySelector(".calendar-selected").checked) { + cal.manager.registerCalendar(listItem.calendar); + } + } +} + +/** + * Open the calendar properties dialog for a calendar in the calendar list. + * + * @param {Event} event - The triggering event. + */ +function openCalendarPropertiesFromEvent(event) { + let listItem = event.target.closest("richlistitem"); + if (listItem) { + let calendar = listItem.calendar; + if (calendar && !calendar.getProperty("disabled")) { + cal.window.openCalendarProperties(window, { calendar, canDisable: false }); + + // Update the calendar list item. + listItem.querySelector(".calendar-name").value = calendar.name; + listItem.querySelector(".calendar-color").style.backgroundColor = + calendar.getProperty("color"); + } + } +} + +window.addEventListener("load", () => { + fillLocationPlaceholder(); + selectPanel("panel-select-calendar-type"); + if (window.arguments[0]) { + let spec = window.arguments[0].spec; + if (/^webcals?:\/\//.test(spec)) { + selectPanel("panel-network-calendar-settings"); + document.getElementById("network-location-input").value = spec; + checkRequired(); + } + } +}); |