From bd3be1c56b2f464d6e764f5960cfd64ad1cd8fc0 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 18 Feb 2026 21:11:06 +0100 Subject: [PATCH 1/2] Refactor Web UI Javascript into separate files This will simplify further maintainance. Instead of a single large file, there are now separate files for each models, api, utils, and constants. There are also separate files for all Scenes. There are no functional changes in this patch. --- .../web/internal_data/CollectionsScene.js | 202 +++ .../CreateEditCollectionScene.js | 224 +++ .../internal_data/DeleteCollectionScene.js | 121 ++ radicale/web/internal_data/LoadingScene.js | 36 + radicale/web/internal_data/LoginScene.js | 154 ++ .../internal_data/UploadCollectionScene.js | 220 +++ radicale/web/internal_data/api.js | 342 ++++ radicale/web/internal_data/constants.js | 46 + radicale/web/internal_data/fn.js | 1413 ----------------- radicale/web/internal_data/index.html | 2 +- radicale/web/internal_data/main.js | 26 + radicale/web/internal_data/models.js | 103 ++ radicale/web/internal_data/scene_manager.js | 82 + radicale/web/internal_data/utils.js | 103 ++ 14 files changed, 1660 insertions(+), 1414 deletions(-) create mode 100644 radicale/web/internal_data/CollectionsScene.js create mode 100644 radicale/web/internal_data/CreateEditCollectionScene.js create mode 100644 radicale/web/internal_data/DeleteCollectionScene.js create mode 100644 radicale/web/internal_data/LoadingScene.js create mode 100644 radicale/web/internal_data/LoginScene.js create mode 100644 radicale/web/internal_data/UploadCollectionScene.js create mode 100644 radicale/web/internal_data/api.js create mode 100644 radicale/web/internal_data/constants.js delete mode 100644 radicale/web/internal_data/fn.js create mode 100644 radicale/web/internal_data/main.js create mode 100644 radicale/web/internal_data/models.js create mode 100644 radicale/web/internal_data/scene_manager.js create mode 100644 radicale/web/internal_data/utils.js diff --git a/radicale/web/internal_data/CollectionsScene.js b/radicale/web/internal_data/CollectionsScene.js new file mode 100644 index 00000000..214c3a43 --- /dev/null +++ b/radicale/web/internal_data/CollectionsScene.js @@ -0,0 +1,202 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; +import { UploadCollectionScene } from "./UploadCollectionScene.js"; +import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { get_collections } from "./api.js"; +import { CollectionType } from "./models.js"; +import { bytesToHumanReadable } from "./utils.js"; +import { SERVER } from "./constants.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection The principal collection. + * @param {function(string)} onerror Called when an error occurs, before the + * scene is popped. + */ +export function CollectionsScene(user, password, collection, onerror) { + let html_scene = document.getElementById("collectionsscene"); + let template = html_scene.querySelector("[data-name=collectiontemplate]"); + let new_btn = html_scene.querySelector("[data-name=new]"); + let upload_btn = html_scene.querySelector("[data-name=upload]"); + + /** @type {?number} */ let scene_index = null; + /** @type {?XMLHttpRequest} */ let collections_req = null; + /** @type {?Array} */ let collections = null; + /** @type {Array} */ let nodes = []; + + function onnew() { + try { + let create_collection_scene = new CreateEditCollectionScene(user, password, collection); + push_scene(create_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + + function onupload() { + try { + let upload_scene = new UploadCollectionScene(user, password, collection); + push_scene(upload_scene); + } catch(err) { + console.error(err); + } + return false; + } + + function onedit(collection) { + try { + let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); + push_scene(edit_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + + function ondelete(collection) { + try { + let delete_collection_scene = new DeleteCollectionScene(user, password, collection); + push_scene(delete_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + + function show_collections(collections) { + let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px"; + html_scene.style.marginTop = heightOfNavBar; + html_scene.style.height = "calc(100vh - " + heightOfNavBar +")"; + collections.forEach(function (collection) { + let node = template.cloneNode(true); + node.classList.remove("hidden"); + let title_form = node.querySelector("[data-name=title]"); + let description_form = node.querySelector("[data-name=description]"); + let contentcount_form = node.querySelector("[data-name=contentcount]"); + let url_form = node.querySelector("[data-name=url]"); + let color_form = node.querySelector("[data-name=color]"); + let delete_btn = node.querySelector("[data-name=delete]"); + let edit_btn = node.querySelector("[data-name=edit]"); + let download_btn = node.querySelector("[data-name=download]"); + if (collection.color) { + color_form.style.background = collection.color; + } + let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; + [CollectionType.CALENDAR, ""].forEach(function(e) { + [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { + [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { + if (e) { + possible_types.push(e); + } + }); + }); + }); + possible_types.forEach(function(e) { + if (e !== collection.type) { + node.querySelector("[data-name=" + e + "]").classList.add("hidden"); + } + }); + title_form.textContent = collection.displayname || collection.href; + if(title_form.textContent.length > 30){ + title_form.classList.add("smalltext"); + } + description_form.textContent = collection.description; + if(description_form.textContent.length > 150){ + description_form.classList.add("smalltext"); + } + if(collection.type != CollectionType.WEBCAL){ + let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; + if(collection.contentcount > 0){ + contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; + } + contentcount_form.textContent = contentcount_form_txt; + } + let href = SERVER + collection.href; + url_form.value = href; + download_btn.href = href; + if(collection.type == CollectionType.WEBCAL){ + download_btn.parentElement.classList.add("hidden"); + } + delete_btn.onclick = function() {return ondelete(collection);}; + edit_btn.onclick = function() {return onedit(collection);}; + node.classList.remove("hidden"); + nodes.push(node); + template.parentNode.insertBefore(node, template); + }); + } + + function update() { + let loading_scene = new LoadingScene(); + push_scene(loading_scene, false); + collections_req = get_collections(user, password, collection, function(collections1, error) { + if (scene_index === null) { + return; + } + collections_req = null; + if (error) { + onerror(error); + pop_scene(scene_index - 1); + } else { + collections = collections1; + pop_scene(scene_index); + } + }); + } + + this.show = function() { + html_scene.classList.remove("hidden"); + new_btn.onclick = onnew; + upload_btn.onclick = onupload; + if (collections === null) { + update(); + } else { + // from update loading scene + show_collections(collections); + } + }; + this.hide = function() { + html_scene.classList.add("hidden"); + scene_index = scene_stack.length - 1; + new_btn.onclick = null; + upload_btn.onclick = null; + collections = null; + // remove collection + nodes.forEach(function(node) { + node.parentNode.removeChild(node); + }); + nodes = []; + }; + this.release = function() { + scene_index = null; + if (collections_req !== null) { + collections_req.abort(); + collections_req = null; + } + collections = null; + }; +} \ No newline at end of file diff --git a/radicale/web/internal_data/CreateEditCollectionScene.js b/radicale/web/internal_data/CreateEditCollectionScene.js new file mode 100644 index 00000000..94791246 --- /dev/null +++ b/radicale/web/internal_data/CreateEditCollectionScene.js @@ -0,0 +1,224 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { Collection, CollectionType } from "./models.js"; +import { random_uuid, random_hex, cleanHREFinput, isValidHREF } from "./utils.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { COLOR_RE } from "./constants.js"; +import { create_collection, edit_collection } from "./api.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @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. + */ +export function CreateEditCollectionScene(user, password, collection) { + let edit = collection.type !== CollectionType.PRINCIPAL; + let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); + let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; + let error_form = html_scene.querySelector("[data-name=error]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); + let displayname_form = html_scene.querySelector("[data-name=displayname]"); + let displayname_label = html_scene.querySelector("label[for=displayname]"); + let description_form = html_scene.querySelector("[data-name=description]"); + let description_label = html_scene.querySelector("label[for=description]"); + let source_form = html_scene.querySelector("[data-name=source]"); + let source_label = html_scene.querySelector("label[for=source]"); + let type_form = html_scene.querySelector("[data-name=type]"); + let type_label = html_scene.querySelector("label[for=type]"); + let color_form = html_scene.querySelector("[data-name=color]"); + let color_label = html_scene.querySelector("label[for=color]"); + let submit_btn = html_scene.querySelector("[data-name=submit]"); + let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + + + /** @type {?number} */ let scene_index = null; + /** @type {?XMLHttpRequest} */ let create_edit_req = null; + let error = ""; + /** @type {?Element} */ let saved_type_form = null; + + let href = edit ? collection.href : collection.href + random_uuid() + "/"; + let displayname = edit ? collection.displayname : ""; + let description = edit ? collection.description : ""; + let source = edit ? collection.source : ""; + let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; + let color = edit && collection.color ? collection.color : "#" + random_hex(6); + + if(!edit){ + href_form.addEventListener("keydown", cleanHREFinput); + } + + function remove_invalid_types() { + if (!edit) { + return; + } + /** @type {HTMLOptionsCollection} */ let options = type_form.options; + // remove all options that are not supersets + let valid_type_options = CollectionType.valid_options_for_type(type); + for (let i = options.length - 1; i >= 0; i--) { + if (valid_type_options.indexOf(options[i].value) < 0) { + options.remove(i); + } + } + } + + function read_form() { + if(!edit){ + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = collection.href + newhreftxtvalue + "/"; + } + displayname = displayname_form.value; + description = description_form.value; + source = source_form.value; + type = type_form.value; + color = color_form.value; + return true; + } + + function fill_form() { + if(!edit){ + href_form.value = random_uuid(); + } + displayname_form.value = displayname; + description_form.value = description; + source_form.value = source; + type_form.value = type; + color_form.value = color; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + } + error_form.classList.add("hidden"); + onTypeChange(); + type_form.addEventListener("change", onTypeChange); + } + + function onsubmit() { + try { + if(!read_form()){ + return false; + } + let sane_color = color.trim(); + if (sane_color) { + let color_match = COLOR_RE.exec(sane_color); + if (!color_match) { + error = "Invalid color"; + fill_form(); + return false; + } + sane_color = 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) { + if (scene_index === null) { + return; + } + create_edit_req = null; + if (error1) { + error = error1; + pop_scene(scene_index); + } else { + pop_scene(scene_index - 1); + } + }; + if (edit) { + create_edit_req = edit_collection(user, password, collection, callback); + } else { + create_edit_req = create_collection(user, password, collection, callback); + } + } catch(err) { + console.error(err); + } + return false; + } + + function oncancel() { + try { + pop_scene(scene_index - 1); + } catch(err) { + console.error(err); + } + return false; + } + + + function onTypeChange(e){ + if(type_form.value == CollectionType.WEBCAL){ + source_label.classList.remove("hidden"); + source_form.classList.remove("hidden"); + }else{ + source_label.classList.add("hidden"); + source_form.classList.add("hidden"); + } + } + + this.show = function() { + this.release(); + scene_index = scene_stack.length - 1; + // Clone type_form because it's impossible to hide options without removing them + saved_type_form = type_form; + type_form = type_form.cloneNode(true); + saved_type_form.parentNode.replaceChild(type_form, saved_type_form); + remove_invalid_types(); + html_scene.classList.remove("hidden"); + if (edit) { + title_form.textContent = collection.displayname || collection.href; + } + fill_form(); + submit_btn.onclick = onsubmit; + cancel_btn.onclick = oncancel; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + }; + this.hide = function() { + read_form(); + html_scene.classList.add("hidden"); + // restore 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; + }; + this.release = function() { + scene_index = null; + if (create_edit_req !== null) { + create_edit_req.abort(); + create_edit_req = null; + } + }; +} \ No newline at end of file diff --git a/radicale/web/internal_data/DeleteCollectionScene.js b/radicale/web/internal_data/DeleteCollectionScene.js new file mode 100644 index 00000000..fd1f9d98 --- /dev/null +++ b/radicale/web/internal_data/DeleteCollectionScene.js @@ -0,0 +1,121 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { Collection } from "./models.js"; +import { DELETE_CONFIRMATION_TEXT } from "./constants.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { delete_collection } from "./api.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection + */ +export function DeleteCollectionScene(user, password, collection) { + let html_scene = document.getElementById("deletecollectionscene"); + let title_form = html_scene.querySelector("[data-name=title]"); + let error_form = html_scene.querySelector("[data-name=error]"); + let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); + let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); + let delete_btn = html_scene.querySelector("[data-name=delete]"); + let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + + delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; + confirmation_txt.value = ""; + confirmation_txt.addEventListener("keydown", onkeydown); + + /** @type {?number} */ let scene_index = null; + /** @type {?XMLHttpRequest} */ let delete_req = null; + let error = ""; + + function ondelete() { + let confirmation_text_value = confirmation_txt.value; + if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){ + alert("Please type the confirmation text to delete this collection."); + return; + } + try { + let loading_scene = new LoadingScene(); + push_scene(loading_scene); + delete_req = delete_collection(user, password, collection, function(error1) { + if (scene_index === null) { + return; + } + delete_req = null; + if (error1) { + error = error1; + pop_scene(scene_index); + } else { + pop_scene(scene_index - 1); + } + }); + } catch(err) { + console.error(err); + } + return false; + } + + function oncancel() { + try { + pop_scene(scene_index - 1); + } catch(err) { + console.error(err); + } + return false; + } + + function onkeydown(event){ + if (event.keyCode !== 13) { + return; + } + ondelete(); + } + + this.show = function() { + this.release(); + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + title_form.textContent = collection.displayname || collection.href; + delete_btn.onclick = ondelete; + cancel_btn.onclick = oncancel; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + + }; + this.hide = function() { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + delete_btn.onclick = null; + }; + this.release = function() { + scene_index = null; + if (delete_req !== null) { + delete_req.abort(); + delete_req = null; + } + }; +} \ No newline at end of file diff --git a/radicale/web/internal_data/LoadingScene.js b/radicale/web/internal_data/LoadingScene.js new file mode 100644 index 00000000..4251147d --- /dev/null +++ b/radicale/web/internal_data/LoadingScene.js @@ -0,0 +1,36 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene } from "./scene_manager.js"; + +/** + * @constructor + * @implements {Scene} + */ +export function LoadingScene() { + let html_scene = document.getElementById("loadingscene"); + this.show = function() { + html_scene.classList.remove("hidden"); + }; + this.hide = function() { + html_scene.classList.add("hidden"); + }; + this.release = function() {}; +} \ No newline at end of file diff --git a/radicale/web/internal_data/LoginScene.js b/radicale/web/internal_data/LoginScene.js new file mode 100644 index 00000000..1314df08 --- /dev/null +++ b/radicale/web/internal_data/LoginScene.js @@ -0,0 +1,154 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { get_principal } from "./api.js"; +import { CollectionsScene } from "./CollectionsScene.js"; + +/** + * @constructor + * @implements {Scene} + */ +export function LoginScene() { + let html_scene = document.getElementById("loginscene"); + let form = html_scene.querySelector("[data-name=form]"); + let user_form = html_scene.querySelector("[data-name=user]"); + let password_form = html_scene.querySelector("[data-name=password]"); + let error_form = html_scene.querySelector("[data-name=error]"); + let logout_view = document.getElementById("logoutview"); + let logout_user_form = logout_view.querySelector("[data-name=user]"); + let logout_btn = logout_view.querySelector("[data-name=logout]"); + let refresh_btn = logout_view.querySelector("[data-name=refresh]"); + + /** @type {?number} */ let scene_index = null; + let user = ""; + let error = ""; + /** @type {?XMLHttpRequest} */ let principal_req = null; + + function read_form() { + user = user_form.value; + } + + function fill_form() { + user_form.value = user; + password_form.value = ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + } + + function onlogin() { + try { + read_form(); + let password = password_form.value; + if (user) { + error = ""; + // setup logout + logout_view.classList.remove("hidden"); + logout_btn.onclick = onlogout; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; + // Fetch principal + let loading_scene = new LoadingScene(); + push_scene(loading_scene, false); + principal_req = get_principal(user, password, function(collection, error1) { + if (scene_index === null) { + return; + } + principal_req = null; + if (error1) { + error = error1; + pop_scene(scene_index); + } else { + // show collections + let saved_user = user; + user = ""; + let collections_scene = new CollectionsScene( + saved_user, password, collection, function(error1) { + error = error1; + user = saved_user; + }); + push_scene(collections_scene, true); + } + }); + } else { + error = "Username is empty"; + fill_form(); + } + } catch(err) { + console.error(err); + } + return false; + } + + function onlogout() { + try { + if (scene_index === null) { + return false; + } + user = ""; + pop_scene(scene_index); + } catch (err) { + console.error(err); + } + return false; + } + + function remove_logout() { + logout_view.classList.add("hidden"); + logout_btn.onclick = null; + refresh_btn.onclick = null; + logout_user_form.textContent = ""; + } + + function refresh(){ + //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + //forcing the scene below it, the Collections Scene to refresh itself. + push_scene(new LoadingScene(), false); + pop_scene(scene_stack.length-2); + } + + this.show = function() { + remove_logout(); + fill_form(); + form.onsubmit = onlogin; + html_scene.classList.remove("hidden"); + scene_index = scene_stack.length - 1; + user_form.focus(); + }; + this.hide = function() { + read_form(); + html_scene.classList.add("hidden"); + form.onsubmit = null; + }; + this.release = function() { + scene_index = null; + // cancel pending requests + if (principal_req !== null) { + principal_req.abort(); + principal_req = null; + } + remove_logout(); + }; +} \ No newline at end of file diff --git a/radicale/web/internal_data/UploadCollectionScene.js b/radicale/web/internal_data/UploadCollectionScene.js new file mode 100644 index 00000000..fbc5b29f --- /dev/null +++ b/radicale/web/internal_data/UploadCollectionScene.js @@ -0,0 +1,220 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { Collection } from "./models.js"; +import { cleanHREFinput, isValidHREF, random_uuid } from "./utils.js"; +import { upload_collection } from "./api.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection parent collection + * @param {Array} files + */ +export function UploadCollectionScene(user, password, collection) { + let html_scene = document.getElementById("uploadcollectionscene"); + let template = html_scene.querySelector("[data-name=filetemplate]"); + let upload_btn = html_scene.querySelector("[data-name=submit]"); + let close_btn = html_scene.querySelector("[data-name=close]"); + let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); + let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); + let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); + let pending_html = html_scene.querySelector("[data-name=pending]"); + + let files = uploadfile_form.files; + href_form.addEventListener("keydown", cleanHREFinput); + upload_btn.onclick = upload_start; + uploadfile_form.onchange = onfileschange; + + href_form.value = ""; + + /** @type {?number} */ let scene_index = null; + /** @type {?XMLHttpRequest} */ let upload_req = null; + /** @type {Array} */ let results = []; + /** @type {?Array} */ let nodes = null; + + function upload_start() { + try { + if(!read_form()){ + return false; + } + uploadfile_form.classList.add("hidden"); + uploadfile_lbl.classList.add("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + hreflimitmsg_html.classList.add("hidden"); + upload_btn.classList.add("hidden"); + close_btn.classList.add("hidden"); + + pending_html.classList.remove("hidden"); + + nodes = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + let node = template.cloneNode(true); + node.classList.remove("hidden"); + let name_form = node.querySelector("[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + nodes.push(node); + updateFileStatus(i); + template.parentNode.insertBefore(node, template); + } + upload_next(); + } catch(err) { + console.error(err); + } + return false; + } + + function upload_next(){ + try{ + if (files.length === results.length) { + pending_html.classList.add("hidden"); + close_btn.classList.remove("hidden"); + return; + } else { + let file = files[results.length]; + if(files.length > 1 || href.length == 0){ + href = random_uuid(); + } + let upload_href = collection.href + href + "/"; + upload_req = upload_collection(user, password, upload_href, file, function(result) { + upload_req = null; + results.push(result); + updateFileStatus(results.length - 1); + upload_next(); + }); + } + }catch(err){ + console.error(err); + } + } + + function onclose() { + try { + pop_scene(scene_index - 1); + } catch(err) { + console.error(err); + } + return false; + } + + function updateFileStatus(i) { + if (nodes === null) { + return; + } + let success_form = nodes[i].querySelector("[data-name=success]"); + let error_form = nodes[i].querySelector("[data-name=error]"); + if (results.length > i) { + if (results[i]) { + success_form.classList.add("hidden"); + error_form.textContent = "Error: " + results[i]; + error_form.classList.remove("hidden"); + } else { + success_form.classList.remove("hidden"); + error_form.classList.add("hidden"); + } + } else { + success_form.classList.add("hidden"); + error_form.classList.add("hidden"); + } + } + + function read_form() { + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = newhreftxtvalue; + + if(uploadfile_form.files.length == 0){ + alert("You must select at least one file to upload"); + return false; + } + files = uploadfile_form.files; + return true; + } + + function onfileschange() { + files = uploadfile_form.files; + if(files.length > 1){ + hreflimitmsg_html.classList.remove("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + href_form.value = random_uuid(); // dummy, will be replaced on upload + }else{ + hreflimitmsg_html.classList.add("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + href_form.value = files[0].name.replace(/\.(ics|vcf)$/, ''); + } + return false; + } + + this.show = function() { + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + close_btn.onclick = onclose; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + }; + + this.hide = function() { + html_scene.classList.add("hidden"); + close_btn.classList.remove("hidden"); + upload_btn.classList.remove("hidden"); + uploadfile_form.classList.remove("hidden"); + uploadfile_lbl.classList.remove("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + hreflimitmsg_html.classList.add("hidden"); + pending_html.classList.add("hidden"); + close_btn.onclick = null; + upload_btn.onclick = null; + href_form.value = ""; + uploadfile_form.value = ""; + if(nodes == null){ + return; + } + nodes.forEach(function(node) { + node.parentNode.removeChild(node); + }); + nodes = null; + }; + this.release = function() { + scene_index = null; + if (upload_req !== null) { + upload_req.abort(); + upload_req = null; + } + }; +} \ No newline at end of file diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js new file mode 100644 index 00000000..3a84dfb4 --- /dev/null +++ b/radicale/web/internal_data/api.js @@ -0,0 +1,342 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { Collection, CollectionType } from "./models.js"; +import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js"; +import { escape_xml } from "./utils.js"; + +/** + * Find the principal collection. + * @param {string} user + * @param {string} password + * @param {function(?Collection, ?string)} callback Returns result or error + * @return {XMLHttpRequest} + */ +export function get_principal(user, password, callback) { + let request = new XMLHttpRequest(); + request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } + 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, + ""), null); + } else { + callback(null, "Internal error"); + } + } else { + callback(null, request.status + " " + request.statusText); + } + }; + request.send('' + + '' + + '' + + '' + + '' + + '' + + ''); + return request; +} + +/** + * Find all calendars and addressbooks in collection. + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {function(?Array, ?string)} callback Returns result or error + * @return {XMLHttpRequest} + */ +export function get_collections(user, password, collection, callback) { + let request = new XMLHttpRequest(); + request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password)); + request.setRequestHeader("depth", "1"); + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } + 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); + } + } + 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)); + } + } + 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); + } else { + callback(null, request.status + " " + request.statusText); + } + }; + request.send('' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''); + return request; +} + +/** + * @param {string} user + * @param {string} password + * @param {string} collection_href Must always start and end with /. + * @param {File} file + * @param {function(?string)} callback Returns error or null + * @return {XMLHttpRequest} + */ +export function upload_collection(user, password, collection_href, file, callback) { + let request = new XMLHttpRequest(); + request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password)); + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + callback(null); + } else { + callback(request.status + " " + request.statusText); + } + }; + request.setRequestHeader("If-None-Match", "*"); + request.send(file); + return request; +} + +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {function(?string)} callback Returns error or null + * @return {XMLHttpRequest} + */ +export function delete_collection(user, password, collection, callback) { + let request = new XMLHttpRequest(); + request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password)); + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + callback(null); + } else { + callback(request.status + " " + request.statusText); + } + }; + request.send(); + return request; +} + +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {boolean} create + * @param {function(?string)} callback Returns error or null + * @return {XMLHttpRequest} + */ +function create_edit_collection(user, password, collection, create, callback) { + let request = new XMLHttpRequest(); + request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password)); + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + callback(null); + } else { + callback(request.status + " " + request.statusText); + } + }; + let displayname = escape_xml(collection.displayname); + let calendar_color = ""; + let addressbook_color = ""; + let calendar_description = ""; + let addressbook_description = ""; + let calendar_source = ""; + let resourcetype; + let components = ""; + if (collection.type === CollectionType.ADDRESSBOOK) { + addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + addressbook_description = escape_xml(collection.description); + resourcetype = ''; + } else if (collection.type === CollectionType.WEBCAL) { + calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + calendar_description = escape_xml(collection.description); + resourcetype = ''; + calendar_source = escape_xml(collection.source); + } else { + calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + calendar_description = escape_xml(collection.description); + resourcetype = ''; + if (CollectionType.is_subset(CollectionType.CALENDAR, collection.type)) { + components += ''; + } + if (CollectionType.is_subset(CollectionType.JOURNAL, collection.type)) { + components += ''; + } + if (CollectionType.is_subset(CollectionType.TASKS, collection.type)) { + components += ''; + } + } + let xml_request = create ? "mkcol" : "propertyupdate"; + request.send('' + + '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + + '' + + '' + + (create ? '' + resourcetype + '' : '') + + (components ? '' + components + '' : '') + + (displayname ? '' + displayname + '' : '') + + (calendar_color ? '' + calendar_color + '' : '') + + (addressbook_color ? '' + addressbook_color + '' : '') + + (addressbook_description ? '' + addressbook_description + '' : '') + + (calendar_description ? '' + calendar_description + '' : '') + + (calendar_source ? '' + calendar_source + '' : '') + + '' + + '' + + (!create ? ('' + + '' + + (!components ? '' : '') + + (!displayname ? '' : '') + + (!calendar_color ? '' : '') + + (!addressbook_color ? '' : '') + + (!addressbook_description ? '' : '') + + (!calendar_description ? '' : '') + + '' + + ''): '') + + ''); + return request; +} + +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {function(?string)} callback Returns error or null + * @return {XMLHttpRequest} + */ +export function create_collection(user, password, collection, callback) { + return create_edit_collection(user, password, collection, true, callback); +} + +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {function(?string)} callback Returns error or null + * @return {XMLHttpRequest} + */ +export function edit_collection(user, password, collection, callback) { + return create_edit_collection(user, password, collection, false, callback); +} \ No newline at end of file diff --git a/radicale/web/internal_data/constants.js b/radicale/web/internal_data/constants.js new file mode 100644 index 00000000..caa88a03 --- /dev/null +++ b/radicale/web/internal_data/constants.js @@ -0,0 +1,46 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Server address + * @const + * @type {string} + */ +export const SERVER = location.origin; + +/** + * Path of the root collection on the server (must end with /) + * @const + * @type {string} + */ +export const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; + +/** + * Regex to match and normalize color + * @const + */ +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 + */ +export const DELETE_CONFIRMATION_TEXT = "DELETE"; \ No newline at end of file diff --git a/radicale/web/internal_data/fn.js b/radicale/web/internal_data/fn.js deleted file mode 100644 index 745ea87d..00000000 --- a/radicale/web/internal_data/fn.js +++ /dev/null @@ -1,1413 +0,0 @@ -/** - * This file is part of Radicale Server - Calendar Server - * Copyright © 2017-2024 Unrud - * Copyright © 2023-2024 Matthew Hana - * Copyright © 2024-2025 Peter Bieringer - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/** - * Server address - * @const - * @type {string} - */ -const SERVER = location.origin; - -/** - * Path of the root collection on the server (must end with /) - * @const - * @type {string} - */ -const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; - -/** - * Regex to match and normalize color - * @const - */ -const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); - - -/** - * The text needed to confirm deleting a collection - * @const - */ -const DELETE_CONFIRMATION_TEXT = "DELETE"; - -/** - * Escape string for usage in XML - * @param {string} s - * @return {string} - */ -function escape_xml(s) { - return (s - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">")); -} - -/** - * @enum {string} - */ -const CollectionType = { - PRINCIPAL: "PRINCIPAL", - ADDRESSBOOK: "ADDRESSBOOK", - CALENDAR_JOURNAL_TASKS: "CALENDAR_JOURNAL_TASKS", - CALENDAR_JOURNAL: "CALENDAR_JOURNAL", - CALENDAR_TASKS: "CALENDAR_TASKS", - JOURNAL_TASKS: "JOURNAL_TASKS", - CALENDAR: "CALENDAR", - JOURNAL: "JOURNAL", - TASKS: "TASKS", - WEBCAL: "WEBCAL", - is_subset: function(a, b) { - let components = a.split("_"); - for (let i = 0; i < components.length; i++) { - if (b.search(components[i]) === -1) { - return false; - } - } - return true; - }, - union: function(a, b) { - if (a.search(this.ADDRESSBOOK) !== -1 || b.search(this.ADDRESSBOOK) !== -1) { - if (a && a !== this.ADDRESSBOOK || b && b !== this.ADDRESSBOOK) { - throw "Invalid union: " + a + " " + b; - } - return this.ADDRESSBOOK; - } - let union = []; - if (a.search(this.CALENDAR) !== -1 || b.search(this.CALENDAR) !== -1) { - union.push(this.CALENDAR); - } - if (a.search(this.JOURNAL) !== -1 || b.search(this.JOURNAL) !== -1) { - union.push(this.JOURNAL); - } - if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { - union.push(this.TASKS); - } - if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) { - union.push(this.WEBCAL); - } - return union.join("_"); - }, - valid_options_for_type: function(a){ - a = a.trim().toUpperCase(); - switch(a){ - case CollectionType.CALENDAR_JOURNAL_TASKS: - case CollectionType.CALENDAR_JOURNAL: - case CollectionType.CALENDAR_TASKS: - case CollectionType.JOURNAL_TASKS: - case CollectionType.CALENDAR: - case CollectionType.JOURNAL: - case CollectionType.TASKS: - return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS]; - case CollectionType.ADDRESSBOOK: - case CollectionType.WEBCAL: - default: - return [a]; - } - } -}; - -/** - * @constructor - * @struct - * @param {string} href Must always start and end with /. - * @param {CollectionType} type - * @param {string} displayname - * @param {string} description - * @param {string} color - */ -function Collection(href, type, displayname, description, color, contentcount, size, source) { - this.href = href; - this.type = type; - this.displayname = displayname; - this.color = color; - this.description = description; - this.source = source; - this.contentcount = contentcount; - this.size = size; -} - -/** - * Find the principal collection. - * @param {string} user - * @param {string} password - * @param {function(?Collection, ?string)} callback Returns result or error - * @return {XMLHttpRequest} - */ -function get_principal(user, password, callback) { - let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - 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, - ""), null); - } else { - callback(null, "Internal error"); - } - } else { - callback(null, request.status + " " + request.statusText); - } - }; - request.send('' + - '' + - '' + - '' + - '' + - '' + - ''); - return request; -} - -/** - * Find all calendars and addressbooks in collection. - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?Array, ?string)} callback Returns result or error - * @return {XMLHttpRequest} - */ -function get_collections(user, password, collection, callback) { - let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password)); - request.setRequestHeader("depth", "1"); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - 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); - } - } - 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)); - } - } - 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); - } else { - callback(null, request.status + " " + request.statusText); - } - }; - request.send('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {string} collection_href Must always start and end with /. - * @param {File} file - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function upload_collection(user, password, collection_href, file, callback) { - let request = new XMLHttpRequest(); - request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password)); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - callback(null); - } else { - callback(request.status + " " + request.statusText); - } - }; - request.setRequestHeader("If-None-Match", "*"); - request.send(file); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function delete_collection(user, password, collection, callback) { - let request = new XMLHttpRequest(); - request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password)); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - callback(null); - } else { - callback(request.status + " " + request.statusText); - } - }; - request.send(); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {boolean} create - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function create_edit_collection(user, password, collection, create, callback) { - let request = new XMLHttpRequest(); - request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password)); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - callback(null); - } else { - callback(request.status + " " + request.statusText); - } - }; - let displayname = escape_xml(collection.displayname); - let calendar_color = ""; - let addressbook_color = ""; - let calendar_description = ""; - let addressbook_description = ""; - let calendar_source = ""; - let resourcetype; - let components = ""; - if (collection.type === CollectionType.ADDRESSBOOK) { - addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); - addressbook_description = escape_xml(collection.description); - resourcetype = ''; - } else if (collection.type === CollectionType.WEBCAL) { - calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); - calendar_description = escape_xml(collection.description); - resourcetype = ''; - calendar_source = escape_xml(collection.source); - } else { - calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); - calendar_description = escape_xml(collection.description); - resourcetype = ''; - if (CollectionType.is_subset(CollectionType.CALENDAR, collection.type)) { - components += ''; - } - if (CollectionType.is_subset(CollectionType.JOURNAL, collection.type)) { - components += ''; - } - if (CollectionType.is_subset(CollectionType.TASKS, collection.type)) { - components += ''; - } - } - let xml_request = create ? "mkcol" : "propertyupdate"; - request.send('' + - '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + - '' + - '' + - (create ? '' + resourcetype + '' : '') + - (components ? '' + components + '' : '') + - (displayname ? '' + displayname + '' : '') + - (calendar_color ? '' + calendar_color + '' : '') + - (addressbook_color ? '' + addressbook_color + '' : '') + - (addressbook_description ? '' + addressbook_description + '' : '') + - (calendar_description ? '' + calendar_description + '' : '') + - (calendar_source ? '' + calendar_source + '' : '') + - '' + - '' + - (!create ? ('' + - '' + - (!components ? '' : '') + - (!displayname ? '' : '') + - (!calendar_color ? '' : '') + - (!addressbook_color ? '' : '') + - (!addressbook_description ? '' : '') + - (!calendar_description ? '' : '') + - '' + - ''): '') + - ''); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function create_collection(user, password, collection, callback) { - return create_edit_collection(user, password, collection, true, callback); -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function edit_collection(user, password, collection, callback) { - return create_edit_collection(user, password, collection, false, callback); -} - -/** - * @return {string} -*/ -function random_uuid() { - return random_hex(8) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(12); -} - -/** - * @interface - */ -function Scene() {} -/** - * Scene is on top of stack and visible. - */ -Scene.prototype.show = function() {}; -/** - * Scene is no longer visible. - */ -Scene.prototype.hide = function() {}; -/** - * Scene is removed from scene stack. - */ -Scene.prototype.release = function() {}; - - -/** - * @type {Array} - */ -let scene_stack = []; - -/** - * Push scene onto stack. - * @param {Scene} scene - * @param {boolean} replace Replace the scene on top of the stack. - */ -function push_scene(scene, replace) { - if (scene_stack.length >= 1) { - scene_stack[scene_stack.length - 1].hide(); - if (replace) { - scene_stack.pop().release(); - } - } - scene_stack.push(scene); - scene.show(); -} - -/** - * Remove scenes from stack. - * @param {number} index New top of stack - */ -function pop_scene(index) { - if (scene_stack.length - 1 <= index) { - return; - } - scene_stack[scene_stack.length - 1].hide(); - while (scene_stack.length - 1 > index) { - let old_length = scene_stack.length; - scene_stack.pop().release(); - if (old_length - 1 === index + 1) { - break; - } - } - if (scene_stack.length >= 1) { - let scene = scene_stack[scene_stack.length - 1]; - scene.show(); - } else { - throw "Scene stack is empty"; - } -} - -/** - * @constructor - * @implements {Scene} - */ -function LoginScene() { - let html_scene = document.getElementById("loginscene"); - let form = html_scene.querySelector("[data-name=form]"); - let user_form = html_scene.querySelector("[data-name=user]"); - let password_form = html_scene.querySelector("[data-name=password]"); - let error_form = html_scene.querySelector("[data-name=error]"); - let logout_view = document.getElementById("logoutview"); - let logout_user_form = logout_view.querySelector("[data-name=user]"); - let logout_btn = logout_view.querySelector("[data-name=logout]"); - let refresh_btn = logout_view.querySelector("[data-name=refresh]"); - - /** @type {?number} */ let scene_index = null; - let user = ""; - let error = ""; - /** @type {?XMLHttpRequest} */ let principal_req = null; - - function read_form() { - user = user_form.value; - } - - function fill_form() { - user_form.value = user; - password_form.value = ""; - if(error){ - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - }else{ - error_form.classList.add("hidden"); - } - } - - function onlogin() { - try { - read_form(); - let password = password_form.value; - if (user) { - error = ""; - // setup logout - logout_view.classList.remove("hidden"); - logout_btn.onclick = onlogout; - refresh_btn.onclick = refresh; - logout_user_form.textContent = user + "'s Collections"; - // Fetch principal - let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); - principal_req = get_principal(user, password, function(collection, error1) { - if (scene_index === null) { - return; - } - principal_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - // show collections - let saved_user = user; - user = ""; - let collections_scene = new CollectionsScene( - saved_user, password, collection, function(error1) { - error = error1; - user = saved_user; - }); - push_scene(collections_scene, true); - } - }); - } else { - error = "Username is empty"; - fill_form(); - } - } catch(err) { - console.error(err); - } - return false; - } - - function onlogout() { - try { - if (scene_index === null) { - return false; - } - user = ""; - pop_scene(scene_index); - } catch (err) { - console.error(err); - } - return false; - } - - function remove_logout() { - logout_view.classList.add("hidden"); - logout_btn.onclick = null; - refresh_btn.onclick = null; - logout_user_form.textContent = ""; - } - - function refresh(){ - //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it - //forcing the scene below it, the Collections Scene to refresh itself. - push_scene(new LoadingScene(), false); - pop_scene(scene_stack.length-2); - } - - this.show = function() { - remove_logout(); - fill_form(); - form.onsubmit = onlogin; - html_scene.classList.remove("hidden"); - scene_index = scene_stack.length - 1; - user_form.focus(); - }; - this.hide = function() { - read_form(); - html_scene.classList.add("hidden"); - form.onsubmit = null; - }; - this.release = function() { - scene_index = null; - // cancel pending requests - if (principal_req !== null) { - principal_req.abort(); - principal_req = null; - } - remove_logout(); - }; -} - -/** - * @constructor - * @implements {Scene} - */ -function LoadingScene() { - let html_scene = document.getElementById("loadingscene"); - this.show = function() { - html_scene.classList.remove("hidden"); - }; - this.hide = function() { - html_scene.classList.add("hidden"); - }; - this.release = function() {}; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection The principal collection. - * @param {function(string)} onerror Called when an error occurs, before the - * scene is popped. - */ -function CollectionsScene(user, password, collection, onerror) { - let html_scene = document.getElementById("collectionsscene"); - let template = html_scene.querySelector("[data-name=collectiontemplate]"); - let new_btn = html_scene.querySelector("[data-name=new]"); - let upload_btn = html_scene.querySelector("[data-name=upload]"); - - /** @type {?number} */ let scene_index = null; - /** @type {?XMLHttpRequest} */ let collections_req = null; - /** @type {?Array} */ let collections = null; - /** @type {Array} */ let nodes = []; - - function onnew() { - try { - let create_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(create_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function onupload() { - try { - let upload_scene = new UploadCollectionScene(user, password, collection); - push_scene(upload_scene); - } catch(err) { - console.error(err); - } - return false; - } - - function onedit(collection) { - try { - let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(edit_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function ondelete(collection) { - try { - let delete_collection_scene = new DeleteCollectionScene(user, password, collection); - push_scene(delete_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function show_collections(collections) { - let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px"; - html_scene.style.marginTop = heightOfNavBar; - html_scene.style.height = "calc(100vh - " + heightOfNavBar +")"; - collections.forEach(function (collection) { - let node = template.cloneNode(true); - node.classList.remove("hidden"); - let title_form = node.querySelector("[data-name=title]"); - let description_form = node.querySelector("[data-name=description]"); - let contentcount_form = node.querySelector("[data-name=contentcount]"); - let url_form = node.querySelector("[data-name=url]"); - let color_form = node.querySelector("[data-name=color]"); - let delete_btn = node.querySelector("[data-name=delete]"); - let edit_btn = node.querySelector("[data-name=edit]"); - let download_btn = node.querySelector("[data-name=download]"); - if (collection.color) { - color_form.style.background = collection.color; - } - let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; - [CollectionType.CALENDAR, ""].forEach(function(e) { - [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { - [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { - if (e) { - possible_types.push(e); - } - }); - }); - }); - possible_types.forEach(function(e) { - if (e !== collection.type) { - node.querySelector("[data-name=" + e + "]").classList.add("hidden"); - } - }); - title_form.textContent = collection.displayname || collection.href; - if(title_form.textContent.length > 30){ - title_form.classList.add("smalltext"); - } - description_form.textContent = collection.description; - if(description_form.textContent.length > 150){ - description_form.classList.add("smalltext"); - } - if(collection.type != CollectionType.WEBCAL){ - let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; - if(collection.contentcount > 0){ - contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; - } - contentcount_form.textContent = contentcount_form_txt; - } - let href = SERVER + collection.href; - url_form.value = href; - download_btn.href = href; - if(collection.type == CollectionType.WEBCAL){ - download_btn.parentElement.classList.add("hidden"); - } - delete_btn.onclick = function() {return ondelete(collection);}; - edit_btn.onclick = function() {return onedit(collection);}; - node.classList.remove("hidden"); - nodes.push(node); - template.parentNode.insertBefore(node, template); - }); - } - - function update() { - let loading_scene = new LoadingScene(); - push_scene(loading_scene, false); - collections_req = get_collections(user, password, collection, function(collections1, error) { - if (scene_index === null) { - return; - } - collections_req = null; - if (error) { - onerror(error); - pop_scene(scene_index - 1); - } else { - collections = collections1; - pop_scene(scene_index); - } - }); - } - - this.show = function() { - html_scene.classList.remove("hidden"); - new_btn.onclick = onnew; - upload_btn.onclick = onupload; - if (collections === null) { - update(); - } else { - // from update loading scene - show_collections(collections); - } - }; - this.hide = function() { - html_scene.classList.add("hidden"); - scene_index = scene_stack.length - 1; - new_btn.onclick = null; - upload_btn.onclick = null; - collections = null; - // remove collection - nodes.forEach(function(node) { - node.parentNode.removeChild(node); - }); - nodes = []; - }; - this.release = function() { - scene_index = null; - if (collections_req !== null) { - collections_req.abort(); - collections_req = null; - } - collections = null; - }; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection parent collection - * @param {Array} files - */ -function UploadCollectionScene(user, password, collection) { - let html_scene = document.getElementById("uploadcollectionscene"); - let template = html_scene.querySelector("[data-name=filetemplate]"); - let upload_btn = html_scene.querySelector("[data-name=submit]"); - let close_btn = html_scene.querySelector("[data-name=close]"); - let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); - let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); - let href_form = html_scene.querySelector("[data-name=href]"); - let href_label = html_scene.querySelector("label[for=href]"); - let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); - let pending_html = html_scene.querySelector("[data-name=pending]"); - - let files = uploadfile_form.files; - href_form.addEventListener("keydown", cleanHREFinput); - upload_btn.onclick = upload_start; - uploadfile_form.onchange = onfileschange; - - href_form.value = ""; - - /** @type {?number} */ let scene_index = null; - /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let results = []; - /** @type {?Array} */ let nodes = null; - - function upload_start() { - try { - if(!read_form()){ - return false; - } - uploadfile_form.classList.add("hidden"); - uploadfile_lbl.classList.add("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - hreflimitmsg_html.classList.add("hidden"); - upload_btn.classList.add("hidden"); - close_btn.classList.add("hidden"); - - pending_html.classList.remove("hidden"); - - nodes = []; - for (let i = 0; i < files.length; i++) { - let file = files[i]; - let node = template.cloneNode(true); - node.classList.remove("hidden"); - let name_form = node.querySelector("[data-name=name]"); - name_form.textContent = file.name; - node.classList.remove("hidden"); - nodes.push(node); - updateFileStatus(i); - template.parentNode.insertBefore(node, template); - } - upload_next(); - } catch(err) { - console.error(err); - } - return false; - } - - function upload_next(){ - try{ - if (files.length === results.length) { - pending_html.classList.add("hidden"); - close_btn.classList.remove("hidden"); - return; - } else { - let file = files[results.length]; - if(files.length > 1 || href.length == 0){ - href = random_uuid(); - } - let upload_href = collection.href + href + "/"; - upload_req = upload_collection(user, password, upload_href, file, function(result) { - upload_req = null; - results.push(result); - updateFileStatus(results.length - 1); - upload_next(); - }); - } - }catch(err){ - console.error(err); - } - } - - function onclose() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - function updateFileStatus(i) { - if (nodes === null) { - return; - } - let success_form = nodes[i].querySelector("[data-name=success]"); - let error_form = nodes[i].querySelector("[data-name=error]"); - if (results.length > i) { - if (results[i]) { - success_form.classList.add("hidden"); - error_form.textContent = "Error: " + results[i]; - error_form.classList.remove("hidden"); - } else { - success_form.classList.remove("hidden"); - error_form.classList.add("hidden"); - } - } else { - success_form.classList.add("hidden"); - error_form.classList.add("hidden"); - } - } - - function read_form() { - cleanHREFinput(href_form); - let newhreftxtvalue = href_form.value.trim().toLowerCase(); - if(!isValidHREF(newhreftxtvalue)){ - alert("You must enter a valid HREF"); - return false; - } - href = newhreftxtvalue; - - if(uploadfile_form.files.length == 0){ - alert("You must select at least one file to upload"); - return false; - } - files = uploadfile_form.files; - return true; - } - - function onfileschange() { - files = uploadfile_form.files; - if(files.length > 1){ - hreflimitmsg_html.classList.remove("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - href_form.value = random_uuid(); // dummy, will be replaced on upload - }else{ - hreflimitmsg_html.classList.add("hidden"); - href_form.classList.remove("hidden"); - href_label.classList.remove("hidden"); - href_form.value = files[0].name.replace(/\.(ics|vcf)$/, ''); - } - return false; - } - - this.show = function() { - scene_index = scene_stack.length - 1; - html_scene.classList.remove("hidden"); - close_btn.onclick = onclose; - if(error){ - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - }else{ - error_form.classList.add("hidden"); - } - }; - - this.hide = function() { - html_scene.classList.add("hidden"); - close_btn.classList.remove("hidden"); - upload_btn.classList.remove("hidden"); - uploadfile_form.classList.remove("hidden"); - uploadfile_lbl.classList.remove("hidden"); - href_form.classList.remove("hidden"); - href_label.classList.remove("hidden"); - hreflimitmsg_html.classList.add("hidden"); - pending_html.classList.add("hidden"); - close_btn.onclick = null; - upload_btn.onclick = null; - href_form.value = ""; - uploadfile_form.value = ""; - if(nodes == null){ - return; - } - nodes.forEach(function(node) { - node.parentNode.removeChild(node); - }); - nodes = null; - }; - this.release = function() { - scene_index = null; - if (upload_req !== null) { - upload_req.abort(); - upload_req = null; - } - }; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection - */ -function DeleteCollectionScene(user, password, collection) { - let html_scene = document.getElementById("deletecollectionscene"); - let title_form = html_scene.querySelector("[data-name=title]"); - let error_form = html_scene.querySelector("[data-name=error]"); - let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); - let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); - let delete_btn = html_scene.querySelector("[data-name=delete]"); - let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - - delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; - confirmation_txt.value = ""; - confirmation_txt.addEventListener("keydown", onkeydown); - - /** @type {?number} */ let scene_index = null; - /** @type {?XMLHttpRequest} */ let delete_req = null; - let error = ""; - - function ondelete() { - let confirmation_text_value = confirmation_txt.value; - if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){ - alert("Please type the confirmation text to delete this collection."); - return; - } - try { - let loading_scene = new LoadingScene(); - push_scene(loading_scene); - delete_req = delete_collection(user, password, collection, function(error1) { - if (scene_index === null) { - return; - } - delete_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - pop_scene(scene_index - 1); - } - }); - } catch(err) { - console.error(err); - } - return false; - } - - function oncancel() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - function onkeydown(event){ - if (event.keyCode !== 13) { - return; - } - ondelete(); - } - - this.show = function() { - this.release(); - scene_index = scene_stack.length - 1; - html_scene.classList.remove("hidden"); - title_form.textContent = collection.displayname || collection.href; - delete_btn.onclick = ondelete; - cancel_btn.onclick = oncancel; - if(error){ - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - }else{ - error_form.classList.add("hidden"); - } - - }; - this.hide = function() { - html_scene.classList.add("hidden"); - cancel_btn.onclick = null; - delete_btn.onclick = null; - }; - this.release = function() { - scene_index = null; - if (delete_req !== null) { - delete_req.abort(); - delete_req = null; - } - }; -} - -/** - * Generate random hex number. - * @param {number} length - * @return {string} - */ -function random_hex(length) { - let bytes = new Uint8Array(Math.ceil(length / 2)); - window.crypto.getRandomValues(bytes); - return bytes.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "").substring(0, length); -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @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. - */ -function CreateEditCollectionScene(user, password, collection) { - let edit = collection.type !== CollectionType.PRINCIPAL; - let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); - let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; - let error_form = html_scene.querySelector("[data-name=error]"); - let href_form = html_scene.querySelector("[data-name=href]"); - let href_label = html_scene.querySelector("label[for=href]"); - let displayname_form = html_scene.querySelector("[data-name=displayname]"); - let displayname_label = html_scene.querySelector("label[for=displayname]"); - let description_form = html_scene.querySelector("[data-name=description]"); - let description_label = html_scene.querySelector("label[for=description]"); - let source_form = html_scene.querySelector("[data-name=source]"); - let source_label = html_scene.querySelector("label[for=source]"); - let type_form = html_scene.querySelector("[data-name=type]"); - let type_label = html_scene.querySelector("label[for=type]"); - let color_form = html_scene.querySelector("[data-name=color]"); - let color_label = html_scene.querySelector("label[for=color]"); - let submit_btn = html_scene.querySelector("[data-name=submit]"); - let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - - - /** @type {?number} */ let scene_index = null; - /** @type {?XMLHttpRequest} */ let create_edit_req = null; - let error = ""; - /** @type {?Element} */ let saved_type_form = null; - - let href = edit ? collection.href : collection.href + random_uuid() + "/"; - let displayname = edit ? collection.displayname : ""; - let description = edit ? collection.description : ""; - let source = edit ? collection.source : ""; - let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; - let color = edit && collection.color ? collection.color : "#" + random_hex(6); - - if(!edit){ - href_form.addEventListener("keydown", cleanHREFinput); - } - - function remove_invalid_types() { - if (!edit) { - return; - } - /** @type {HTMLOptionsCollection} */ let options = type_form.options; - // remove all options that are not supersets - let valid_type_options = CollectionType.valid_options_for_type(type); - for (let i = options.length - 1; i >= 0; i--) { - if (valid_type_options.indexOf(options[i].value) < 0) { - options.remove(i); - } - } - } - - function read_form() { - if(!edit){ - cleanHREFinput(href_form); - let newhreftxtvalue = href_form.value.trim().toLowerCase(); - if(!isValidHREF(newhreftxtvalue)){ - alert("You must enter a valid HREF"); - return false; - } - href = collection.href + newhreftxtvalue + "/"; - } - displayname = displayname_form.value; - description = description_form.value; - source = source_form.value; - type = type_form.value; - color = color_form.value; - return true; - } - - function fill_form() { - if(!edit){ - href_form.value = random_uuid(); - } - displayname_form.value = displayname; - description_form.value = description; - source_form.value = source; - type_form.value = type; - color_form.value = color; - if(error){ - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - } - error_form.classList.add("hidden"); - onTypeChange(); - type_form.addEventListener("change", onTypeChange); - } - - function onsubmit() { - try { - if(!read_form()){ - return false; - } - let sane_color = color.trim(); - if (sane_color) { - let color_match = COLOR_RE.exec(sane_color); - if (!color_match) { - error = "Invalid color"; - fill_form(); - return false; - } - sane_color = 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) { - if (scene_index === null) { - return; - } - create_edit_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - pop_scene(scene_index - 1); - } - }; - if (edit) { - create_edit_req = edit_collection(user, password, collection, callback); - } else { - create_edit_req = create_collection(user, password, collection, callback); - } - } catch(err) { - console.error(err); - } - return false; - } - - function oncancel() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - - function onTypeChange(e){ - if(type_form.value == CollectionType.WEBCAL){ - source_label.classList.remove("hidden"); - source_form.classList.remove("hidden"); - }else{ - source_label.classList.add("hidden"); - source_form.classList.add("hidden"); - } - } - - this.show = function() { - this.release(); - scene_index = scene_stack.length - 1; - // Clone type_form because it's impossible to hide options without removing them - saved_type_form = type_form; - type_form = type_form.cloneNode(true); - saved_type_form.parentNode.replaceChild(type_form, saved_type_form); - remove_invalid_types(); - html_scene.classList.remove("hidden"); - if (edit) { - title_form.textContent = collection.displayname || collection.href; - } - fill_form(); - submit_btn.onclick = onsubmit; - cancel_btn.onclick = oncancel; - if(error){ - error_form.textContent = "Error: " + error; - error_form.classList.remove("hidden"); - }else{ - error_form.classList.add("hidden"); - } - }; - this.hide = function() { - read_form(); - html_scene.classList.add("hidden"); - // restore 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; - }; - this.release = function() { - scene_index = null; - if (create_edit_req !== null) { - create_edit_req.abort(); - create_edit_req = null; - } - }; -} - -/** - * Removed invalid HREF characters for a collection HREF. - * - * @param a A valid Input element or an onchange Event of an Input element. - */ -function cleanHREFinput(a) { - let href_form = a; - if (a.target) { - href_form = a.target; - } - let currentTxtVal = href_form.value.trim().toLowerCase(); - //Clean the HREF to remove not permitted chars - currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_\.])./g, ''); - //Clean the HREF to remove leading . (would result in hidden directory) - currentTxtVal = currentTxtVal.replace(/^\./, ''); - href_form.value = currentTxtVal; -} - -/** - * Checks if a proposed HREF for a collection has a valid format and syntax. - * - * @param href String of the porposed HREF. - * - * @return Boolean results if the HREF is valid. - */ -function isValidHREF(href) { - if (href.length < 1) { - return false; - } - if (href.indexOf("/") != -1) { - return false; - } - - return true; -} - -/** - * Format bytes to human-readable text. - * - * @param bytes Number of bytes. - * - * @return Formatted string. - */ -function bytesToHumanReadable(bytes, dp=1) { - let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); - if(!isNumber){ - return ""; - } - var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; -} - - -function main() { - // Hide startup loading message - document.getElementById("loadingscene").classList.add("hidden"); - push_scene(new LoginScene(), false); -} - -window.addEventListener("load", main); diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 8d257c92..16ef91d3 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -14,7 +14,7 @@ - + diff --git a/radicale/web/internal_data/main.js b/radicale/web/internal_data/main.js new file mode 100644 index 00000000..075714b6 --- /dev/null +++ b/radicale/web/internal_data/main.js @@ -0,0 +1,26 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { push_scene } from "./scene_manager.js"; +import { LoginScene } from "./LoginScene.js"; + +// Hide startup loading message +document.getElementById("loadingscene").classList.add("hidden"); +push_scene(new LoginScene(), false); \ No newline at end of file diff --git a/radicale/web/internal_data/models.js b/radicale/web/internal_data/models.js new file mode 100644 index 00000000..9dcdc5e9 --- /dev/null +++ b/radicale/web/internal_data/models.js @@ -0,0 +1,103 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * @enum {string} + */ +export const CollectionType = { + PRINCIPAL: "PRINCIPAL", + ADDRESSBOOK: "ADDRESSBOOK", + CALENDAR_JOURNAL_TASKS: "CALENDAR_JOURNAL_TASKS", + CALENDAR_JOURNAL: "CALENDAR_JOURNAL", + CALENDAR_TASKS: "CALENDAR_TASKS", + JOURNAL_TASKS: "JOURNAL_TASKS", + CALENDAR: "CALENDAR", + JOURNAL: "JOURNAL", + TASKS: "TASKS", + WEBCAL: "WEBCAL", + is_subset: function(a, b) { + let components = a.split("_"); + for (let i = 0; i < components.length; i++) { + if (b.search(components[i]) === -1) { + return false; + } + } + return true; + }, + union: function(a, b) { + if (a.search(this.ADDRESSBOOK) !== -1 || b.search(this.ADDRESSBOOK) !== -1) { + if (a && a !== this.ADDRESSBOOK || b && b !== this.ADDRESSBOOK) { + throw "Invalid union: " + a + " " + b; + } + return this.ADDRESSBOOK; + } + let union = []; + if (a.search(this.CALENDAR) !== -1 || b.search(this.CALENDAR) !== -1) { + union.push(this.CALENDAR); + } + if (a.search(this.JOURNAL) !== -1 || b.search(this.JOURNAL) !== -1) { + union.push(this.JOURNAL); + } + if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { + union.push(this.TASKS); + } + if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) { + union.push(this.WEBCAL); + } + return union.join("_"); + }, + valid_options_for_type: function(a){ + a = a.trim().toUpperCase(); + switch(a){ + case CollectionType.CALENDAR_JOURNAL_TASKS: + case CollectionType.CALENDAR_JOURNAL: + case CollectionType.CALENDAR_TASKS: + case CollectionType.JOURNAL_TASKS: + case CollectionType.CALENDAR: + case CollectionType.JOURNAL: + case CollectionType.TASKS: + return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS]; + case CollectionType.ADDRESSBOOK: + case CollectionType.WEBCAL: + default: + return [a]; + } + } +}; + +/** + * @constructor + * @struct + * @param {string} href Must always start and end with /. + * @param {CollectionType} type + * @param {string} displayname + * @param {string} description + * @param {string} color + */ +export function Collection(href, type, displayname, description, color, contentcount, size, source) { + this.href = href; + this.type = type; + this.displayname = displayname; + this.color = color; + this.description = description; + this.source = source; + this.contentcount = contentcount; + this.size = size; +} \ No newline at end of file diff --git a/radicale/web/internal_data/scene_manager.js b/radicale/web/internal_data/scene_manager.js new file mode 100644 index 00000000..bb489257 --- /dev/null +++ b/radicale/web/internal_data/scene_manager.js @@ -0,0 +1,82 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * @interface + */ +export function Scene() {} +/** + * Scene is on top of stack and visible. + */ +Scene.prototype.show = function() {}; +/** + * Scene is no longer visible. + */ +Scene.prototype.hide = function() {}; +/** + * Scene is removed from scene stack. + */ +Scene.prototype.release = function() {}; + + +/** + * @type {Array} + */ +export let scene_stack = []; + +/** + * Push scene onto stack. + * @param {Scene} scene + * @param {boolean} replace Replace the scene on top of the stack. + */ +export function push_scene(scene, replace) { + if (scene_stack.length >= 1) { + scene_stack[scene_stack.length - 1].hide(); + if (replace) { + scene_stack.pop().release(); + } + } + scene_stack.push(scene); + scene.show(); +} + +/** + * Remove scenes from stack. + * @param {number} index New top of stack + */ +export function pop_scene(index) { + if (scene_stack.length - 1 <= index) { + return; + } + scene_stack[scene_stack.length - 1].hide(); + while (scene_stack.length - 1 > index) { + let old_length = scene_stack.length; + scene_stack.pop().release(); + if (old_length - 1 === index + 1) { + break; + } + } + if (scene_stack.length >= 1) { + let scene = scene_stack[scene_stack.length - 1]; + scene.show(); + } else { + throw "Scene stack is empty"; + } +} \ No newline at end of file diff --git a/radicale/web/internal_data/utils.js b/radicale/web/internal_data/utils.js new file mode 100644 index 00000000..ad709f55 --- /dev/null +++ b/radicale/web/internal_data/utils.js @@ -0,0 +1,103 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Escape string for usage in XML + * @param {string} s + * @return {string} + */ +export function escape_xml(s) { + return (s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">")); +} + +/** + * @return {string} +*/ +export function random_uuid() { + return random_hex(8) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(12); +} + +/** + * Generate random hex number. + * @param {number} length + * @return {string} + */ +export function random_hex(length) { + let bytes = new Uint8Array(Math.ceil(length / 2)); + window.crypto.getRandomValues(bytes); + return bytes.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "").substring(0, length); +} + +/** + * Removed invalid HREF characters for a collection HREF. + * + * @param a A valid Input element or an onchange Event of an Input element. + */ +export function cleanHREFinput(a) { + let href_form = a; + if (a.target) { + href_form = a.target; + } + let currentTxtVal = href_form.value.trim().toLowerCase(); + //Clean the HREF to remove not permitted chars + currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_\.])./g, ''); + //Clean the HREF to remove leading . (would result in hidden directory) + currentTxtVal = currentTxtVal.replace(/^\./, ''); + href_form.value = currentTxtVal; +} + +/** + * Checks if a proposed HREF for a collection has a valid format and syntax. + * + * @param href String of the porposed HREF. + * + * @return Boolean results if the HREF is valid. + */ +export function isValidHREF(href) { + if (href.length < 1) { + return false; + } + if (href.indexOf("/") != -1) { + return false; + } + + return true; +} + +/** + * Format bytes to human-readable text. + * + * @param bytes Number of bytes. + * + * @return Formatted string. + */ +export function bytesToHumanReadable(bytes, dp=1) { + let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); + if(!isNumber){ + return ""; + } + var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; +} \ No newline at end of file From fda0f5a5a3f4432446fd4621e7b1c5c94c7c4969 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 18 Feb 2026 23:03:54 +0100 Subject: [PATCH 2/2] Added basic integration tests focussing on javascript --- integ_tests/README.md | 28 ++++++++ integ_tests/test_basic_operation.py | 104 ++++++++++++++++++++++++++++ pyproject.toml | 8 ++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 integ_tests/README.md create mode 100644 integ_tests/test_basic_operation.py diff --git a/integ_tests/README.md b/integ_tests/README.md new file mode 100644 index 00000000..72984e3d --- /dev/null +++ b/integ_tests/README.md @@ -0,0 +1,28 @@ +# Integration tests + +These tests use Playwright to run the tests agains a full Radicale instance. + +## First time install + +You need to install playwright. The easiest way to do this is to create a .venv with playwright inside. You can do this by trying to run the integ tests and then entering the .venv + +Assuming you use uv to run/create your .venvs, note that the invocation will fail: + +``` lang=shell +uv run --extra integ_test pytest integ_tests +source .venv/bin/activate +``` + +then run + +``` lang=shell +playwright install --with-deps +``` + +## Running the tests + +if you use uv, you can run + +``` lang=shell +uv run --extra integ_test pytest integ_tests +``` diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py new file mode 100644 index 00000000..e0d07994 --- /dev/null +++ b/integ_tests/test_basic_operation.py @@ -0,0 +1,104 @@ +import os +import socket +import subprocess +import sys +import time + +import pytest +from playwright.sync_api import Page, expect + + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def radicale_server(tmp_path): + port = get_free_port() + config_path = tmp_path / "config" + user_path = tmp_path / "users" + storage_path = tmp_path / "collections" + + # Create a local config file + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = htpasswd +htpasswd_filename = {user_path} +[web] +type = internal +""" + ) + with open(user_path, "w") as f: + f.write( + """admin:adminpassword +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + # Assuming this test file is in /integ_tests/ + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +def test_index_html_loads(page: Page, radicale_server): + """Test that the index.html loads from the server.""" + console_msgs = [] + page.on("console", lambda msg: console_msgs.append(msg.text)) + page.goto(radicale_server) + expect(page).to_have_title("Radicale Web Interface") + # There should be no errors on the console + assert len(console_msgs) == 0 + + +def test_user_login_works(page: Page, radicale_server): + """Test that the login form works.""" + page.goto(radicale_server) + # Fill in the login form + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.click('button:has-text("Next")') + + # After login, we should see the collections list (which is empty) + expect( + page.locator('span[data-name="user"]', has_text="admin's Collections") + ).to_be_visible() diff --git a/pyproject.toml b/pyproject.toml index 80e7fe37..91bff149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ test = ["pytest>=7", "waitress", "bcrypt", "argon2-cffi"] bcrypt = ["bcrypt"] argon2 = ["argon2-cffi"] ldap = ["ldap3"] +integ_test = ["pytest", "pytest-playwright"] [project.scripts] radicale = "radicale.__main__:run" @@ -79,13 +80,16 @@ deps = ["mypy==1.11.0"] commands = [["mypy", "--install-types", "--non-interactive", "."]] skip_install = true +[tool.pytest.ini_options] +norecursedirs = ["integ_tests"] + [tool.setuptools] platforms = ["Any"] include-package-data = false [tool.setuptools.packages.find] -exclude = ["*.tests"] # *.tests.*; tests.*; tests +exclude = ["*.tests", "integ_tests"] # *.tests.*; tests.*; tests namespaces = false [tool.setuptools.package-data] @@ -116,7 +120,7 @@ exclude = "(^|/)build($|/)" [tool.coverage.run] branch = true source = ["radicale"] -omit = ["tests/*", "*/tests/*"] +omit = ["tests/*", "*/tests/*", "integ_tests/*", "*/integ_tests/*"] [tool.coverage.report] # Regexes for lines to exclude from consideration