From e3242fcc2ca63cda930122f0d9aa4b3f510162bd Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 18 Mar 2026 23:30:03 +0100 Subject: [PATCH] Add support for Bday sharing --- integ_tests/common.py | 2 + integ_tests/test_sharing.py | 104 ++++++++++++++++ radicale/web/internal_data/index.html | 28 +++++ radicale/web/internal_data/js/api/sharing.js | 116 +++++++++++++++++- .../js/scenes/CollectionsScene.js | 15 ++- .../js/scenes/CreateEditShareScene.js | 50 ++++++-- .../js/scenes/IncomingSharingScene.js | 4 +- .../js/scenes/ShareCollectionScene.js | 49 +++++++- 8 files changed, 346 insertions(+), 22 deletions(-) diff --git a/integ_tests/common.py b/integ_tests/common.py index 8a7b503b..ca1527f3 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -60,6 +60,8 @@ collection_by_token = true permit_create_token = true permit_create_map = true permit_properties_overlay = true +collection_by_bday = true +permit_create_bday = true """ ) diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 4aaf32cc..48c37f9b 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -449,3 +449,107 @@ def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None: page.click('#incomingsharingscene button[data-name="cancel"]') expect(page.locator("#incomingsharingscene")).to_be_hidden() + + +def test_create_and_delete_share_by_bday(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + # create collection of type ADDRESSBOOK for bday (bday only works with ADDRESSBOOK) + page.click('a[data-name="new"]') + page.locator('#createcollectionscene select[data-name="type"]').select_option( + "ADDRESSBOOK" + ) + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "Addressbook For Bday" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator("tr[data-name='sharebdayrowtemplate']:not(.hidden)") + ).to_have_count(0) + + page.click('button[data-name="sharebybday"]') + + # verify user is auto-filled with current user (admin) + expect(page.locator('input[data-name="shareuser"]')).to_have_value("admin") + page.locator('input[data-name="sharehref"]').fill("bdaymapped") + + # verify that the permissions section is hidden entirely + expect(page.locator("input#newshare_attr_permissions_ro")).to_be_hidden() + expect(page.locator("input#newshare_attr_permissions_rw")).to_be_hidden() + + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharebdayrowtemplate']:not(.hidden)") + ).to_have_count(1) + + # verify no permissions pill in the bday row + expect( + page.locator( + "tr[data-name='sharebdayrowtemplate']:not(.hidden) span[data-name='ro']" + ) + ).to_have_count(0) + + # Close the share scene and verify the virtual bday calendar is now in the collections list + page.click('#sharecollectionscene button[data-name="cancel"]') + expect(page.locator("#sharecollectionscene")).to_be_hidden() + + # The virtual calendar (bdaymapped) should appear as its own article + # after the cache was invalidated following the self-share + expect(page.locator("article:not(.hidden)")).to_have_count(2) + + # Delete the bday share by re-opening the share scene + page.hover("article:not(.hidden) >> nth=0") + page.click('article:not(.hidden) >> nth=0 >> a[data-name="share"]', force=True) + page.click( + "tr[data-name='sharebdayrowtemplate']:not(.hidden) button[data-name='delete']", + strict=True, + ) + page.click('#deleteconfirmationscene button[data-name="delete"]') + expect( + page.locator("tr[data-name='sharebdayrowtemplate']:not(.hidden)") + ).to_have_count(0) + + +def test_bday_section_hidden_for_calendar(page: Page, radicale_server: str) -> None: + """Verify the bday calendar section is hidden for CALENDAR collections.""" + login(page, radicale_server) + + page.click('a[data-name="new"]') + page.locator('#createcollectionscene select[data-name="type"]').select_option( + "CALENDAR" + ) + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "My Calendar" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect(page.locator("#sharecollectionscene")).to_be_visible() + expect(page.locator("div[data-name='sharebybday']")).to_be_hidden() + page.click('#sharecollectionscene button[data-name="cancel"]') + + +def test_bday_section_visible_for_addressbook(page: Page, radicale_server: str) -> None: + """Verify the bday calendar section is visible for ADDRESSBOOK collections.""" + login(page, radicale_server) + + page.click('a[data-name="new"]') + page.locator('#createcollectionscene select[data-name="type"]').select_option( + "ADDRESSBOOK" + ) + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "My Addressbook" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect(page.locator("#sharecollectionscene")).to_be_visible() + expect(page.locator("div[data-name='sharebybday']")).to_be_visible() + page.click('#sharecollectionscene button[data-name="cancel"]') diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index e0a4153f..bcaf868b 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -70,6 +70,7 @@

Title

+ Address book Calendar, journal and tasks Calendar and journal @@ -200,6 +201,33 @@ +
+
+ shared as bday calendar + + + + + + + + + + + +
+ +
+
+
diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index a727362d..be118039 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -27,6 +27,8 @@ import { CollectionType } from "../models/collection.js"; * @property {boolean} [PermittedCreateCollectionByMap] * @property {boolean} [FeatureEnabledCollectionByToken] * @property {boolean} [PermittedCreateCollectionByToken] + * @property {boolean} [FeatureEnabledCollectionByBday] + * @property {boolean} [PermittedCreateCollectionByBday] */ /** @@ -433,7 +435,7 @@ export function update_incoming_share( call_sharing_api( user, password, - "map/update", + share.ShareType + "/update", { PathOrToken: share.PathOrToken, Enabled: share.EnabledByUser, @@ -453,3 +455,115 @@ export function update_incoming_share( } ); } + +/** + * @param {string} user + * @param {string} password + * @param {Share} share + * @param {function(?string):void} callback + */ +export function add_share_by_bday( + user, + password, + share, + callback, +) { + call_sharing_api( + user, + password, + "bday/create", + { + PathMapped: share.PathMapped, + Permissions: share.Permissions, + Enabled: share.EnabledByOwner, + Hidden: share.HiddenByOwner, + Properties: share.Properties, + User: share.User, + PathOrToken: share.PathOrToken, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} + +/** + * @param {string} user + * @param {string} password + * @param {Share} share + * @param {function(?string):void} callback + */ +export function delete_share_by_bday( + user, + password, + share, + callback, +) { + call_sharing_api( + user, + password, + "bday/delete", + { PathOrToken: share.PathOrToken }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} + +/** + * @param {string} user + * @param {string} password + * @param {Share} share + * @param {function(?string):void} callback + */ +export function update_share_by_bday( + user, + password, + share, + callback, +) { + call_sharing_api( + user, + password, + "bday/update", + { + PathOrToken: share.PathOrToken, + PathMapped: share.PathMapped, + User: share.User, + Permissions: share.Permissions, + Enabled: share.EnabledByOwner, + Hidden: share.HiddenByOwner, + Properties: share.Properties, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index 5b1279da..2c5c8f58 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -170,10 +170,17 @@ export class CollectionsScene { } }); let share_info = node.querySelector("[data-name=shared-by]"); - let share = (shares || []).find(s => s.ShareType === "map" && (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, "")); - if (share && share.Owner !== user) { - share_info.classList.remove("hidden"); - node.querySelector("[data-name=shared-by-owner]").textContent = share.Owner; + let transformed_from = node.querySelector("[data-name=transformed-from]"); + let share = (shares || []).find( + s => (s.ShareType === "map" || s.ShareType === "bday") && + (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, "")); + if (share) { + if (share.Owner !== user) { + share_info.classList.remove("hidden"); + node.querySelector("[data-name=shared-by-owner]").textContent = share.Owner; + } else { + transformed_from.classList.remove("hidden"); + } let share_option = node.querySelector("[data-name=shareoption]"); if (share_option) { share_option.classList.add("hidden"); diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index f930e42a..18176178 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -19,10 +19,11 @@ * along with this program. If not, see . */ -import { Share, add_share_by_map, add_share_by_token, get_property_key, update_share_by_map, update_share_by_token } from "../api/sharing.js"; +import { Share, add_share_by_bday, add_share_by_map, add_share_by_token, get_property_key, update_share_by_bday, update_share_by_map, update_share_by_token } from "../api/sharing.js"; import { CollectionType } from "../models/collection.js"; +import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; -import { FormValidator, validate_href, validate_not_empty_or_equals } from "../utils/form_validator.js"; +import { FormValidator, validate_href, validate_non_empty, validate_not_empty_or_equals } from "../utils/form_validator.js"; import { onCleanHREFinput, random_uuid } from "../utils/misc.js"; import { Scene, is_current_scene, pop_scene } from "./scene_manager.js"; @@ -66,7 +67,12 @@ export class CreateEditShareScene { let errorHandler = new ErrorHandler(error_form); let map_validator = new FormValidator(errorHandler); - map_validator.addValidator(shareuser_input, validate_not_empty_or_equals(shareuser_input, user, "Share User")); + if (shareType === "bday") { + // bday can share to self, so just validate non-empty + map_validator.addValidator(shareuser_input, validate_non_empty(shareuser_input, "Share User")); + } else { + map_validator.addValidator(shareuser_input, validate_not_empty_or_equals(shareuser_input, user, "Share User")); + } map_validator.addValidator(sharehref_input, validate_href(sharehref_input, "Share Href")); sharehref_input.addEventListener("input", onCleanHREFinput); @@ -93,13 +99,13 @@ export class CreateEditShareScene { function onsubmit() { try { - if (shareType === "map") { + if (shareType === "map" || shareType === "bday") { if (!map_validator.validate()) { return false; } } - let enabled_by_owner = enabled_checkbox.checked; - let hidden_by_owner = hidden_checkbox.checked; + let enabled_by_owner = (shareType === "bday" && shareuser_input.value === user) ? true : enabled_checkbox.checked; + let hidden_by_owner = (shareType === "bday" && shareuser_input.value === user) ? false : hidden_checkbox.checked; let permissions = permissions_rw_radio.checked ? "rw" : "r"; let properties = {}; @@ -123,6 +129,11 @@ export class CreateEditShareScene { if (error) { errorHandler.setError(error); } else { + // For bday shares to the current user, invalidate the + // collections cache so the virtual calendar appears immediately. + if (shareType === "bday" && new_share.User === user) { + collectionsCache.invalidate(); + } pop_scene(); } }; @@ -137,18 +148,22 @@ export class CreateEditShareScene { HiddenByUser: edit ? share.HiddenByUser : null, Properties: properties, User: edit ? share.User : shareuser_input.value, - PathOrToken: edit ? share.PathOrToken : (shareType === "map" ? "/" + shareuser_input.value + "/" + sharehref_input.value + "/" : ""), + PathOrToken: edit ? share.PathOrToken : ((shareType === "map" || shareType === "bday") ? "/" + shareuser_input.value + "/" + sharehref_input.value + "/" : ""), }); if (edit) { if (shareType === "map") { update_share_by_map(user, password, new_share, callback); + } else if (shareType === "bday") { + update_share_by_bday(user, password, new_share, callback); } else { update_share_by_token(user, password, new_share, callback); } } else { if (shareType === "map") { add_share_by_map(user, password, new_share, callback); + } else if (shareType === "bday") { + add_share_by_bday(user, password, new_share, callback); } else { add_share_by_token(user, password, new_share, callback); } @@ -175,12 +190,25 @@ export class CreateEditShareScene { html_scene.querySelector("h1").textContent = edit ? "Edit Share" : "New Share"; submit_btn.textContent = edit ? "Save" : "Create"; - shareuser_input.value = edit ? share.User : ""; + shareuser_input.value = edit ? share.User : (shareType === "bday" ? user : ""); shareuser_input.disabled = edit; enabled_checkbox.checked = edit ? share.EnabledByOwner : true; hidden_checkbox.checked = edit ? share.HiddenByOwner : false; - permissions_ro_radio.checked = edit ? share.Permissions.toLowerCase() === "r" : true; - permissions_rw_radio.checked = edit ? share.Permissions.toLowerCase() === "rw" : false; + + if (shareType === "bday") { + // bday is always read-only; hide the permissions section entirely + permissions_ro_radio.checked = true; + permissions_rw_radio.checked = false; + permissions_ro_radio.disabled = true; + permissions_rw_radio.disabled = true; + permissions_ro_radio.closest("details")?.classList.add("hidden"); + } else { + permissions_ro_radio.closest("details")?.classList.remove("hidden"); + permissions_ro_radio.disabled = false; + permissions_rw_radio.disabled = false; + permissions_ro_radio.checked = edit ? share.Permissions.toLowerCase() === "r" : true; + permissions_rw_radio.checked = edit ? share.Permissions.toLowerCase() === "rw" : false; + } let displayname = collection.displayname || ""; let description = collection.description || ""; @@ -236,7 +264,7 @@ export class CreateEditShareScene { color_override_enabled.parentElement.classList.add("hidden"); } - if (shareType === "map") { + if (shareType === "map" || shareType === "bday") { if (edit) { sharehref_input.value = share.PathOrToken.split("/").filter(Boolean).pop() || ""; } else { diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index 1cc1ac2c..44efee9a 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -95,7 +95,9 @@ export class IncomingSharingScene { nodes = []; let prefix = "/" + user + "/"; - let filtered_shares = shares.filter(share => share.ShareType === "map" && share.PathOrToken.startsWith(prefix)); + let filtered_shares = shares.filter( + share => (share.ShareType === "map" || share.ShareType === "bday") + && share.PathOrToken.startsWith(prefix)); if (filtered_shares.length === 0) { table.classList.add("hidden"); diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 32e22856..3337bf64 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -20,11 +20,12 @@ */ import { + delete_share_by_bday, delete_share_by_map, delete_share_by_token, reload_sharing_list, } from "../api/sharing.js"; -import { Collection } from "../models/collection.js"; +import { Collection, CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { displayPermissions } from "../utils/permissions.js"; @@ -51,6 +52,9 @@ export class ShareCollectionScene { ); /** @type {HTMLElement} */ let share_by_map_btn = html_scene.querySelector( "button[data-name=sharebymap]" + ); + /** @type {HTMLElement} */ let share_by_bday_btn = html_scene.querySelector( + "button[data-name=sharebybday]" ); /** @type {HTMLElement} */ let share_by_token_div = html_scene.querySelector( "div[data-name=sharebytoken]" @@ -58,6 +62,9 @@ export class ShareCollectionScene { /** @type {HTMLElement} */ let share_by_map_div = html_scene.querySelector( "div[data-name=sharebymap]" ); + /** @type {HTMLElement} */ let share_by_bday_div = html_scene.querySelector( + "div[data-name=sharebybday]" + ); /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); let errorHandler = new ErrorHandler(error_form); @@ -83,11 +90,16 @@ export class ShareCollectionScene { push_scene(create_edit_share_scene); } + function onsharebybday() { + let create_edit_share_scene = new CreateEditShareScene(user, password, collection, "bday"); + push_scene(create_edit_share_scene); + } + this.show = function () { this.release(); html_scene.classList.remove("hidden"); html_scene.querySelectorAll("details").forEach(function (details) { - details.open = true; + details.open = true; }); cancel_btn.onclick = oncancel; @@ -121,6 +133,26 @@ export class ShareCollectionScene { } else { if (share_by_map_div) share_by_map_div.classList.add("hidden"); } + + if (features.sharing && features.sharing.PermittedCreateCollectionByBday) { + let is_addressbook = collection.type === CollectionType.ADDRESSBOOK; + if (share_by_bday_btn) { + if (is_addressbook) { + share_by_bday_btn.classList.remove("hidden"); + share_by_bday_btn.onclick = onsharebybday; + } else { + share_by_bday_btn.classList.add("hidden"); + } + } + } else { + if (share_by_bday_btn) share_by_bday_btn.classList.add("hidden"); + } + + if (features.sharing && features.sharing.FeatureEnabledCollectionByBday && collection.type === CollectionType.ADDRESSBOOK) { + if (share_by_bday_div) share_by_bday_div.classList.remove("hidden"); + } else { + if (share_by_bday_div) share_by_bday_div.classList.add("hidden"); + } }); title.textContent = collection.displayname || collection.href; @@ -143,7 +175,7 @@ export class ShareCollectionScene { */ function update_share_list(user, password, collection, errorHandler) { let share_rows = document.querySelectorAll( - "[data-name=sharetokenrowtemplate], [data-name=sharemaprowtemplate]", + "[data-name=sharetokenrowtemplate], [data-name=sharemaprowtemplate], [data-name=sharebdayrowtemplate]", ); share_rows.forEach(function (row) { if (!row.classList.contains("hidden")) { @@ -170,8 +202,9 @@ function update_share_list(user, password, collection, errorHandler) { * @param {string} delete_label * @param {function(string, string, import('../api/sharing.js').Share, function(?string):void):void} delete_action * @param {ErrorHandler} errorHandler + * @param {function():void} [onDeleteSuccess] Optional extra callback after a successful delete. */ -function add_share_row_node(user, password, collection, share, template, delete_label, delete_action, errorHandler) { +function add_share_row_node(user, password, collection, share, template, delete_label, delete_action, errorHandler, onDeleteSuccess) { let pathortoken = share["PathOrToken"] || ""; let node = /** @type {HTMLElement} */ (template.cloneNode(true)); node.classList.remove("hidden"); @@ -195,6 +228,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ let delete_collection_scene = new DeleteConfirmationScene( user, password, "Delete Share", share, delete_label + " " + pathortoken, delete_action, false, function () { + if (onDeleteSuccess) onDeleteSuccess(); pop_scene(); update_share_list(user, password, collection, errorHandler); } @@ -215,6 +249,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ function add_share_rows(user, password, collection, shares, errorHandler) { /** @type {HTMLElement} */ let token_template = document.querySelector("[data-name=sharetokenrowtemplate]"); /** @type {HTMLElement} */ let map_template = document.querySelector("[data-name=sharemaprowtemplate]"); + /** @type {HTMLElement} */ let bday_template = document.querySelector("[data-name=sharebdayrowtemplate]"); shares.forEach(function (share) { let pathortoken = share["PathOrToken"] || ""; let pathmapped = share["PathMapped"] || ""; @@ -226,6 +261,9 @@ function add_share_rows(user, password, collection, shares, errorHandler) { add_share_row_node(user, password, collection, share, token_template, "share", delete_share_by_token, errorHandler); } else if (share["ShareType"] === "map") { add_share_row_node(user, password, collection, share, map_template, "map", delete_share_by_map, errorHandler); + } else if (share["ShareType"] === "bday") { + let onDeleteSuccess = (share["User"] === user) ? function () { collectionsCache.invalidate(); } : undefined; + add_share_row_node(user, password, collection, share, bday_template, "bday", delete_share_by_bday, errorHandler, onDeleteSuccess); } } }); @@ -238,7 +276,8 @@ export function maybe_enable_sharing_options(features) { if (!features || !features.sharing) return; let map_is_enabled = features.sharing.FeatureEnabledCollectionByMap || false; let token_is_enabled = features.sharing.FeatureEnabledCollectionByToken || false; - if (map_is_enabled || token_is_enabled) { + let bday_is_enabled = features.sharing.FeatureEnabledCollectionByBday || false; + if (map_is_enabled || token_is_enabled || bday_is_enabled) { let share_options = document.querySelectorAll("[data-name=shareoption]"); for (let i = 0; i < share_options.length; i++) { let share_option = share_options[i];