From ef30d89452dab5727dbcdb64356de103c515e34b Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 20:19:54 +0100 Subject: [PATCH] Change new map to use dialog This allows setting additional properties --- integ_tests/test_sharing.py | 7 +- .../web/internal_data/CollectionsScene.js | 19 +- radicale/web/internal_data/NewShareScene.js | 101 ++++ .../web/internal_data/ShareCollectionScene.js | 45 +- radicale/web/internal_data/api.js | 373 ++++++++------ radicale/web/internal_data/css/main.css | 10 +- radicale/web/internal_data/index.html | 487 ++++++++++-------- 7 files changed, 610 insertions(+), 432 deletions(-) create mode 100644 radicale/web/internal_data/NewShareScene.js diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 9c28486c..9a97f9de 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -22,7 +22,8 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(0) - page.click('button[data-name="sharebytoken_ro"]') + page.click('button[data-name="sharebytoken"]') + page.click('#newshare button[data-name="submit"]') expect( page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(1) @@ -34,7 +35,9 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non expect( page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(0) - page.click('button[data-name="sharebytoken_rw"]') + page.click('button[data-name="sharebytoken"]') + page.click('label[for="newshare_attr_permissions_rw"]') + page.click('#newshare button[data-name="submit"]') expect( page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") ).to_have_count(1) diff --git a/radicale/web/internal_data/CollectionsScene.js b/radicale/web/internal_data/CollectionsScene.js index bfbea540..d4a5284a 100644 --- a/radicale/web/internal_data/CollectionsScene.js +++ b/radicale/web/internal_data/CollectionsScene.js @@ -21,7 +21,7 @@ import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; import { LoadingScene } from "./LoadingScene.js"; -import { CreateShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js"; +import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; import { discover_server_features, get_collections } from "./api.js"; import { SERVER } from "./constants.js"; @@ -30,15 +30,16 @@ import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; import { bytesToHumanReadable } from "./utils.js"; /** - * @constructor * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection The principal collection. - * @param {function(string):void} onerror Called when an error occurs, before the - * scene is popped. */ export class CollectionsScene { + /** + * @param {string} user + * @param {string} password + * @param {Collection} collection The collection to show sharing options for. + * @param {function(string):void} onerror Called when an error occurs, before the + * scene is popped. + */ constructor(user, password, collection, onerror) { /** @type {HTMLElement} */ let html_scene = document.getElementById("collectionsscene"); /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=collectiontemplate]"); @@ -82,7 +83,7 @@ export class CollectionsScene { function onshare(collection) { try { - let share_collection_scene = new CreateShareCollectionScene(user, password, collection); + let share_collection_scene = new ShareCollectionScene(user, password, collection); push_scene(share_collection_scene, false); } catch (err) { console.error(err); @@ -189,7 +190,7 @@ export class CollectionsScene { upload_btn.onclick = onupload; if (collections === null) { update(); - discover_server_features(user, password, maybe_enable_sharing_options); + discover_server_features(user, password, maybe_enable_sharing_options); } else { // from update loading scene show_collections(collections); diff --git a/radicale/web/internal_data/NewShareScene.js b/radicale/web/internal_data/NewShareScene.js new file mode 100644 index 00000000..e0f48afc --- /dev/null +++ b/radicale/web/internal_data/NewShareScene.js @@ -0,0 +1,101 @@ +/** + * 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 { add_share_by_token } from "./api.js"; +import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; + +/** + * @implements {Scene} + */ +export class NewShareScene { + /** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {function():void} onclose + */ + constructor(user, password, pathMapped, onclose) { + /** @type {HTMLElement} */ let html_scene = document.getElementById("newshare"); + /** @type {HTMLFormElement} */ let form = html_scene.querySelector("form"); + /** @type {HTMLInputElement} */ let enabled_checkbox = html_scene.querySelector("[data-name=enabled]"); + /** @type {HTMLInputElement} */ let hidden_checkbox = html_scene.querySelector("[data-name=hidden]"); + let permissions_ro_radio = /** @type {HTMLInputElement} */ (document.getElementById("newshare_attr_permissions_ro")); + let permissions_rw_radio = /** @type {HTMLInputElement} */ (document.getElementById("newshare_attr_permissions_rw")); + /** @type {HTMLInputElement} */ let properties_input = html_scene.querySelector("[data-name=properties]"); + /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + + /** @type {?number} */ let scene_index = null; + + function oncancel() { + try { + if (scene_index !== null) { + pop_scene(scene_index - 1); + } + if (onclose) onclose(); + } catch (err) { + console.error(err); + } + return false; + } + + function onsubmit() { + try { + let enabled = enabled_checkbox.checked; + let hidden = hidden_checkbox.checked; + let permissions = permissions_rw_radio.checked ? "rw" : "r"; + let properties = properties_input.value; + + add_share_by_token(user, password, pathMapped, permissions, enabled, hidden, properties, function () { + if (scene_index !== null) { + pop_scene(scene_index - 1); + } + if (onclose) onclose(); + }); + } catch (err) { + console.error(err); + } + return false; + } + + this.show = function () { + this.release(); + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + cancel_btn.onclick = oncancel; + form.onsubmit = onsubmit; + + enabled_checkbox.checked = true; + hidden_checkbox.checked = false; + permissions_ro_radio.checked = true; + permissions_rw_radio.checked = false; + properties_input.value = ""; + }; + + this.hide = function () { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + form.onsubmit = null; + }; + + this.release = function () { + scene_index = null; + }; + } +} diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js index 399009fc..419be838 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -19,32 +19,31 @@ */ import { - add_share_by_token, delete_share_by_token, reload_sharing_list, server_features, } from "./api.js"; import { Collection } from "./models.js"; -import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { NewShareScene } from "./NewShareScene.js"; +import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; /** * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection The collection on which to edit sharing setting. Must exist. */ -export class CreateShareCollectionScene { +export class ShareCollectionScene { + /** + * @param {string} user + * @param {string} password + * @param {Collection} collection The collection on which to edit sharing setting. Must exist. + */ constructor(user, password, collection) { - /** @type {?number} */ let scene_index = null; + /** @type {?number} */ let scene_index = null; let html_scene = document.getElementById("sharecollectionscene"); - /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); - /** @type {HTMLElement} */ let share_by_token_btn_ro = html_scene.querySelector( - "[data-name=sharebytoken_ro]" - ); - /** @type {HTMLElement} */ let share_by_token_btn_rw = html_scene.querySelector( - "[data-name=sharebytoken_rw]" + /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {HTMLElement} */ let share_by_token_btn = html_scene.querySelector( + "[data-name=sharebytoken]" ); /** @type {HTMLElement} */ let title = html_scene.querySelector("[data-name=title]"); @@ -58,16 +57,11 @@ export class CreateShareCollectionScene { return false; } - function onsharebytoken_rw() { - add_share_by_token(user, password, collection, "rw", function () { - update_share_list(user, password, collection); - }); - } - - function onsharebytoken_ro() { - add_share_by_token(user, password, collection, "r", function () { + function onsharebytoken() { + let new_share_scene = new NewShareScene(user, password, collection.href, function () { update_share_list(user, password, collection); }); + push_scene(new_share_scene, false); } this.show = function () { @@ -76,13 +70,10 @@ export class CreateShareCollectionScene { html_scene.classList.remove("hidden"); cancel_btn.onclick = oncancel; if (server_features["sharing"]["PermittedCreateCollectionByToken"]) { - share_by_token_btn_ro.classList.remove("hidden"); - share_by_token_btn_rw.classList.remove("hidden"); - share_by_token_btn_ro.onclick = onsharebytoken_ro; - share_by_token_btn_rw.onclick = onsharebytoken_rw; + share_by_token_btn.classList.remove("hidden"); + share_by_token_btn.onclick = onsharebytoken; } else { - share_by_token_btn_ro.classList.add("hidden"); - share_by_token_btn_rw.classList.add("hidden"); + share_by_token_btn.classList.add("hidden"); } title.textContent = collection.displayname || collection.href; update_share_list(user, password, collection); diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 46d52ff1..d10f4dd6 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -18,8 +18,8 @@ * along with this program. If not, see . */ +import { COLOR_RE, ROOT_PATH, SERVER } from "./constants.js"; import { Collection, CollectionType } from "./models.js"; -import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js"; import { escape_xml } from "./utils.js"; export let server_features = {}; @@ -34,7 +34,7 @@ export let server_features = {}; export function get_principal(user, password, callback) { let request = new XMLHttpRequest(); request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); - request.onreadystatechange = function() { + request.onreadystatechange = function () { if (request.readyState !== 4) { return; } @@ -59,12 +59,12 @@ export function get_principal(user, password, callback) { } }; request.send('' + - '' + - '' + - '' + - '' + - '' + - ''); + '' + + '' + + '' + + '' + + '' + + ''); return request; } @@ -80,7 +80,7 @@ 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() { + request.onreadystatechange = function () { if (request.readyState !== 4) { return; } @@ -155,7 +155,7 @@ export function get_collections(user, password, collection, callback) { collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); } } - collections.sort(function(a, b) { + collections.sort(function (a, b) { /** @type {string} */ let ca = a.displayname || a.href; /** @type {string} */ let cb = b.displayname || b.href; return ca.localeCompare(cb); @@ -166,28 +166,28 @@ export function get_collections(user, password, collection, callback) { } }; request.send('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''); + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''); return request; } @@ -202,7 +202,7 @@ export function get_collections(user, password, collection, callback) { 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() { + request.onreadystatechange = function () { if (request.readyState !== 4) { return; } @@ -227,7 +227,7 @@ export function upload_collection(user, password, collection_href, file, callbac 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() { + request.onreadystatechange = function () { if (request.readyState !== 4) { return; } @@ -252,7 +252,7 @@ export function delete_collection(user, password, collection, callback) { 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() { + request.onreadystatechange = function () { if (request.readyState !== 4) { return; } @@ -295,30 +295,30 @@ function create_edit_collection(user, password, collection, create, callback) { } 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 ? '' : '') + - '' + - ''): '') + - ''); + '<' + 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; } @@ -345,128 +345,171 @@ export function edit_collection(user, password, collection, callback) { } /* Sharing API */ +/** + * @param {string} user + * @param {string} password + * @param {string} path + * @param {object} body + * @param {function(string):void} on_success + * @param {function():void} on_not_found + * @param {function(string):void} on_error + * @returns {XMLHttpRequest} + */ function call_sharing_api( - user, - password, - path, - body, - on_success, - on_not_found = null, - on_error = null, -) { - let request = new XMLHttpRequest(); - request.open( - "POST", - SERVER + ROOT_PATH + ".sharing/v1/" + path, - true, user, - encodeURIComponent(password), - ); - request.onreadystatechange = function () { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - on_success(request.responseText); - } else if (request.status === 404) { - if (on_not_found) { - on_not_found(); - } else if (on_error) { - on_error("Not found"); - } else { - console.error("Not found"); - } - } else { - if (on_error) { - on_error(request.status + " " + request.statusText); - } else { - console.error(request.status + " " + request.statusText); - } - } - }; - request.setRequestHeader("Accept", "application/json"); - request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); - request.send(body ? JSON.stringify(body) : null); - return request; + password, + path, + body, + on_success, + on_not_found = null, + on_error = null, +) { + let request = new XMLHttpRequest(); + request.open( + "POST", + SERVER + ROOT_PATH + ".sharing/v1/" + path, + true, + user, + encodeURIComponent(password), + ); + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + on_success(request.responseText); + } else if (request.status === 404) { + if (on_not_found) { + on_not_found(); + } else if (on_error) { + on_error("Not found"); + } else { + console.error("Not found"); + } + } else { + if (on_error) { + on_error(request.status + " " + request.statusText); + } else { + console.error(request.status + " " + request.statusText); + } + } + }; + request.setRequestHeader("Accept", "application/json"); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(body ? JSON.stringify(body) : null); + return request; } +/** + * @param {string} user + * @param {string} password + * @param {function():void} callback + */ export function discover_server_features(user, password, callback) { - call_sharing_api( - user, - password, - "all/info", - {}, - function (response) { - server_features["sharing"] = JSON.parse(response); - callback(); - }, - function () { - // sharing is disabled on the server - server_features["sharing"] = {}; - callback(); - }, - function (error) { - console.error("Failed to discover sharing features: " + error); - }, - ); + call_sharing_api( + user, + password, + "all/info", + {}, + function (response) { + server_features["sharing"] = JSON.parse(response); + callback(); + }, + function () { + // sharing is disabled on the server + server_features["sharing"] = {}; + callback(); + }, + function (error) { + console.error("Failed to discover sharing features: " + error); + }, + ); } +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {function(object):void} callback + */ export function reload_sharing_list(user, password, collection, callback) { - call_sharing_api( - user, - password, - "all/list", - { PathMapped: collection.href }, - function (response) { - callback(JSON.parse(response)); - }, - ); + call_sharing_api( + user, + password, + "all/list", + { PathMapped: collection.href }, + function (response) { + callback(JSON.parse(response)); + }, + ); } +/** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {string} permissions + * @param {boolean} enabled + * @param {boolean} hidden + * @param {string} properties + * @param {function():void} callback + */ export function add_share_by_token( - user, - password, - collection, - permissions, - callback, -) { - call_sharing_api( user, password, - "token/create", - { - PathMapped: collection.href, - Permissions: permissions, - }, - function (response) { - let json_response = JSON.parse(response); - if (json_response["Status"] !== "success") { - console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error")); - } else { - callback(); - } - }, - ); + pathMapped, + permissions, + enabled, + hidden, + properties, + callback, +) { + call_sharing_api( + user, + password, + "token/create", + { + PathMapped: pathMapped, + Permissions: permissions, + Enabled: enabled, + Hidden: hidden, + Properties: properties, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); } +/** + * @param {string} user + * @param {string} password + * @param {string} token + * @param {function():void} callback + */ export function delete_share_by_token( - user, - password, - token, - callback, -) { - call_sharing_api( user, password, - "token/delete", - { PathOrToken: token }, - function (response) { - let json_response = JSON.parse(response); - if (json_response["Status"] !== "success") { - console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error")); - } else { - callback(); - } - }, - ); + token, + callback, +) { + call_sharing_api( + user, + password, + "token/delete", + { PathOrToken: token }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); } diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index f81a5aaf..5dc06481 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -44,7 +44,7 @@ main { width: 100%; text-align: left; color: #484848; - font-size: 1.5em; + font-size: 14pt; } #loginscene .infcloudlink { @@ -405,6 +405,14 @@ input:focus-visible { border-width: 1px !important; } +input[type=radio], +input[type=checkbox] { + width: auto; + height: 1.5em; + padding: 0; + margin: 2px; +} + p.red, span.red { color: #b50202; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 8689fc38..caf9a9a8 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -6,244 +6,275 @@ * Copyright © 2024-2025 Peter Bieringer --> - - - - - Radicale Web Interface - - - - - - - + + + + + Radicale Web Interface + + + + + -
-
- Loading... -

Loading

-

Please wait...

- -
+ + - - - - - - - - - - - -
- - + + + + + + + + \ No newline at end of file