From 951cfde278ad763ab8fdad9914314a45886072f4 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 25 Mar 2026 21:32:32 +0100 Subject: [PATCH 1/2] Address null-safety identified by TSC compiler v6 There are no actual functional changes. This just adds many additional annotations and function which ensure type safety and null safety. --- radicale/web/internal_data/js/api/api.js | 187 +++++++++--------- radicale/web/internal_data/js/api/sharing.js | 68 ++++--- radicale/web/internal_data/js/constants.js | 2 + .../js/scenes/CollectionsScene.js | 72 ++++--- .../js/scenes/CreateEditCollectionScene.js | 57 +++--- .../js/scenes/CreateEditShareScene.js | 87 ++++---- .../js/scenes/DeleteConfirmationScene.js | 27 +-- .../js/scenes/IncomingSharingScene.js | 35 ++-- .../internal_data/js/scenes/LoadingScene.js | 3 +- .../web/internal_data/js/scenes/LoginScene.js | 71 +++---- .../js/scenes/ShareCollectionScene.js | 61 +++--- .../js/scenes/UploadCollectionScene.js | 50 +++-- .../internal_data/js/scenes/scene_manager.js | 23 ++- .../js/utils/collections_cache.js | 12 +- radicale/web/internal_data/js/utils/error.js | 8 +- .../internal_data/js/utils/form_validator.js | 9 +- radicale/web/internal_data/js/utils/misc.js | 33 +++- .../web/internal_data/js/utils/permissions.js | 4 +- radicale/web/jsconfig.json | 2 +- 19 files changed, 461 insertions(+), 350 deletions(-) diff --git a/radicale/web/internal_data/js/api/api.js b/radicale/web/internal_data/js/api/api.js index c371e07e..5b65e5ce 100644 --- a/radicale/web/internal_data/js/api/api.js +++ b/radicale/web/internal_data/js/api/api.js @@ -26,8 +26,8 @@ import { create_request, to_error_message } from "./common.js"; /** * Find the principal collection. - * @param {string} user - * @param {string} password + * @param {?string} user + * @param {?string} password * @param {function(?Collection, ?string):void} callback Returns result or error * @return {XMLHttpRequest} */ @@ -39,18 +39,22 @@ export function get_principal(user, password, callback) { } if (request.status === 207) { let xml = request.responseXML; - let principal_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|current-user-principal > *|href"); - let displayname_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|displayname"); - if (principal_element) { - callback(new Collection( - principal_element.textContent, - CollectionType.PRINCIPAL, - displayname_element ? displayname_element.textContent : "", - "", - "", - 0, - 0, - ""), null); + if (xml) { + let principal_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|current-user-principal > *|href"); + let displayname_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|displayname"); + if (principal_element) { + callback(new Collection( + principal_element.textContent, + CollectionType.PRINCIPAL, + displayname_element ? displayname_element.textContent : "", + "", + "", + 0, + 0, + ""), null); + } else { + callback(null, "No valid XML received") + } } else { callback(null, "Internal error"); } @@ -71,7 +75,7 @@ export function get_principal(user, password, callback) { /** * Find all calendars and addressbooks in collection. * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {function(?Array, ?string):void} callback Returns result or error * @return {XMLHttpRequest} @@ -85,81 +89,86 @@ export function get_collections(user, password, collection, callback) { } if (request.status === 207) { let xml = request.responseXML; - let collections = []; - let response_query = "*|multistatus:root > *|response"; - let responses = xml.querySelectorAll(response_query); - for (let i = 0; i < responses.length; i++) { - let response = responses[i]; - let href_element = response.querySelector(response_query + " > *|href"); - let resourcetype_query = response_query + " > *|propstat > *|prop > *|resourcetype"; - let resourcetype_element = response.querySelector(resourcetype_query); - let displayname_element = response.querySelector(response_query + " > *|propstat > *|prop > *|displayname"); - let calendarcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-color"); - let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); - let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); - let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); - let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); - let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); - let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); - let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; - let components_element = response.querySelector(components_query); - let href = href_element ? href_element.textContent : ""; - let displayname = displayname_element ? displayname_element.textContent : ""; - let type = ""; - let color = ""; - let description = ""; - let source = ""; - let count = 0; - let size = 0; - if (resourcetype_element) { - if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { - type = CollectionType.ADDRESSBOOK; - color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; - description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; - count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; - size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; - } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { - type = CollectionType.WEBCAL; - source = webcalsource_element ? webcalsource_element.textContent : ""; - color = calendarcolor_element ? calendarcolor_element.textContent : ""; - description = calendardesc_element ? calendardesc_element.textContent : ""; - } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { - if (components_element) { - if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { - type = CollectionType.union(type, CollectionType.CALENDAR); - } - if (components_element.querySelector(components_query + " > *|comp[name=VJOURNAL]")) { - type = CollectionType.union(type, CollectionType.JOURNAL); - } - if (components_element.querySelector(components_query + " > *|comp[name=VTODO]")) { - type = CollectionType.union(type, CollectionType.TASKS); + if (xml) { + let collections = []; + let response_query = "*|multistatus:root > *|response"; + let responses = xml.querySelectorAll(response_query); + for (let i = 0; i < responses.length; i++) { + let response = responses[i]; + let href_element = response.querySelector(response_query + " > *|href"); + let resourcetype_query = response_query + " > *|propstat > *|prop > *|resourcetype"; + let resourcetype_element = response.querySelector(resourcetype_query); + let displayname_element = response.querySelector(response_query + " > *|propstat > *|prop > *|displayname"); + let calendarcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-color"); + let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); + let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); + let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); + let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); + let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); + let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; + let components_element = response.querySelector(components_query); + let href = href_element ? href_element.textContent : ""; + let displayname = displayname_element ? displayname_element.textContent : ""; + let type = ""; + let color = ""; + let description = ""; + let source = ""; + let count = 0; + let size = 0; + if (resourcetype_element) { + if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { + type = CollectionType.ADDRESSBOOK; + color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; + description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; + } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { + type = CollectionType.WEBCAL; + source = webcalsource_element ? webcalsource_element.textContent : ""; + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; + } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { + if (components_element) { + if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { + type = CollectionType.union(type, CollectionType.CALENDAR); + } + if (components_element.querySelector(components_query + " > *|comp[name=VJOURNAL]")) { + type = CollectionType.union(type, CollectionType.JOURNAL); + } + if (components_element.querySelector(components_query + " > *|comp[name=VTODO]")) { + type = CollectionType.union(type, CollectionType.TASKS); + } } + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; } - color = calendarcolor_element ? calendarcolor_element.textContent : ""; - description = calendardesc_element ? calendardesc_element.textContent : ""; - count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; - size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; + } + let sane_color = color.trim(); + if (sane_color) { + let color_match = COLOR_RE.exec(sane_color); + if (color_match) { + sane_color = color_match[1]; + } else { + sane_color = ""; + } + } + if (href.substr(-1) === "/" && href !== collection.href && type) { + collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); } } - let sane_color = color.trim(); - if (sane_color) { - let color_match = COLOR_RE.exec(sane_color); - if (color_match) { - sane_color = color_match[1]; - } else { - sane_color = ""; - } - } - if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); - } - } - collections.sort(function (a, b) { + collections.sort(function (a, b) { /** @type {string} */ let ca = a.displayname || a.href; /** @type {string} */ let cb = b.displayname || b.href; - return ca.localeCompare(cb); - }); - callback(collections, null); + return ca.localeCompare(cb); + }); + callback(collections, null); + } else { + callback(null, "No valid XML received") + } + } else { callback(null, to_error_message(request)); } @@ -192,7 +201,7 @@ export function get_collections(user, password, collection, callback) { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {string} collection_href Must always start and end with /. * @param {File} file * @param {function(?string):void} callback Returns error or null @@ -217,7 +226,7 @@ export function upload_collection(user, password, collection_href, file, callbac /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {function(?string):void} callback Returns error or null * @return {XMLHttpRequest} @@ -240,7 +249,7 @@ export function delete_collection(user, password, collection, callback) { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {boolean} create * @param {function(?string):void} callback Returns error or null @@ -320,7 +329,7 @@ function create_edit_collection(user, password, collection, create, callback) { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {function(?string):void} callback Returns error or null * @return {XMLHttpRequest} @@ -331,7 +340,7 @@ export function create_collection(user, password, collection, callback) { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {function(?string):void} callback Returns error or null * @return {XMLHttpRequest} diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index 30739e0f..bdfa2587 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -39,12 +39,12 @@ import { create_request, to_error_message } from "./common.js"; /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {string} path * @param {object} body * @param {function(string):void} on_success - * @param {function():void} on_not_found - * @param {function(string):void} on_error + * @param {?function():void} on_not_found + * @param {?function(string):void} on_error * @returns {XMLHttpRequest} */ function call_sharing_api( @@ -92,7 +92,7 @@ function call_sharing_api( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {function(import("../api/sharing.js").ServerFeatures, ?string):void} callback */ export function discover_server_features(user, password, callback) { @@ -106,7 +106,11 @@ export function discover_server_features(user, password, callback) { let features = { "sharing": JSON.parse(response) }; callback(features, null); } catch (e) { - callback({}, e.message); + if (e instanceof Error) { + callback({}, e.message); + } else { + callback({}, e ? e.toString() : "Unknown error"); + } } }, function () { @@ -119,9 +123,29 @@ export function discover_server_features(user, password, callback) { ); } +/** + * @typedef {Object} ShareData + * @property {string} [ShareType] + * @property {string} [PathOrToken] + * @property {string} [PathMapped] + * @property {string} [Owner] + * @property {string} [User] + * @property {string} [Permissions] + * @property {?boolean} [EnabledByOwner] + * @property {?boolean} [EnabledByUser] + * @property {?boolean} [Enabled] + * @property {?boolean} [HiddenByOwner] + * @property {?boolean} [HiddenByUser] + * @property {?boolean} [Hidden] + * @property {number} [TimestampCreated] + * @property {number} [TimestampUpdated] + * @property {Object} [Properties] + */ + + export class Share { /** - * @param {Object} [data] + * @param {ShareData} [data] */ constructor(data = {}) { /** @type {string} */ this.ShareType = data.ShareType || ""; @@ -130,20 +154,20 @@ export class Share { /** @type {string} */ this.Owner = data.Owner || ""; /** @type {string} */ this.User = data.User || ""; /** @type {string} */ this.Permissions = data.Permissions || "r"; - /** @type {boolean} */ this.EnabledByOwner = data.EnabledByOwner ?? data.Enabled ?? false; + /** @type {?boolean} */ this.EnabledByOwner = data.EnabledByOwner ?? data.Enabled ?? false; /** @type {?boolean} */ this.EnabledByUser = data.EnabledByUser ?? data.Enabled ?? null; - /** @type {boolean} */ this.HiddenByOwner = data.HiddenByOwner ?? data.Hidden ?? false; + /** @type {?boolean} */ this.HiddenByOwner = data.HiddenByOwner ?? data.Hidden ?? false; /** @type {?boolean} */ this.HiddenByUser = data.HiddenByUser ?? data.Hidden ?? null; /** @type {number} */ this.TimestampCreated = data.TimestampCreated || 0; /** @type {number} */ this.TimestampUpdated = data.TimestampUpdated || 0; - /** @type {Object} */ this.Properties = data.Properties || {}; + /** @type {Object} */ this.Properties = data.Properties || {}; } } /** * @param {string} user - * @param {string} password - * @param {import("../models/collection.js").Collection} collection + * @param {?string} password + * @param {?import("../models/collection.js").Collection} collection * @param {function(Array, ?string):void} callback */ export function reload_sharing_list(user, password, collection, callback) { @@ -155,7 +179,7 @@ export function reload_sharing_list(user, password, collection, callback) { body, function (response) { let parsed = JSON.parse(response); - let shares = (parsed["Content"] || []).map(data => new Share(data)); + let shares = (parsed["Content"] || []).map((/** @type {ShareData} */ data) => new Share(data)); callback(shares, null); }, null, // on_not_found @@ -200,7 +224,7 @@ export function get_property_key(type, property) { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -238,7 +262,7 @@ export function add_share_by_token( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -278,7 +302,7 @@ export function add_share_by_map( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -310,7 +334,7 @@ export function delete_share_by_token( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -341,7 +365,7 @@ export function delete_share_by_map( } /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -379,7 +403,7 @@ export function update_share_by_token( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -421,7 +445,7 @@ export function update_share_by_map( * Update a shared map entry as the recipient user. * Only sends fields the non-owner user is allowed to change: PathOrToken, EnabledByUser, HiddenByUser. * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -457,7 +481,7 @@ export function update_incoming_share( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -497,7 +521,7 @@ export function add_share_by_bday( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ @@ -529,7 +553,7 @@ export function delete_share_by_bday( /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Share} share * @param {function(?string):void} callback */ diff --git a/radicale/web/internal_data/js/constants.js b/radicale/web/internal_data/js/constants.js index 031fed39..f760a97b 100644 --- a/radicale/web/internal_data/js/constants.js +++ b/radicale/web/internal_data/js/constants.js @@ -36,6 +36,7 @@ export const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\ /** * Regex to match and normalize color * @const + * @type {RegExp} */ export const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); @@ -43,5 +44,6 @@ export const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); /** * The text needed to confirm deleting a collection * @const + * @type {string} */ export const DELETE_CONFIRMATION_TEXT = "DELETE"; \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index c53245cb..8e4ce432 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -25,7 +25,7 @@ import { SERVER } from "../constants.js"; import { Collection, CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; -import { bytesToHumanReadable } from "../utils/misc.js"; +import { bytesToHumanReadable, get_element, get_element_by_id } from "../utils/misc.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js"; import { IncomingSharingScene } from "./IncomingSharingScene.js"; @@ -39,18 +39,18 @@ import { UploadCollectionScene } from "./UploadCollectionScene.js"; export class CollectionsScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} principal_collection The princial collection - * @param {function(string):void} onerror Called when an error occurs, before the + * @param {function(?string):void} onerror Called when an error occurs, before the * scene is popped. */ constructor(user, password, principal_collection, onerror) { - /** @type {HTMLElement} */ let html_scene = document.getElementById("collectionsscene"); - /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=collectiontemplate]"); - /** @type {HTMLElement} */ let new_btn = html_scene.querySelector("[data-name=new]"); - /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=upload]"); - /** @type {HTMLElement} */ let incomingshares_btn = html_scene.querySelector("[data-name=incomingshares]"); - /** @type {HTMLElement} */ let error_div = html_scene.querySelector("[data-name=collectionsscene_error]"); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("collectionsscene"); + /** @type {HTMLElement} */ let template = get_element(html_scene, "[data-name=collectiontemplate]"); + /** @type {HTMLElement} */ let new_btn = get_element(html_scene, "[data-name=new]"); + /** @type {HTMLElement} */ let upload_btn = get_element(html_scene, "[data-name=upload]"); + /** @type {HTMLElement} */ let incomingshares_btn = get_element(html_scene, "[data-name=incomingshares]"); + /** @type {HTMLElement} */ let error_div = get_element(html_scene, "[data-name=collectionsscene_error]"); /** @type {Array} */ let nodes = []; let errorHandler = new ErrorHandler(error_div); @@ -133,7 +133,7 @@ export class CollectionsScene { * @param {boolean} clear_error */ function show_collections(collections, shares, clear_error) { - /** @type {HTMLElement} */ let navBar = document.querySelector("#logoutview"); + /** @type {HTMLElement} */ let navBar = get_element(document, "#logoutview"); let heightOfNavBar = navBar.offsetHeight + "px"; html_scene.style.marginTop = heightOfNavBar; html_scene.style.height = "calc(100vh - " + heightOfNavBar + ")"; @@ -144,22 +144,24 @@ export class CollectionsScene { // Clear old nodes nodes.forEach(function (node) { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } }); nodes = []; collections.forEach(function (/** @type {Collection} */ collection) { /** @type {HTMLElement} */ let node = /** @type {HTMLElement} */(template.cloneNode(true)); node.classList.remove("hidden"); - /** @type {HTMLElement} */ let title_form = node.querySelector("[data-name=title]"); - /** @type {HTMLElement} */ let description_form = node.querySelector("[data-name=description]"); - /** @type {HTMLElement} */ let contentcount_form = node.querySelector("[data-name=contentcount]"); - /** @type {HTMLInputElement} */ let url_form = node.querySelector("[data-name=url]"); - /** @type {HTMLElement} */ let color_form = node.querySelector("[data-name=color]"); - /** @type {HTMLElement} */ let delete_btn = node.querySelector("[data-name=delete]"); - /** @type {HTMLElement} */ let edit_btn = node.querySelector("[data-name=edit]"); - /** @type {HTMLElement} */ let share_btn = node.querySelector("[data-name=share]"); - /** @type {HTMLAnchorElement} */ let download_btn = node.querySelector("[data-name=download]"); + /** @type {HTMLElement} */ let title_form = get_element(node, "[data-name=title]"); + /** @type {HTMLElement} */ let description_form = get_element(node, "[data-name=description]"); + /** @type {HTMLElement} */ let contentcount_form = get_element(node, "[data-name=contentcount]"); + /** @type {HTMLInputElement} */ let url_form = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=url]")); + /** @type {HTMLElement} */ let color_form = get_element(node, "[data-name=color]"); + /** @type {HTMLElement} */ let delete_btn = get_element(node, "[data-name=delete]"); + /** @type {HTMLElement} */ let edit_btn = get_element(node, "[data-name=edit]"); + /** @type {HTMLElement} */ let share_btn = get_element(node, "[data-name=share]"); + /** @type {HTMLAnchorElement} */ let download_btn = /** @type {HTMLAnchorElement} */ (get_element(node, "[data-name=download]")); if (collection.color) { color_form.style.background = collection.color; } @@ -175,22 +177,22 @@ export class CollectionsScene { }); possible_types.forEach(function (e) { if (e !== collection.type) { - node.querySelector("[data-name=" + e + "]").classList.add("hidden"); + get_element(node, "[data-name=" + e + "]").classList.add("hidden"); } }); - let share_info = node.querySelector("[data-name=shared-by]"); - let transformed_from = node.querySelector("[data-name=transformed-from]"); + let share_info = get_element(node, "[data-name=shared-by]"); + let transformed_from = get_element(node, "[data-name=transformed-from]"); let share = (shares || []).find( s => (s.ShareType === "map" || s.ShareType === "bday") && (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, "")); if (share) { if (share.Owner !== user) { share_info.classList.remove("hidden"); - node.querySelector("[data-name=shared-by-owner]").textContent = share.Owner; + get_element(node, "[data-name=shared-by-owner]").textContent = share.Owner; } else { transformed_from.classList.remove("hidden"); } - let share_option = node.querySelector("[data-name=shareoption]"); + let share_option = get_element(node, "[data-name=shareoption]"); if (share_option) { share_option.classList.add("hidden"); share_option.removeAttribute("data-name"); @@ -223,10 +225,10 @@ export class CollectionsScene { download_btn.onclick = function (event) { event.preventDefault(); let auth = get_auth_header(user, password); - let headers = auth ? { 'Authorization': auth } : {}; - fetch(href, { - headers: headers - }).then(function (response) { + let headers = auth ? { + 'Authorization': auth + } : undefined; + fetch(href, { headers: headers }).then(function (response) { if (response.ok) { return response.blob(); } @@ -245,14 +247,18 @@ export class CollectionsScene { }); }; if (collection.type == CollectionType.WEBCAL) { - download_btn.parentElement.classList.add("hidden"); + if (download_btn.parentElement) { + download_btn.parentElement.classList.add("hidden"); + } } delete_btn.onclick = function () { return ondelete(collection); }; edit_btn.onclick = function () { return onedit(collection); }; share_btn.onclick = function () { return onshare(collection); }; node.classList.remove("hidden"); nodes.push(node); - template.parentNode.insertBefore(node, template); + if (template.parentNode) { + template.parentNode.insertBefore(node, template); + } }); } @@ -276,7 +282,9 @@ export class CollectionsScene { incomingshares_btn.onclick = null; // remove collection nodes.forEach(function (node) { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } }); nodes = []; }; diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index 2d47a1b6..422c36ac 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -25,7 +25,7 @@ import { Collection, CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_color, validate_href } from "../utils/form_validator.js"; -import { cleanHREFinput, onCleanHREFinput, random_hex, random_uuid } from "../utils/misc.js"; +import { cleanHREFinput, get_element, get_element_by_id, onCleanHREFinput, random_hex, random_uuid } from "../utils/misc.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, is_current_scene, pop_scene, pop_to_parent, push_scene } from "./scene_manager.js"; @@ -35,25 +35,25 @@ import { Scene, is_current_scene, pop_scene, pop_to_parent, push_scene } from ". export class CreateEditCollectionScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection if it's a principal collection, a new * collection will be created inside of it. * Otherwise the collection will be edited. */ constructor(user, password, collection) { let edit = collection.type !== CollectionType.PRINCIPAL; - let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); - /** @type {HTMLElement} */ let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; - /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); - /** @type {HTMLInputElement} */ let href_form = html_scene.querySelector("[data-name=href]"); - /** @type {HTMLInputElement} */ let displayname_form = html_scene.querySelector("[data-name=displayname]"); - /** @type {HTMLInputElement} */ let description_form = html_scene.querySelector("[data-name=description]"); - /** @type {HTMLInputElement} */ let source_form = html_scene.querySelector("[data-name=source]"); - /** @type {HTMLElement} */ let source_label = html_scene.querySelector("label[for=source]"); - /** @type {HTMLSelectElement} */ let type_form = html_scene.querySelector("[data-name=type]"); - /** @type {HTMLInputElement} */ let color_form = html_scene.querySelector("[data-name=color]"); - /** @type {HTMLElement} */ let submit_btn = html_scene.querySelector("[data-name=submit]"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + let html_scene = get_element_by_id(edit ? "editcollectionscene" : "createcollectionscene"); + /** @type {?HTMLElement} */ let title_form = edit ? get_element(html_scene, "[data-name=title]") : null; + /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); + /** @type {?HTMLInputElement} */ let href_form = edit ? null : /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=href]")); + /** @type {HTMLInputElement} */ let displayname_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=displayname]")); + /** @type {HTMLInputElement} */ let description_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=description]")); + /** @type {HTMLInputElement} */ let source_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=source]")); + /** @type {HTMLElement} */ let source_label = get_element(html_scene, "label[for=source]"); + /** @type {HTMLSelectElement} */ let type_form = /** @type {HTMLSelectElement} */(get_element(html_scene, "[data-name=type]")); + /** @type {HTMLInputElement} */ let color_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=color]")); + /** @type {HTMLElement} */ let submit_btn = get_element(html_scene, "[data-name=submit]"); + /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); /** @type {?XMLHttpRequest} */ let create_edit_req = null; @@ -62,7 +62,7 @@ export class CreateEditCollectionScene { let errorHandler = new ErrorHandler(error_form); let validator = new FormValidator(errorHandler); - if (!edit) { + if (!edit && href_form) { validator.addValidator(href_form, validate_href(href_form, "HREF")); } validator.addValidator(color_form, validate_color(color_form, "Color")); @@ -74,7 +74,7 @@ export class CreateEditCollectionScene { let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; let color = edit && collection.color ? collection.color : "#" + random_hex(6); - if (!edit) { + if (!edit && href_form) { href_form.addEventListener("input", onCleanHREFinput); } @@ -93,7 +93,7 @@ export class CreateEditCollectionScene { } function read_form() { - if (!edit) { + if (!edit && href_form) { cleanHREFinput(href_form); let newhreftxtvalue = href_form.value.trim().toLowerCase(); href = collection.href + newhreftxtvalue + "/"; @@ -107,7 +107,7 @@ export class CreateEditCollectionScene { } function fill_form() { - if (!edit) { + if (!edit && href_form) { href_form.value = random_uuid(); } displayname_form.value = displayname; @@ -127,12 +127,15 @@ export class CreateEditCollectionScene { read_form(); let sane_color = color.trim(); if (sane_color) { - sane_color = COLOR_RE.exec(sane_color)[1]; + /** @type {?RegExpExecArray} */ let match = COLOR_RE.exec(sane_color); + if (match) { + sane_color = match[1]; + } } let loading_scene = new LoadingScene(); push_scene(loading_scene); let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); - let callback = function (error1) { + let callback = function (/** @type {?string} */ error1) { if (!is_current_scene(loading_scene)) { return; } @@ -166,7 +169,7 @@ export class CreateEditCollectionScene { } /** - * @param {Event} _e + * @param {?Event} _e */ function onTypeChange(_e) { if (type_form.value == CollectionType.WEBCAL) { @@ -183,10 +186,12 @@ export class CreateEditCollectionScene { // Clone type_form because it's impossible to hide options without removing them saved_type_form = type_form; type_form = /** @type {HTMLSelectElement} */ (type_form.cloneNode(true)); - saved_type_form.parentNode.replaceChild(type_form, saved_type_form); + if (saved_type_form.parentNode) { + saved_type_form.parentNode.replaceChild(type_form, saved_type_form); + } remove_invalid_types(); html_scene.classList.remove("hidden"); - if (edit) { + if (edit && title_form) { title_form.textContent = collection.displayname || collection.href; } fill_form(); @@ -198,8 +203,10 @@ export class CreateEditCollectionScene { read_form(); html_scene.classList.add("hidden"); // restore type_form - type_form.parentNode.replaceChild(saved_type_form, type_form); - type_form = saved_type_form; + if (type_form.parentNode && saved_type_form) { + type_form.parentNode.replaceChild(saved_type_form, type_form); + type_form = saved_type_form; + } saved_type_form = null; submit_btn.onclick = null; cancel_btn.onclick = null; diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 18176178..f5c97518 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -24,7 +24,7 @@ import { CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_href, validate_non_empty, validate_not_empty_or_equals } from "../utils/form_validator.js"; -import { onCleanHREFinput, random_uuid } from "../utils/misc.js"; +import { get_element, get_element_by_id, onCleanHREFinput, random_uuid } from "../utils/misc.js"; import { Scene, is_current_scene, pop_scene } from "./scene_manager.js"; /** @@ -33,7 +33,7 @@ import { Scene, is_current_scene, pop_scene } from "./scene_manager.js"; export class CreateEditShareScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {import("../models/collection.js").Collection} collection * @param {string} shareType * @param {Share} [share] If provided, the scene will be in edit mode. @@ -42,27 +42,27 @@ export class CreateEditShareScene { let self = this; let edit = !!share; let pathMapped = collection.href; - /** @type {HTMLElement} */ let html_scene = document.getElementById("newshare"); - /** @type {HTMLFormElement} */ let form = html_scene.querySelector("form"); - /** @type {HTMLElement} */ let sharemapfields = html_scene.querySelector("[data-name=sharemapfields]"); - /** @type {HTMLInputElement} */ let shareuser_input = html_scene.querySelector("[data-name=shareuser]"); - /** @type {HTMLInputElement} */ let sharehref_input = html_scene.querySelector("[data-name=sharehref]"); - /** @type {HTMLInputElement} */ let enabled_checkbox = html_scene.querySelector("[data-name=enabled]"); - /** @type {HTMLInputElement} */ let hidden_checkbox = html_scene.querySelector("[data-name=hidden]"); - let permissions_ro_radio = /** @type {HTMLInputElement} */ (document.getElementById("newshare_attr_permissions_ro")); - let permissions_rw_radio = /** @type {HTMLInputElement} */ (document.getElementById("newshare_attr_permissions_rw")); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("newshare"); + /** @type {HTMLFormElement} */ let form = /** @type {HTMLFormElement} */ (get_element(html_scene, "form")); + /** @type {HTMLElement} */ let sharemapfields = get_element(html_scene, "[data-name=sharemapfields]"); + /** @type {HTMLInputElement} */ let shareuser_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=shareuser]")); + /** @type {HTMLInputElement} */ let sharehref_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=sharehref]")); + /** @type {HTMLInputElement} */ let enabled_checkbox = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=enabled]")); + /** @type {HTMLInputElement} */ let hidden_checkbox = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=hidden]")); + let permissions_ro_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_ro")); + let permissions_rw_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_rw")); - /** @type {HTMLDetailsElement} */ let properties_fieldset = /** @type {HTMLDetailsElement} */ (html_scene.querySelector("[data-name=properties_override]")); - /** @type {HTMLInputElement} */ let displayname_override_enabled = html_scene.querySelector("[data-name=displayname_override_enabled]"); - /** @type {HTMLInputElement} */ let displayname_override_input = html_scene.querySelector("[data-name=displayname_override]"); - /** @type {HTMLInputElement} */ let description_override_enabled = html_scene.querySelector("[data-name=description_override_enabled]"); - /** @type {HTMLInputElement} */ let description_override_input = html_scene.querySelector("[data-name=description_override]"); - /** @type {HTMLInputElement} */ let color_override_enabled = html_scene.querySelector("[data-name=color_override_enabled]"); - /** @type {HTMLInputElement} */ let color_override_input = html_scene.querySelector("[data-name=color_override]"); + /** @type {HTMLDetailsElement} */ let properties_fieldset = /** @type {HTMLDetailsElement} */ (get_element(html_scene, "[data-name=properties_override]")); + /** @type {HTMLInputElement} */ let displayname_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=displayname_override_enabled]")); + /** @type {HTMLInputElement} */ let displayname_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=displayname_override]")); + /** @type {HTMLInputElement} */ let description_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=description_override_enabled]")); + /** @type {HTMLInputElement} */ let description_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=description_override]")); + /** @type {HTMLInputElement} */ let color_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=color_override_enabled]")); + /** @type {HTMLInputElement} */ let color_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=color_override]")); - /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); - /** @type {HTMLElement} */ let submit_btn = html_scene.querySelector("[data-name=submit]"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); + /** @type {HTMLElement} */ let submit_btn = get_element(html_scene, "[data-name=submit]"); + /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); let errorHandler = new ErrorHandler(error_form); let map_validator = new FormValidator(errorHandler); @@ -108,7 +108,7 @@ export class CreateEditShareScene { let hidden_by_owner = (shareType === "bday" && shareuser_input.value === user) ? false : hidden_checkbox.checked; let permissions = permissions_rw_radio.checked ? "rw" : "r"; - let properties = {}; + /** @type {Object} */ let properties = {}; if (displayname_override_enabled.checked) { let key = get_property_key(collection.type, "DISPLAYNAME"); if (key) properties[key] = displayname_override_input.value; @@ -122,7 +122,7 @@ export class CreateEditShareScene { if (key) properties[key] = color_override_input.value + (color_override_input.value ? "ff" : ""); } - let callback = function (/** @type {string} */ error) { + let callback = function (/** @type {?string} */ error) { if (!is_current_scene(self)) { return; } @@ -143,12 +143,12 @@ export class CreateEditShareScene { PathMapped: pathMapped, Permissions: permissions, EnabledByOwner: enabled_by_owner, - EnabledByUser: edit ? share.EnabledByUser : null, + EnabledByUser: (edit && share) ? share.EnabledByUser : null, HiddenByOwner: hidden_by_owner, - HiddenByUser: edit ? share.HiddenByUser : null, + HiddenByUser: (edit && share) ? share.HiddenByUser : null, Properties: properties, - User: edit ? share.User : shareuser_input.value, - PathOrToken: edit ? share.PathOrToken : ((shareType === "map" || shareType === "bday") ? "/" + shareuser_input.value + "/" + sharehref_input.value + "/" : ""), + User: (edit && share) ? share.User : shareuser_input.value, + PathOrToken: (edit && share) ? share.PathOrToken : ((shareType === "map" || shareType === "bday") ? "/" + shareuser_input.value + "/" + sharehref_input.value + "/" : ""), }); if (edit) { @@ -187,13 +187,14 @@ export class CreateEditShareScene { cancel_btn.onclick = oncancel; form.onsubmit = onsubmit; - html_scene.querySelector("h1").textContent = edit ? "Edit Share" : "New Share"; + /** @type {HTMLHeadingElement} */ let title = /** @type {HTMLHeadingElement} */ (get_element(html_scene, "h1")); + title.textContent = edit ? "Edit Share" : "New Share"; submit_btn.textContent = edit ? "Save" : "Create"; - shareuser_input.value = edit ? share.User : (shareType === "bday" ? user : ""); + shareuser_input.value = (edit && share) ? share.User : (shareType === "bday" ? user : ""); shareuser_input.disabled = edit; - enabled_checkbox.checked = edit ? share.EnabledByOwner : true; - hidden_checkbox.checked = edit ? share.HiddenByOwner : false; + enabled_checkbox.checked = (edit && share && share.EnabledByOwner !== null) ? share.EnabledByOwner : true; + hidden_checkbox.checked = (edit && share && share.HiddenByOwner !== null) ? share.HiddenByOwner : false; if (shareType === "bday") { // bday is always read-only; hide the permissions section entirely @@ -206,8 +207,8 @@ export class CreateEditShareScene { permissions_ro_radio.closest("details")?.classList.remove("hidden"); permissions_ro_radio.disabled = false; permissions_rw_radio.disabled = false; - permissions_ro_radio.checked = edit ? share.Permissions.toLowerCase() === "r" : true; - permissions_rw_radio.checked = edit ? share.Permissions.toLowerCase() === "rw" : false; + permissions_ro_radio.checked = (edit && share) ? share.Permissions.toLowerCase() === "r" : true; + permissions_rw_radio.checked = (edit && share) ? share.Permissions.toLowerCase() === "rw" : false; } let displayname = collection.displayname || ""; @@ -217,7 +218,7 @@ export class CreateEditShareScene { let description_override_enabled_value = false; let color_override_enabled_value = false; - if (edit && share.Properties) { + if (edit && share && share.Properties) { let displayname_key = get_property_key(collection.type, "DISPLAYNAME"); if (displayname_key && share.Properties[displayname_key] !== undefined) { displayname = share.Properties[displayname_key]; @@ -257,15 +258,23 @@ export class CreateEditShareScene { properties_fieldset.open = true; } if (is_calendar || is_addressbook) { - description_override_enabled.parentElement.classList.remove("hidden"); - color_override_enabled.parentElement.classList.remove("hidden"); + if (description_override_enabled.parentElement) { + description_override_enabled.parentElement.classList.remove("hidden"); + } + if (color_override_enabled.parentElement) { + color_override_enabled.parentElement.classList.remove("hidden"); + } } else { - description_override_enabled.parentElement.classList.add("hidden"); - color_override_enabled.parentElement.classList.add("hidden"); + if (description_override_enabled.parentElement) { + description_override_enabled.parentElement.classList.add("hidden"); + } + if (color_override_enabled.parentElement) { + color_override_enabled.parentElement.classList.add("hidden"); + } } if (shareType === "map" || shareType === "bday") { - if (edit) { + if (edit && share) { sharehref_input.value = share.PathOrToken.split("/").filter(Boolean).pop() || ""; } else { sharehref_input.value = random_uuid(); diff --git a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js index df687723..d0d9b5a0 100644 --- a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js @@ -23,6 +23,7 @@ import { DELETE_CONFIRMATION_TEXT } from "../constants.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_equals } from "../utils/form_validator.js"; +import { get_element, get_element_by_id } from "../utils/misc.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager.js"; @@ -32,7 +33,7 @@ import { Scene, is_current_scene, pop_scene, push_scene } from "./scene_manager. export class DeleteConfirmationScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {string} header_title * @param {any} item * @param {string} item_title @@ -41,16 +42,16 @@ export class DeleteConfirmationScene { * @param {function} [on_success] */ constructor(user, password, header_title, item, item_title, delete_action, needsconfirmation, on_success) { - /** @type {HTMLElement} */ let html_scene = document.getElementById("deleteconfirmationscene"); - /** @type {HTMLElement} */ let header_html = html_scene.querySelector("[data-name=headertitle]"); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("deleteconfirmationscene"); + /** @type {HTMLElement} */ let header_html = get_element(html_scene, "[data-name=headertitle]"); if (header_html) header_html.textContent = header_title; - /** @type {HTMLElement} */ let title_form = html_scene.querySelector("[data-name=title]"); - /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); - /** @type {HTMLElement} */ let confirmation_prompt = html_scene.querySelector("[data-name=confirmationprompt]"); - /** @type {HTMLInputElement} */ let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); - /** @type {HTMLElement} */ let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); - /** @type {HTMLElement} */ let delete_btn = html_scene.querySelector("[data-name=delete]"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {HTMLElement} */ let title_form = get_element(html_scene, "[data-name=title]"); + /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); + /** @type {HTMLElement} */ let confirmation_prompt = get_element(html_scene, "[data-name=confirmationprompt]"); + /** @type {HTMLInputElement} */ let confirmation_txt = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=confirmationtxt]")); + /** @type {HTMLElement} */ let delete_confirmation_lbl = get_element(html_scene, "[data-name=deleteconfirmationtext]"); + /** @type {HTMLElement} */ let delete_btn = get_element(html_scene, "[data-name=delete]"); + /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); if (needsconfirmation) { delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; @@ -80,7 +81,7 @@ export class DeleteConfirmationScene { try { let loading_scene = new LoadingScene(); push_scene(loading_scene); - delete_req = delete_action(user, password, item, function (error1) { + delete_req = delete_action(user, password, item, function (/** @type {?string} */ error1) { if (!is_current_scene(loading_scene)) { return; } @@ -113,8 +114,8 @@ export class DeleteConfirmationScene { return false; } - function onkeydown(event) { - if (event.keyCode !== 13) { + function onkeydown(/** @type {KeyboardEvent}*/ event) { + if (event.code !== "Enter") { return; } ondelete(); diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index 44efee9a..46bbfe94 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -22,6 +22,7 @@ import { update_incoming_share } from "../api/sharing.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; +import { get_element, get_element_by_id } from "../utils/misc.js"; import { displayPermissions } from "../utils/permissions.js"; import { Scene, pop_scene } from "./scene_manager.js"; @@ -31,16 +32,16 @@ import { Scene, pop_scene } from "./scene_manager.js"; export class IncomingSharingScene { /** * @param {string} user - * @param {string} password + * @param {?string} password */ constructor(user, password) { - /** @type {HTMLElement} */ let html_scene = document.getElementById("incomingsharingscene"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - /** @type {HTMLElement} */ let error_element = html_scene.querySelector("[data-name=error]"); - /** @type {HTMLElement} */ let tbody = html_scene.querySelector("tbody[data-name=incomingsharesbody]"); - /** @type {HTMLElement} */ let template = tbody.querySelector("[data-name=incomingsharerowtemplate]"); - /** @type {HTMLElement} */ let table = html_scene.querySelector("table"); - /** @type {HTMLElement} */ let noshares_message = html_scene.querySelector("[data-name=nosharesmessage]"); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("incomingsharingscene"); + /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); + /** @type {HTMLElement} */ let error_element = get_element(html_scene, "[data-name=error]"); + /** @type {HTMLElement} */ let tbody = get_element(html_scene, "tbody[data-name=incomingsharesbody]"); + /** @type {HTMLElement} */ let template = get_element(tbody, "[data-name=incomingsharerowtemplate]"); + /** @type {HTMLElement} */ let table = get_element(html_scene, "table"); + /** @type {HTMLElement} */ let noshares_message = get_element(html_scene, "[data-name=nosharesmessage]"); let error_handler = new ErrorHandler(error_element); @@ -62,8 +63,8 @@ export class IncomingSharingScene { enabled_cb.disabled = true; shown_cb.disabled = true; - let old_enabled = share.EnabledByUser; - let old_hidden = share.HiddenByUser; + let old_enabled = share.EnabledByUser || false; + let old_hidden = share.HiddenByUser || false; share.EnabledByUser = enabled_cb.checked; share.HiddenByUser = !shown_cb.checked; @@ -90,7 +91,9 @@ export class IncomingSharingScene { function render_shares(shares) { // clear old nodes nodes.forEach(function (node) { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } }); nodes = []; @@ -111,11 +114,11 @@ export class IncomingSharingScene { let node = /** @type {HTMLElement} */(template.cloneNode(true)); node.classList.remove("hidden"); - let pathortoken = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=pathortoken]")); - let owner_td = node.querySelector("[data-name=owner]"); - let permissions_td = /** @type {HTMLElement} */ (node.querySelector("[data-name=permissions]")); - let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); - let shown_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=shown]")); + let pathortoken = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=pathortoken]")); + let owner_td = get_element(node, "[data-name=owner]"); + let permissions_td = /** @type {HTMLElement} */ (get_element(node, "[data-name=permissions]")); + let enabled_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=enabled]")); + let shown_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=shown]")); let displayPath = share.PathOrToken.substring(prefix.length); if (displayPath.endsWith("/")) { diff --git a/radicale/web/internal_data/js/scenes/LoadingScene.js b/radicale/web/internal_data/js/scenes/LoadingScene.js index 84a376e4..fd1f5378 100644 --- a/radicale/web/internal_data/js/scenes/LoadingScene.js +++ b/radicale/web/internal_data/js/scenes/LoadingScene.js @@ -19,6 +19,7 @@ * along with this program. If not, see . */ +import { get_element_by_id } from "../utils/misc.js"; import { Scene } from "./scene_manager.js"; /** @@ -26,7 +27,7 @@ import { Scene } from "./scene_manager.js"; */ export class LoadingScene { constructor() { - this.html_scene = document.getElementById("loadingscene"); + this.html_scene = get_element_by_id("loadingscene"); } show() { this.html_scene.classList.remove("hidden"); diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index de0a0829..a3b7479d 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -21,27 +21,30 @@ import { get_principal } from "../api/api.js"; import { ROOT_PATH, SERVER } from "../constants.js"; +import { Collection } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_non_empty } from "../utils/form_validator.js"; +import { get_element, get_element_by_id } from "../utils/misc.js"; import { CollectionsScene } from "./CollectionsScene.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, is_current_scene, pop_scene, pop_to_root, push_scene, replace_scene } from "./scene_manager.js"; + /** * @constructor * @implements {Scene} */ export class LoginScene { constructor() { - /** @type {HTMLElement} */ let html_scene = document.getElementById("loginscene"); - /** @type {HTMLElement} */ let form = html_scene.querySelector("[data-name=form]"); - /** @type {HTMLInputElement} */ let user_form = html_scene.querySelector("[data-name=user]"); - /** @type {HTMLInputElement} */ let password_form = html_scene.querySelector("[data-name=password]"); - /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); - /** @type {HTMLElement} */ let logout_view = document.getElementById("logoutview"); - /** @type {HTMLElement} */ let logout_user_form = logout_view.querySelector("[data-name=user]"); - /** @type {HTMLElement} */ let logout_btn = logout_view.querySelector("[data-name=logout]"); - /** @type {HTMLElement} */ let refresh_btn = logout_view.querySelector("[data-name=refresh]"); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("loginscene"); + /** @type {HTMLElement} */ let form = get_element(html_scene, "[data-name=form]"); + /** @type {HTMLInputElement} */ let user_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=user]")); + /** @type {HTMLInputElement} */ let password_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=password]")); + /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); + /** @type {HTMLElement} */ let logout_view = get_element_by_id("logoutview"); + /** @type {HTMLElement} */ let logout_user_form = get_element(logout_view, "[data-name=user]"); + /** @type {HTMLElement} */ let logout_btn = get_element(logout_view, "[data-name=logout]"); + /** @type {HTMLElement} */ let refresh_btn = get_element(logout_view, "[data-name=refresh]"); let user = ""; /** @type {?XMLHttpRequest} */ let principal_req = null; @@ -60,7 +63,7 @@ export class LoginScene { /** * @param {string} p_user - * @param {string} p_password + * @param {?string} p_password */ function perform_login(p_user, p_password) { user = p_user; @@ -77,7 +80,7 @@ export class LoginScene { // Fetch principal let loading_scene = new LoadingScene(); push_scene(loading_scene); - principal_req = get_principal(user, p_password, function (principal_collection, error1) { + principal_req = get_principal(user, p_password, function (/** @type {?Collection} */ principal_collection, error1) { if (!is_current_scene(loading_scene)) { return; } @@ -85,12 +88,12 @@ export class LoginScene { if (error1) { errorHandler.setError(error1); pop_scene(); - } else { + } else if (principal_collection) { // show collections let saved_user = user; user = ""; let collections_scene = new CollectionsScene( - saved_user, p_password, principal_collection, function (error1) { + saved_user, p_password, principal_collection, function (/** @type {?string} */ error1) { errorHandler.setError(error1); user = saved_user; }); @@ -150,29 +153,27 @@ export class LoginScene { // Probe for existing authentication (e.g. X-Remote-User) // Use fetch with credentials: 'omit' to avoid browser login prompt on 401 - if (window.fetch) { - fetch(SERVER + ROOT_PATH, { - method: 'PROPFIND', - headers: { 'Depth': '0' }, - credentials: 'omit' - }).then(function (response) { - if (response.ok) { - // Authenticated! Now it's safe to call get_principal - get_principal(null, null, function (principal_collection, error) { - if (!error && principal_collection) { - let authenticated_user = principal_collection.displayname; - if (!authenticated_user) { - let href = principal_collection.href.replace(/\/+$/, ""); - authenticated_user = href.substring(href.lastIndexOf("/") + 1); - } - perform_login(authenticated_user, null); + fetch(SERVER + ROOT_PATH, { + method: 'PROPFIND', + headers: { 'Depth': '0' }, + credentials: 'omit' + }).then(function (response) { + if (response.ok) { + // Authenticated! Now it's safe to call get_principal + get_principal(null, null, function (/** @type {?Collection} */ principal_collection, error) { + if (!error && principal_collection) { + let authenticated_user = principal_collection.displayname; + if (!authenticated_user) { + let href = principal_collection.href.replace(/\/+$/, ""); + authenticated_user = href.substring(href.lastIndexOf("/") + 1); } - }); - } - })["catch"](function () { - // Ignore error: we are not authenticated or something else went wrong - }); - } + perform_login(authenticated_user, null); + } + }); + } + })["catch"](function () { + // Ignore error: we are not authenticated or something else went wrong + }); }; this.hide = function () { read_form(); diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 3337bf64..617ede89 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -28,6 +28,7 @@ import { import { Collection, CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; +import { get_element, get_element_by_id } from "../utils/misc.js"; import { displayPermissions } from "../utils/permissions.js"; import { CreateEditShareScene } from "./CreateEditShareScene.js"; import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js"; @@ -39,37 +40,25 @@ import { Scene, pop_scene, push_scene } from "./scene_manager.js"; export class ShareCollectionScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection The collection on which to edit sharing setting. Must exist. */ constructor(user, password, collection) { - let html_scene = document.getElementById("sharecollectionscene"); + let html_scene = get_element_by_id("sharecollectionscene"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - /** @type {HTMLElement} */ let share_by_token_btn = html_scene.querySelector( - "button[data-name=sharebytoken]" - ); - /** @type {HTMLElement} */ let share_by_map_btn = html_scene.querySelector( - "button[data-name=sharebymap]" - ); - /** @type {HTMLElement} */ let share_by_bday_btn = html_scene.querySelector( - "button[data-name=sharebybday]" - ); - /** @type {HTMLElement} */ let share_by_token_div = html_scene.querySelector( - "div[data-name=sharebytoken]" - ); - /** @type {HTMLElement} */ let share_by_map_div = html_scene.querySelector( - "div[data-name=sharebymap]" - ); - /** @type {HTMLElement} */ let share_by_bday_div = html_scene.querySelector( - "div[data-name=sharebybday]" - ); - /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); + /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); + /** @type {HTMLElement} */ let share_by_token_btn = get_element(html_scene, "button[data-name=sharebytoken]"); + /** @type {HTMLElement} */ let share_by_map_btn = get_element(html_scene, "button[data-name=sharebymap]"); + /** @type {HTMLElement} */ let share_by_bday_btn = get_element(html_scene, "button[data-name=sharebybday]"); + /** @type {HTMLElement} */ let share_by_token_div = get_element(html_scene, "div[data-name=sharebytoken]"); + /** @type {HTMLElement} */ let share_by_map_div = get_element(html_scene, "div[data-name=sharebymap]"); + /** @type {HTMLElement} */ let share_by_bday_div = get_element(html_scene, "div[data-name=sharebybday]"); + /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); let errorHandler = new ErrorHandler(error_form); - /** @type {HTMLElement} */ let title = html_scene.querySelector("[data-name=title]"); + /** @type {HTMLElement} */ let title = get_element(html_scene, "[data-name=title]"); function oncancel() { try { @@ -169,7 +158,7 @@ export class ShareCollectionScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {ErrorHandler} errorHandler */ @@ -179,7 +168,9 @@ function update_share_list(user, password, collection, errorHandler) { ); share_rows.forEach(function (row) { if (!row.classList.contains("hidden")) { - row.parentNode.removeChild(row); + if (row.parentNode) { + row.parentNode.removeChild(row); + } } }); @@ -195,7 +186,7 @@ function update_share_list(user, password, collection, errorHandler) { /** * * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {import('../api/sharing.js').Share} share * @param {HTMLElement} template @@ -209,7 +200,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ let node = /** @type {HTMLElement} */ (template.cloneNode(true)); node.classList.remove("hidden"); - /** @type {HTMLInputElement} */ let pathortoken_form = node.querySelector("[data-name=pathortoken]"); + /** @type {HTMLInputElement} */ let pathortoken_form = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=pathortoken]")); if (pathortoken_form) { pathortoken_form.value = pathortoken; } @@ -217,13 +208,13 @@ function add_share_row_node(user, password, collection, share, template, delete_ let permissions = (share["Permissions"] || "").toLowerCase(); displayPermissions(permissions, node); - /** @type {HTMLElement} */ let edit_btn = node.querySelector("[data-name=edit]"); + /** @type {HTMLElement} */ let edit_btn = get_element(node, "[data-name=edit]"); edit_btn.onclick = function () { let create_edit_share_scene = new CreateEditShareScene(user, password, collection, share.ShareType, share); push_scene(create_edit_share_scene); }; - /** @type {HTMLElement} */ let delete_btn = node.querySelector("[data-name=delete]"); + /** @type {HTMLElement} */ let delete_btn = get_element(node, "[data-name=delete]"); delete_btn.onclick = function () { let delete_collection_scene = new DeleteConfirmationScene( user, password, "Delete Share", share, delete_label + " " + pathortoken, delete_action, false, @@ -236,20 +227,22 @@ function add_share_row_node(user, password, collection, share, template, delete_ push_scene(delete_collection_scene); }; - template.parentNode.insertBefore(node, template); + if (template.parentNode) { + template.parentNode.insertBefore(node, template); + } } /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection * @param {Array} shares * @param {ErrorHandler} errorHandler */ function add_share_rows(user, password, collection, shares, errorHandler) { - /** @type {HTMLElement} */ let token_template = document.querySelector("[data-name=sharetokenrowtemplate]"); - /** @type {HTMLElement} */ let map_template = document.querySelector("[data-name=sharemaprowtemplate]"); - /** @type {HTMLElement} */ let bday_template = document.querySelector("[data-name=sharebdayrowtemplate]"); + /** @type {HTMLElement} */ let token_template = get_element(document, "[data-name=sharetokenrowtemplate]"); + /** @type {HTMLElement} */ let map_template = get_element(document, "[data-name=sharemaprowtemplate]"); + /** @type {HTMLElement} */ let bday_template = get_element(document, "[data-name=sharebdayrowtemplate]"); shares.forEach(function (share) { let pathortoken = share["PathOrToken"] || ""; let pathmapped = share["PathMapped"] || ""; diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index b012885f..3b11e654 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -24,7 +24,7 @@ import { Collection } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_files, validate_href } from "../utils/form_validator.js"; -import { cleanHREFinput, onCleanHREFinput, random_uuid } from "../utils/misc.js"; +import { cleanHREFinput, get_element, get_element_by_id, onCleanHREFinput, random_uuid } from "../utils/misc.js"; import { Scene, pop_scene } from "./scene_manager.js"; /** @@ -33,21 +33,21 @@ import { Scene, pop_scene } from "./scene_manager.js"; export class UploadCollectionScene { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {Collection} collection parent collection */ constructor(user, password, collection) { - /** @type {HTMLElement} */ let html_scene = document.getElementById("uploadcollectionscene"); - /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=filetemplate]"); - /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=submit]"); - /** @type {HTMLElement} */ let close_btn = html_scene.querySelector("[data-name=close]"); - /** @type {HTMLInputElement} */ let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); - /** @type {HTMLElement} */ let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); - /** @type {HTMLInputElement} */ let href_form = html_scene.querySelector("[data-name=href]"); - /** @type {HTMLElement} */ let href_label = html_scene.querySelector("label[for=href]"); - /** @type {HTMLElement} */ let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); - /** @type {HTMLElement} */ let pending_html = html_scene.querySelector("[data-name=pending]"); - /** @type {HTMLElement} */ let error_form = html_scene.querySelector(":scope > span[data-name=error]"); + /** @type {HTMLElement} */ let html_scene = get_element_by_id("uploadcollectionscene"); + /** @type {HTMLElement} */ let template = get_element(html_scene, "[data-name=filetemplate]"); + /** @type {HTMLElement} */ let upload_btn = get_element(html_scene, "[data-name=submit]"); + /** @type {HTMLElement} */ let close_btn = get_element(html_scene, "[data-name=close]"); + /** @type {HTMLInputElement} */ let uploadfile_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=uploadfile]")); + /** @type {HTMLElement} */ let uploadfile_lbl = get_element(html_scene, "label[for=uploadfile]"); + /** @type {HTMLInputElement} */ let href_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=href]")); + /** @type {HTMLElement} */ let href_label = get_element(html_scene, "label[for=href]"); + /** @type {HTMLElement} */ let hreflimitmsg_html = get_element(html_scene, "[data-name=hreflimitmsg]"); + /** @type {HTMLElement} */ let pending_html = get_element(html_scene, "[data-name=pending]"); + /** @type {HTMLElement} */ let error_form = get_element(html_scene, ":scope > span[data-name=error]"); let files = uploadfile_form.files; href_form.addEventListener("input", onCleanHREFinput); @@ -64,7 +64,7 @@ export class UploadCollectionScene { validator.addValidator(uploadfile_form, validate_files(uploadfile_form, "file")); /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let results = []; + /** @type {Array} */ let results = []; /** @type {?Array} */ let nodes = null; function upload_start() { @@ -72,6 +72,9 @@ export class UploadCollectionScene { if (!validator.validate()) { return false; } + if (!files) { + return false; + } read_form(); uploadfile_form.classList.add("hidden"); uploadfile_lbl.classList.add("hidden"); @@ -88,12 +91,14 @@ export class UploadCollectionScene { let file = files[i]; let node = /** @type {HTMLElement} */ (template.cloneNode(true)); node.classList.remove("hidden"); - let name_form = node.querySelector("[data-name=name]"); + let name_form = get_element(node, "[data-name=name]"); name_form.textContent = file.name; node.classList.remove("hidden"); nodes.push(node); updateFileStatus(i); - template.parentNode.insertBefore(node, template); + if (template.parentNode) { + template.parentNode.insertBefore(node, template); + } } upload_next(); } catch (err) { @@ -103,6 +108,9 @@ export class UploadCollectionScene { } function upload_next() { + if (!files) { + return; + } try { if (files.length === results.length) { pending_html.classList.add("hidden"); @@ -145,8 +153,8 @@ export class UploadCollectionScene { if (nodes === null) { return; } - /** @type {HTMLElement} */ let file_success_form = nodes[i].querySelector("[data-name=success]"); - /** @type {HTMLElement} */ let file_error_form = nodes[i].querySelector("[data-name=error]"); + /** @type {HTMLElement} */ let file_success_form = get_element(nodes[i], "[data-name=success]"); + /** @type {HTMLElement} */ let file_error_form = get_element(nodes[i], "[data-name=error]"); if (results.length > i) { if (results[i]) { file_success_form.classList.add("hidden"); @@ -171,7 +179,7 @@ export class UploadCollectionScene { function onfileschange() { files = uploadfile_form.files; - if (files.length > 1) { + if (files && files.length > 1) { hreflimitmsg_html.classList.remove("hidden"); href_form.classList.add("hidden"); href_label.classList.add("hidden"); @@ -213,7 +221,9 @@ export class UploadCollectionScene { return; } nodes.forEach(function (node) { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } }); nodes = null; }; diff --git a/radicale/web/internal_data/js/scenes/scene_manager.js b/radicale/web/internal_data/js/scenes/scene_manager.js index b54a20ae..0e79955c 100644 --- a/radicale/web/internal_data/js/scenes/scene_manager.js +++ b/radicale/web/internal_data/js/scenes/scene_manager.js @@ -56,6 +56,19 @@ export function push_scene(scene) { scene.show(); } +/** + * Pop the current scene and release it. + */ +function pop_and_release() { + if (scene_stack.length === 0) { + return; + } + let scene = scene_stack.pop(); + if (scene) { + scene.release(); + } +} + /** * Replace the current scene with a new one. * @param {Scene} scene @@ -63,7 +76,7 @@ export function push_scene(scene) { export function replace_scene(scene) { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].hide(); - scene_stack.pop().release(); + pop_and_release(); } scene_stack.push(scene); scene.show(); @@ -77,7 +90,7 @@ export function pop_scene() { return; } scene_stack[scene_stack.length - 1].hide(); - scene_stack.pop().release(); + pop_and_release(); if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); } @@ -92,9 +105,9 @@ export function pop_to_parent() { return; } scene_stack[scene_stack.length - 1].hide(); - scene_stack.pop().release(); + pop_and_release(); if (scene_stack.length >= 1) { - scene_stack.pop().release(); + pop_and_release(); } if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); @@ -112,7 +125,7 @@ export function pop_to_root() { // Pop all scenes until only the first one remains while (scene_stack.length > 1) { scene_stack[scene_stack.length - 1].hide(); - scene_stack.pop().release(); + pop_and_release(); } // The root scene is now at the top (index 0) and should be shown if (scene_stack.length === 1) { diff --git a/radicale/web/internal_data/js/utils/collections_cache.js b/radicale/web/internal_data/js/utils/collections_cache.js index 6e83ed19..719a3c56 100644 --- a/radicale/web/internal_data/js/utils/collections_cache.js +++ b/radicale/web/internal_data/js/utils/collections_cache.js @@ -49,7 +49,7 @@ class CollectionsCache { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {import("../models/collection.js").Collection} principal_collection * @param {function(string):void} onerror * @param {function(Array, Array, boolean):void} displayData @@ -63,9 +63,9 @@ class CollectionsCache { let loading_scene = new LoadingScene(); push_scene(loading_scene); - let collections = null; - let shares = null; - let error = null; + /** @type {?Array} */ let collections = null; + /** @type {?Array} */ let shares = null; + /** @type {?string} */ let error = null; let check_if_completed = () => { if (!is_current_scene(loading_scene)) { @@ -101,7 +101,7 @@ class CollectionsCache { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {function(string):void} onerror * @param {function(Array):void} displayData */ @@ -132,7 +132,7 @@ class CollectionsCache { /** * @param {string} user - * @param {string} password + * @param {?string} password * @param {function(string):void} onerror * @param {function(import("../api/sharing.js").ServerFeatures):void} displayData */ diff --git a/radicale/web/internal_data/js/utils/error.js b/radicale/web/internal_data/js/utils/error.js index 842d5791..96310fcc 100644 --- a/radicale/web/internal_data/js/utils/error.js +++ b/radicale/web/internal_data/js/utils/error.js @@ -31,10 +31,14 @@ export class ErrorHandler { /** * Sets an error message for a given key. - * @param {string} errorMessage + * @param {?string} errorMessage */ setError(errorMessage) { - this._update([errorMessage]); + if (errorMessage) { + this._update([errorMessage]); + } else { + this.clearError(); + } } /** diff --git a/radicale/web/internal_data/js/utils/form_validator.js b/radicale/web/internal_data/js/utils/form_validator.js index 2cd53a76..81b678eb 100644 --- a/radicale/web/internal_data/js/utils/form_validator.js +++ b/radicale/web/internal_data/js/utils/form_validator.js @@ -30,6 +30,7 @@ export class FormValidator { */ constructor(error_handler) { this.error_handler = error_handler; + /** @type {Array<{field: HTMLInputElement, validation_method: function(): ?string}>} */ this.validation_methods = []; } @@ -51,8 +52,8 @@ export class FormValidator { */ validate() { let errorMessages = []; - for (let { field, validation_method } of this.validation_methods) { - let errorMessage = validation_method(field); + for (let { validation_method } of this.validation_methods) { + let errorMessage = validation_method(); if (errorMessage) { errorMessages.push(errorMessage); } @@ -136,7 +137,9 @@ export function validate_color(input, field_name) { } return null; }; -}/** +} + +/** * Validates that the input matches a specific string. * @param {HTMLInputElement} input * @param {string} target diff --git a/radicale/web/internal_data/js/utils/misc.js b/radicale/web/internal_data/js/utils/misc.js index fdecb51a..3014041f 100644 --- a/radicale/web/internal_data/js/utils/misc.js +++ b/radicale/web/internal_data/js/utils/misc.js @@ -48,11 +48,7 @@ export function random_uuid() { export function random_hex(length) { let bytes = new Uint8Array(Math.ceil(length / 2)); window.crypto.getRandomValues(bytes); - // Fallback for compatibility with older browsers which may not have padStart - return bytes.reduce((s, b) => { - let hex = b.toString(16); - return s + (String.prototype["padStart"] ? hex["padStart"](2, "0") : ("0" + hex).slice(-2)); - }, "").substring(0, length); + return bytes.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "").substring(0, length); } /** @@ -119,3 +115,30 @@ export function setupSelectAll() { } }); } + +/** + * Get an element by its ID and throw an error if it's not found. + * @param {string} id The ID of the element to find. + * @return {HTMLElement} The found element. + */ +export function get_element_by_id(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error("Element with ID '" + id + "' not found"); + } + return element; +} + +/** + * Get an element by a selector and throw an error if it's not found. + * @param {ParentNode} node The parent node to search within. + * @param {string} selector The CSS selector to use. + * @return {HTMLElement} The found element. + */ +export function get_element(node, selector) { + const element = node.querySelector(selector); + if (!element) { + throw new Error("Element with selector '" + selector + "' not found"); + } + return /** @type {HTMLElement} */ (element); +} diff --git a/radicale/web/internal_data/js/utils/permissions.js b/radicale/web/internal_data/js/utils/permissions.js index 02ca4c6f..d5fff8e3 100644 --- a/radicale/web/internal_data/js/utils/permissions.js +++ b/radicale/web/internal_data/js/utils/permissions.js @@ -24,12 +24,12 @@ export function displayPermissions(permissions, node) { permissions = (permissions || "").toLowerCase(); if (permissions === "rw") { const roElement = node.querySelector("[data-name=ro]"); - if (roElement) { + if (roElement && roElement.parentNode) { roElement.parentNode.removeChild(roElement); } } else if (permissions === "r") { const rwElement = node.querySelector("[data-name=rw]"); - if (rwElement) { + if (rwElement && rwElement.parentNode) { rwElement.parentNode.removeChild(rwElement); } } else { diff --git a/radicale/web/jsconfig.json b/radicale/web/jsconfig.json index a14b6171..34bac809 100644 --- a/radicale/web/jsconfig.json +++ b/radicale/web/jsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "CommonJS", - "target": "ES6", + "target": "ES2017", "checkJs": true, "noEmit": true, "noUnusedLocals": true, From 57e0c94d2998a763909918ec74dcf7df33cf91e5 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 25 Mar 2026 23:39:42 +0100 Subject: [PATCH 2/2] remove xunit annotator since it was not working correctly --- .github/workflows/test.yml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 257570c8..37be494e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -191,17 +191,7 @@ jobs: node-version: 24 - name: JS Type Check run: | - set -o pipefail - npx -p typescript tsc -p radicale/web/jsconfig.json | npx typescript-xunit-xml > tsc-results.xml - - uses: mikepenz/action-junit-report@v6 - if: ${{ failure() && (github.event.pull_request.head.repo.full_name != github.repository) }} - with: - report_paths: 'tsc-results.xml' - annotate_only: true # forked repo cannot write to checks so just do annotations - - uses: mikepenz/action-junit-report@v6 - if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} - with: - report_paths: 'tsc-results.xml' + npx -p typescript tsc -p radicale/web/jsconfig.json lint: name: Lint @@ -230,13 +220,4 @@ jobs: - name: Install Playwright Browsers run: uv run --extra integ_test playwright install --with-deps - name: Run Integration Tests - run: uv run --extra integ_test pytest --junitxml=pytest-results.xml integ_tests - - uses: mikepenz/action-junit-report@v6 - if: ${{ failure() && (github.event.pull_request.head.repo.full_name != github.repository) }} - with: - report_paths: 'pytest-results.xml' - annotate_only: true # forked repo cannot write to checks so just do annotations - - uses: mikepenz/action-junit-report@v6 - if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} - with: - report_paths: 'pytest-results.xml' + run: uv run --extra integ_test pytest integ_tests/ -v