From 76abbf3115e00e89dd092077b57d07aa962f1b28 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 20:26:32 +0100 Subject: [PATCH 1/9] Formatted main.css --- radicale/web/internal_data/css/main.css | 166 +++++++++++++----------- 1 file changed, 88 insertions(+), 78 deletions(-) diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 8c4086f9..f81a5aaf 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -1,4 +1,4 @@ -body{ +body { background: #ffffff; color: #424247; font-family: sans-serif; @@ -13,11 +13,11 @@ body{ justify-content: space-around; } -main{ - width: 100%; +main { + width: 100%; } -.container{ +.container { height: auto; min-height: 450px; width: 350px; @@ -32,14 +32,14 @@ main{ margin: 0 auto; } -.container h1{ +.container h1 { margin: 0; width: 100%; text-align: center; color: #484848; } -.container h2{ +.container h2 { margin: 0; width: 100%; text-align: left; @@ -47,41 +47,37 @@ main{ font-size: 1.5em; } -#loginscene .infcloudlink{ +#loginscene .infcloudlink { margin: 0; width: 100%; text-align: center; color: #484848; } -#loginscene .infcloudlink-hidden{ +#loginscene .infcloudlink-hidden { visibility: hidden; } -#loginscene input{ -} - - -#loginscene .logocontainer{ +#loginscene .logocontainer { width: 100%; text-align: center; } -#loginscene .logocontainer img{ +#loginscene .logocontainer img { width: 75px; } -#loginscene h1{ +#loginscene h1 { text-align: center; font-family: sans-serif; font-weight: normal; } -#loginscene button{ +#loginscene button { float: right; } -#loadingscene{ +#loadingscene { width: 100%; height: 100%; background: rgb(237 237 237); @@ -97,12 +93,12 @@ main{ z-index: 999; } -#loadingscene h2{ +#loadingscene h2 { font-size: 2em; font-weight: bold; } -#logoutview{ +#logoutview { width: 100%; display: block; background: white; @@ -113,12 +109,12 @@ main{ position: fixed; } -#logoutview span{ +#logoutview span { width: calc(100% - 60px); display: inline-block; } -#logoutview a{ +#logoutview a { color: white; text-decoration: none; padding: 3px 10px; @@ -126,17 +122,17 @@ main{ border-radius: 4px; } -#logoutview a[data-name=logout]{ +#logoutview a[data-name=logout] { right: 25px; float: right; } -#logoutview a[data-name=refresh]{ +#logoutview a[data-name=refresh] { left: 25px; float: left; } -#collectionsscene{ +#collectionsscene { display: flex; flex-direction: row; flex-wrap: wrap; @@ -147,7 +143,7 @@ main{ height: 100vh; } -#collectionsscene article{ +#collectionsscene article { width: 275px; background: rgb(250, 250, 250); border-radius: 8px; @@ -161,23 +157,23 @@ main{ overflow: hidden; } -#collectionsscene article .colorbar{ +#collectionsscene article .colorbar { width: 500%; height: 15px; margin: 0px -100%; background: #000000; } -#collectionsscene article .title{ +#collectionsscene article .title { width: 100%; text-align: center; font-size: 1.5em; display: block; padding: 10px 0; - margin: 0; + margin: 0; } -#collectionsscene article small{ +#collectionsscene article small { font-size: 15px; float: left; font-weight: normal; @@ -187,21 +183,21 @@ main{ text-align: center; } -#collectionsscene article input[type=text]{ +#collectionsscene article input[type=text] { margin-bottom: 0 !important; } -#collectionsscene article p{ +#collectionsscene article p { font-size: 1em; max-height: 130px; overflow: overlay; } -#collectionsscene article:hover ul{ +#collectionsscene article:hover ul { visibility: visible; } -#collectionsscene ul{ +#collectionsscene ul { visibility: hidden; display: flex; justify-content: space-evenly; @@ -210,12 +206,12 @@ main{ padding: 0; } -#collectionsscene li{ +#collectionsscene li { list-style: none; display: block; } -#collectionsscene li a{ +#collectionsscene li a { text-decoration: none !important; padding: 5px; float: left; @@ -225,41 +221,41 @@ main{ text-align: center; } -#collectionsscene article small[data-name=contentcount]{ +#collectionsscene article small[data-name=contentcount] { font-weight: bold; font-style: normal; } -#editcollectionscene p span{ - word-wrap:break-word; +#editcollectionscene p span { + word-wrap: break-word; font-weight: bold; color: #4e9a06; } -#deletecollectionscene p span{ - word-wrap:break-word; +#deletecollectionscene p span { + word-wrap: break-word; font-weight: bold; color: #a40000; } -#uploadcollectionscene ul{ +#uploadcollectionscene ul { margin: 10px -30px; max-height: 600px; overflow-y: scroll; } -#uploadcollectionscene li{ +#uploadcollectionscene li { border-bottom: 1px dashed #d5d5d5; margin-bottom: 10px; padding-bottom: 10px; } -#uploadcollectionscene div[data-name=pending]{ +#uploadcollectionscene div[data-name=pending] { width: 100%; text-align: center; } -#uploadcollectionscene .successmessage{ +#uploadcollectionscene .successmessage { color: #4e9a06; width: 100%; text-align: center; @@ -267,13 +263,13 @@ main{ margin-top: 15px; } -.deleteconfirmationtxt{ +.deleteconfirmationtxt { text-align: center; font-size: 1em; font-weight: bold; } -.fabcontainer{ +.fabcontainer { display: flex; flex-direction: column-reverse; position: fixed; @@ -281,7 +277,7 @@ main{ right: 0; } -.fabcontainer a{ +.fabcontainer a { width: 30px; height: 30px; text-decoration: none; @@ -300,29 +296,29 @@ main{ box-shadow: 2px 2px 7px #000000d6; } -.title{ +.title { word-wrap: break-word; font-weight: bold; } -.icon{ +.icon { width: 100%; height: 100%; filter: invert(1); } -.small_icon{ +.small_icon { width: 1em; height: 1em; filter: invert(1); } -.med_icon{ +.med_icon { width: 1.5em; height: 1.5em; } -.badge_icon{ +.badge_icon { width: 1em; height: 1em; position: absolute; @@ -334,30 +330,30 @@ main{ } -.smalltext{ +.smalltext { font-size: 75% !important; } -.error{ +.error { width: 100%; display: block; text-align: center; - color: rgb(217,48,37); + color: rgb(217, 48, 37); font-family: sans-serif; clear: both; padding-top: 15px; } -img.loading{ +img.loading { width: 150px; height: 150px; } -.error::before{ +.error::before { content: "!"; height: 1em; color: white; - background: rgb(217,48,37); + background: rgb(217, 48, 37); font-weight: bold; border-radius: 100%; display: inline-block; @@ -367,7 +363,7 @@ img.loading{ text-align: center; } -button{ +button { font-size: 1em; padding: 7px 21px; color: white; @@ -379,7 +375,8 @@ button{ position: relative; } -input, select{ +input, +select { width: 100%; height: 3em; border-style: solid; @@ -396,56 +393,69 @@ input.inline { margin-bottom: 0 !important; } -input[type=text], input[type=password]{ +input[type=text], +input[type=password] { width: calc(100% - 30px); } -input:active, input:focus, input:focus-visible{ +input:active, +input:focus, +input:focus-visible { border-color: #2494fe !important; border-width: 1px !important; } -p.red, span.red{ +p.red, +span.red { color: #b50202; } -button.red, a.red{ +button.red, +a.red { background: #b50202; border: 1px solid #a40000; } -button.red:hover, a.red:hover{ +button.red:hover, +a.red:hover { background: #a40000; } -button.red:active, a.red:active{ +button.red:active, +a.red:active { background: #8f0000; } -button.green, a.green{ +button.green, +a.green { background: #4e9a06; border: 1px solid #377200; } -button.green:hover, a.green:hover{ +button.green:hover, +a.green:hover { background: #377200; } -button.green:active, a.green:active{ +button.green:active, +a.green:active { background: #285200; } -button.blue, a.blue{ +button.blue, +a.blue { background: #2494fe; border: 1px solid #055fb5; } -button.blue:hover, a.blue:hover{ +button.blue:hover, +a.blue:hover { background: #1578d6; cursor: pointer !important; } -button.blue:active, a.blue:active{ +button.blue:active, +a.blue:active { background: #055fb5; cursor: pointer !important; } @@ -457,25 +467,25 @@ button.inline { } @media only screen and (max-width: 600px) { - #collectionsscene{ + #collectionsscene { flex-direction: column !important; flex-wrap: nowrap; } - #collectionsscene article{ + #collectionsscene article { height: auto; min-height: 375px; } - .container{ + .container { max-width: 280px !important; } - #collectionsscene ul{ + #collectionsscene ul { visibility: visible !important; } - #logoutview span{ + #logoutview span { padding: 0 5px; } -} +} \ No newline at end of file From ef30d89452dab5727dbcdb64356de103c515e34b Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 20:19:54 +0100 Subject: [PATCH 2/9] 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 From 36dc9c2599bbaa6f964b41001c5d03a78423fd72 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 22:14:30 +0100 Subject: [PATCH 3/9] Implement showing share by map --- .../web/internal_data/ShareCollectionScene.js | 107 ++++++++++++------ radicale/web/internal_data/api.js | 28 +++++ radicale/web/internal_data/index.html | 27 ++++- 3 files changed, 126 insertions(+), 36 deletions(-) diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js index 419be838..268a1579 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -19,6 +19,7 @@ */ import { + delete_share_by_map, delete_share_by_token, reload_sharing_list, server_features, @@ -88,9 +89,14 @@ export class ShareCollectionScene { } } +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + */ function update_share_list(user, password, collection) { let share_rows = document.querySelectorAll( - "[data-name=sharetokenrowtemplate]", + "[data-name=sharetokenrowtemplate], [data-name=sharemaprowtemplate]", ); share_rows.forEach(function (row) { if (!row.classList.contains("hidden")) { @@ -103,47 +109,78 @@ function update_share_list(user, password, collection) { }); } +/** + * + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {Object} share + * @param {HTMLElement} template + * @param {string} delete_label + * @param {function(string, string, string, function():void):void} delete_action + */ +function add_share_row_node(user, password, collection, share, template, delete_label, delete_action) { + let pathortoken = share["PathOrToken"] || ""; + let node = /** @type {HTMLElement} */ (template.cloneNode(true)); + node.classList.remove("hidden"); + + /** @type {HTMLInputElement} */ let pathortoken_form = node.querySelector("[data-name=pathortoken]"); + if (pathortoken_form) { + pathortoken_form.value = pathortoken; + } + + let permissions = (share["Permissions"] || "").toLowerCase(); + if (permissions === "rw") { + node + .querySelector("[data-name=ro]") + .parentNode.removeChild(node.querySelector("[data-name=ro]")); + } else if (permissions === "r") { + node + .querySelector("[data-name=rw]") + .parentNode.removeChild(node.querySelector("[data-name=rw]")); + } else { + console.warn("Unknown permissions", permissions); + } + + /** @type {HTMLElement} */ let delete_btn = node.querySelector("[data-name=delete]"); + delete_btn.onclick = function () { + if (!confirm("Are you sure you want to delete " + delete_label + " " + pathortoken + "?")) { + return; + } + delete_action( + user, + password, + pathortoken, + function () { + update_share_list(user, password, collection); + }, + ); + }; + + template.parentNode.insertBefore(node, template); +} + +/** + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {Object} shares + */ function add_share_rows(user, password, collection, shares) { - /** @type {HTMLElement} */ let template = document.querySelector("[data-name=sharetokenrowtemplate]"); + /** @type {HTMLElement} */ let token_template = document.querySelector("[data-name=sharetokenrowtemplate]"); + /** @type {HTMLElement} */ let map_template = document.querySelector("[data-name=sharemaprowtemplate]"); shares.forEach(function (share) { let pathortoken = share["PathOrToken"] || ""; let pathmapped = share["PathMapped"] || ""; - if (( + if ( collection.href.includes(pathmapped) || collection.href.includes(pathortoken) - ) && (share["ShareType"] === "token")) { - let node = /** @type {HTMLElement} */ (template.cloneNode(true)); - node.classList.remove("hidden"); - /** @type {HTMLInputElement} */ let pathortoken_form = node.querySelector("[data-name=pathortoken]"); - pathortoken_form.value = pathortoken; - let permissions = (share["Permissions"] || "").toLowerCase(); - if (permissions === "rw") { - node - .querySelector("[data-name=ro]") - .parentNode.removeChild(node.querySelector("[data-name=ro]")); - } else if (permissions === "r") { - node - .querySelector("[data-name=rw]") - .parentNode.removeChild(node.querySelector("[data-name=rw]")); - } else { - console.warn("Unknown permissions", permissions); + ) { + if (share["ShareType"] === "token") { + add_share_row_node(user, password, collection, share, token_template, "share", delete_share_by_token); + } else if (share["ShareType"] === "map") { + add_share_row_node(user, password, collection, share, map_template, "map", delete_share_by_map); } - /** @type {HTMLElement} */ let delete_btn = node.querySelector("[data-name=delete]"); - delete_btn.onclick = function () { - if (!confirm("Are you sure you want to delete share " + pathortoken + "?")) { - return; - } - delete_share_by_token( - user, - password, - pathortoken, - function () { - update_share_list(user, password, collection); - }, - ); - }; - - template.parentNode.insertBefore(node, template); } }); } diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index d10f4dd6..ed45d205 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -513,3 +513,31 @@ export function delete_share_by_token( }, ); } + +/** + * @param {string} user + * @param {string} password + * @param {string} pathortoken + * @param {function():void} callback + */ +export function delete_share_by_map( + user, + password, + pathortoken, + callback, +) { + call_sharing_api( + user, + password, + "map/delete", + { PathOrToken: pathortoken }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to delete map " + pathortoken + ": " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index caf9a9a8..96d93bd2 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -153,7 +153,7 @@ + class="small_icon" alt="Delete"> RWRO @@ -169,6 +169,31 @@ +

By Map

+ + + + + + + + + +
From 23fcd2927ed1ae0ac9af14e134d3b503896b70eb Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 22:20:22 +0100 Subject: [PATCH 4/9] Add typedef for Share type --- .../web/internal_data/ShareCollectionScene.js | 8 +++---- radicale/web/internal_data/api.js | 22 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js index 268a1579..9f39f7d5 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -104,8 +104,8 @@ function update_share_list(user, password, collection) { } }); - reload_sharing_list(user, password, collection, function (response) { - add_share_rows(user, password, collection, response["Content"] || []); + reload_sharing_list(user, password, collection, function (shares) { + add_share_rows(user, password, collection, shares); }); } @@ -114,7 +114,7 @@ function update_share_list(user, password, collection) { * @param {string} user * @param {string} password * @param {Collection} collection - * @param {Object} share + * @param {import('./api.js').Share} share * @param {HTMLElement} template * @param {string} delete_label * @param {function(string, string, string, function():void):void} delete_action @@ -164,7 +164,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ * @param {string} user * @param {string} password * @param {Collection} collection - * @param {Object} shares + * @param {Array} shares */ function add_share_rows(user, password, collection, shares) { /** @type {HTMLElement} */ let token_template = document.querySelector("[data-name=sharetokenrowtemplate]"); diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index ed45d205..6ba48466 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -426,11 +426,28 @@ export function discover_server_features(user, password, callback) { ); } +/** + * @typedef {Object} Share + * @property {string} ShareType + * @property {string} PathOrToken + * @property {string} PathMapped + * @property {string} Owner + * @property {string} User + * @property {string} Permissions + * @property {boolean} EnabledByOwner + * @property {boolean} EnabledByUser + * @property {boolean} HiddenByOwner + * @property {boolean} HiddenByUser + * @property {number} TimestampCreated + * @property {number} TimestampUpdated + * @property {string} Properties + */ + /** * @param {string} user * @param {string} password * @param {Collection} collection - * @param {function(object):void} callback + * @param {function(Array):void} callback */ export function reload_sharing_list(user, password, collection, callback) { call_sharing_api( @@ -439,7 +456,8 @@ export function reload_sharing_list(user, password, collection, callback) { "all/list", { PathMapped: collection.href }, function (response) { - callback(JSON.parse(response)); + let parsed = JSON.parse(response); + callback(parsed["Content"] || []); }, ); } From 8393099b05ff82d48e5df8ebc8b8adfe3f1ffd98 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 22:41:33 +0100 Subject: [PATCH 5/9] Hide sharing tables depending on server features --- .../web/internal_data/ShareCollectionScene.js | 39 ++++++--- radicale/web/internal_data/api.js | 15 ++++ radicale/web/internal_data/index.html | 80 ++++++++++--------- 3 files changed, 86 insertions(+), 48 deletions(-) diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js index 9f39f7d5..34f11c63 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -44,7 +44,13 @@ export class ShareCollectionScene { /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); /** @type {HTMLElement} */ let share_by_token_btn = html_scene.querySelector( - "[data-name=sharebytoken]" + "button[data-name=sharebytoken]" + ); + /** @type {HTMLElement} */ let share_by_token_div = html_scene.querySelector( + "div[data-name=sharebytoken]" + ); + /** @type {HTMLElement} */ let share_by_map_div = html_scene.querySelector( + "div[data-name=sharebymap]" ); /** @type {HTMLElement} */ let title = html_scene.querySelector("[data-name=title]"); @@ -70,12 +76,27 @@ export class ShareCollectionScene { scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); cancel_btn.onclick = oncancel; - if (server_features["sharing"]["PermittedCreateCollectionByToken"]) { - share_by_token_btn.classList.remove("hidden"); - share_by_token_btn.onclick = onsharebytoken; + if (server_features.sharing && server_features.sharing.PermittedCreateCollectionByToken) { + if (share_by_token_btn) { + share_by_token_btn.classList.remove("hidden"); + share_by_token_btn.onclick = onsharebytoken; + } } else { - share_by_token_btn.classList.add("hidden"); + if (share_by_token_btn) share_by_token_btn.classList.add("hidden"); } + + if (server_features.sharing && server_features.sharing.FeatureEnabledCollectionByToken) { + if (share_by_token_div) share_by_token_div.classList.remove("hidden"); + } else { + if (share_by_token_div) share_by_token_div.classList.add("hidden"); + } + + if (server_features.sharing && server_features.sharing.FeatureEnabledCollectionByMap) { + if (share_by_map_div) share_by_map_div.classList.remove("hidden"); + } else { + if (share_by_map_div) share_by_map_div.classList.add("hidden"); + } + title.textContent = collection.displayname || collection.href; update_share_list(user, password, collection); }; @@ -186,11 +207,9 @@ function add_share_rows(user, password, collection, shares) { } export function maybe_enable_sharing_options() { - if (!server_features["sharing"]) return; - let map_is_enabled = - server_features["sharing"]["FeatureEnabledCollectionByMap"] || false; - let token_is_enabled = - server_features["sharing"]["FeatureEnabledCollectionByToken"] || false; + if (!server_features.sharing) return; + let map_is_enabled = server_features.sharing.FeatureEnabledCollectionByMap || false; + let token_is_enabled = server_features.sharing.FeatureEnabledCollectionByToken || false; if (map_is_enabled || token_is_enabled) { let share_options = document.querySelectorAll("[data-name=shareoption]"); for (let i = 0; i < share_options.length; i++) { diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 6ba48466..878ec396 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -21,7 +21,22 @@ import { COLOR_RE, ROOT_PATH, SERVER } from "./constants.js"; import { Collection, CollectionType } from "./models.js"; import { escape_xml } from "./utils.js"; +/** + * @typedef {Object} SharingFeatures + * @property {number} [ApiVersion] + * @property {string} [Status] + * @property {boolean} [FeatureEnabledCollectionByMap] + * @property {boolean} [PermittedCreateCollectionByMap] + * @property {boolean} [FeatureEnabledCollectionByToken] + * @property {boolean} [PermittedCreateCollectionByToken] + */ +/** + * @typedef {Object} ServerFeatures + * @property {SharingFeatures} [sharing] + */ + +/** @type {ServerFeatures} */ export let server_features = {}; /** diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 96d93bd2..0d1c6c82 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -147,42 +147,45 @@

Sharing

Manage sharing for collection title

-

By Token

- - - - - - - - - - - - - -
-
-

By Map

- - - - - - - - - -
+ + +
From 25e571ad63c62a1258ff44a442b40acce8775751 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 7 Mar 2026 23:18:31 +0100 Subject: [PATCH 6/9] Add functionality to add a new share by map --- integ_tests/common.py | 2 + integ_tests/test_sharing.py | 43 +++++++++++++++++ radicale/web/internal_data/NewShareScene.js | 28 +++++++++-- .../web/internal_data/ShareCollectionScene.js | 21 +++++++- radicale/web/internal_data/api.js | 48 +++++++++++++++++++ radicale/web/internal_data/css/main.css | 5 ++ radicale/web/internal_data/index.html | 26 ++++++---- 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/integ_tests/common.py b/integ_tests/common.py index aedc13be..d55af761 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -45,6 +45,8 @@ permit_create_map = true with open(user_path, "w") as f: f.write( """admin:adminpassword +max:maxpassword + """ ) diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 9a97f9de..4cb26b88 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -49,3 +49,46 @@ 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) + + +def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(0) + + page.click('button[data-name="sharebymap"]') + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("1234") + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RO']") + ).to_be_visible() + page.once("dialog", lambda dialog: dialog.accept()) + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(0) + page.click('button[data-name="sharebymap"]') + page.click('label[for="newshare_attr_permissions_rw"]') + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("1234") + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RW']") + ).to_be_visible() + page.once("dialog", lambda dialog: dialog.accept()) + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(0) diff --git a/radicale/web/internal_data/NewShareScene.js b/radicale/web/internal_data/NewShareScene.js index e0f48afc..b9819a21 100644 --- a/radicale/web/internal_data/NewShareScene.js +++ b/radicale/web/internal_data/NewShareScene.js @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -import { add_share_by_token } from "./api.js"; +import { add_share_by_map, add_share_by_token } from "./api.js"; import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; /** @@ -29,11 +29,15 @@ export class NewShareScene { * @param {string} user * @param {string} password * @param {string} pathMapped + * @param {string} shareType * @param {function():void} onclose */ - constructor(user, password, pathMapped, onclose) { + constructor(user, password, pathMapped, shareType, onclose) { /** @type {HTMLElement} */ let html_scene = document.getElementById("newshare"); /** @type {HTMLFormElement} */ let form = html_scene.querySelector("form"); + /** @type {HTMLElement} */ let sharemapfields = html_scene.querySelector("[data-name=sharemapfields]"); + /** @type {HTMLInputElement} */ let shareuser_input = html_scene.querySelector("[data-name=shareuser]"); + /** @type {HTMLInputElement} */ let sharehref_input = html_scene.querySelector("[data-name=sharehref]"); /** @type {HTMLInputElement} */ let enabled_checkbox = html_scene.querySelector("[data-name=enabled]"); /** @type {HTMLInputElement} */ let hidden_checkbox = html_scene.querySelector("[data-name=hidden]"); let permissions_ro_radio = /** @type {HTMLInputElement} */ (document.getElementById("newshare_attr_permissions_ro")); @@ -62,12 +66,20 @@ export class NewShareScene { 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 () { + let callback = function () { if (scene_index !== null) { pop_scene(scene_index - 1); } if (onclose) onclose(); - }); + }; + + if (shareType === "map") { + let share_user = shareuser_input.value; + let href = sharehref_input.value; + add_share_by_map(user, password, pathMapped, permissions, enabled, hidden, properties, share_user, href, callback); + } else { + add_share_by_token(user, password, pathMapped, permissions, enabled, hidden, properties, callback); + } } catch (err) { console.error(err); } @@ -81,6 +93,14 @@ export class NewShareScene { cancel_btn.onclick = oncancel; form.onsubmit = onsubmit; + if (shareType === "map") { + sharemapfields.classList.remove("hidden"); + } else { + sharemapfields.classList.add("hidden"); + } + + shareuser_input.value = ""; + sharehref_input.value = ""; enabled_checkbox.checked = true; hidden_checkbox.checked = false; permissions_ro_radio.checked = true; diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js index 34f11c63..834c7a41 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -45,6 +45,9 @@ export class ShareCollectionScene { /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); /** @type {HTMLElement} */ let share_by_token_btn = html_scene.querySelector( "button[data-name=sharebytoken]" + ); + /** @type {HTMLElement} */ let share_by_map_btn = html_scene.querySelector( + "button[data-name=sharebymap]" ); /** @type {HTMLElement} */ let share_by_token_div = html_scene.querySelector( "div[data-name=sharebytoken]" @@ -65,7 +68,14 @@ export class ShareCollectionScene { } function onsharebytoken() { - let new_share_scene = new NewShareScene(user, password, collection.href, function () { + let new_share_scene = new NewShareScene(user, password, collection.href, "token", function () { + update_share_list(user, password, collection); + }); + push_scene(new_share_scene, false); + } + + function onsharebymap() { + let new_share_scene = new NewShareScene(user, password, collection.href, "map", function () { update_share_list(user, password, collection); }); push_scene(new_share_scene, false); @@ -91,6 +101,15 @@ export class ShareCollectionScene { if (share_by_token_div) share_by_token_div.classList.add("hidden"); } + if (server_features.sharing && server_features.sharing.PermittedCreateCollectionByMap) { + if (share_by_map_btn) { + share_by_map_btn.classList.remove("hidden"); + share_by_map_btn.onclick = onsharebymap; + } + } else { + if (share_by_map_btn) share_by_map_btn.classList.add("hidden"); + } + if (server_features.sharing && server_features.sharing.FeatureEnabledCollectionByMap) { if (share_by_map_div) share_by_map_div.classList.remove("hidden"); } else { diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 878ec396..8b1f1cf9 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -519,6 +519,54 @@ export function add_share_by_token( ); } +/** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {string} permissions + * @param {boolean} enabled + * @param {boolean} hidden + * @param {string} properties + * @param {string} share_user + * @param {string} href + * @param {function():void} callback + */ +export function add_share_by_map( + user, + password, + pathMapped, + permissions, + enabled, + hidden, + properties, + share_user, + href, + callback, +) { + call_sharing_api( + user, + password, + "map/create", + { + PathMapped: pathMapped, + Permissions: permissions, + Enabled: enabled, + Hidden: hidden, + Properties: properties, + User: share_user, + PathOrToken: "/" + share_user + "/" + href, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create share map: " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} + /** * @param {string} user * @param {string} password diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 5dc06481..7ba2e92b 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -263,6 +263,11 @@ main { margin-top: 15px; } +#newshare input[type=text] { + margin-bottom: 0 !important; +} + + .deleteconfirmationtxt { text-align: center; font-size: 1em; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 0d1c6c82..43fe4ac0 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -185,16 +185,15 @@ - + + + + + + + @@ -206,6 +205,13 @@