@@ -207,6 +210,35 @@ + + Incoming Shares + Manage shares that others have shared with you. + + + + + + + + + + + + + + Close + + + + New Share diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index ccd01e54..f9385b51 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -146,11 +146,12 @@ export class Share { * @param {function(Array, ?string):void} callback */ export function reload_sharing_list(user, password, collection, callback) { + let body = collection ? { PathMapped: collection.href } : {}; call_sharing_api( user, password, "all/list", - { PathMapped: collection.href }, + body, function (response) { let parsed = JSON.parse(response); let shares = (parsed["Content"] || []).map(data => new Share(data)); @@ -411,3 +412,41 @@ export function update_share_by_map( } ); } + +/** + * Update a shared map entry as the recipient user. + * Only sends fields the non-owner user is allowed to change: PathOrToken, EnabledByUser, HiddenByUser. + * @param {string} user + * @param {string} password + * @param {Share} share + * @param {function(?string):void} callback + */ +export function update_incoming_share( + user, + password, + share, + callback, +) { + call_sharing_api( + user, + password, + "map/update", + { + PathOrToken: share.PathOrToken, + Enabled: share.EnabledByUser, + Hidden: share.HiddenByUser, + }, + 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 03514119..6d191ad0 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -27,6 +27,7 @@ import { Collection, CollectionType } from "../models/collection.js"; import { bytesToHumanReadable } from "../utils/misc.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; +import { IncomingSharingScene } from "./IncomingSharingScene.js"; import { LoadingScene } from "./LoadingScene.js"; import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js"; @@ -48,6 +49,7 @@ export class CollectionsScene { /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=collectiontemplate]"); /** @type {HTMLElement} */ let new_btn = html_scene.querySelector("[data-name=new]"); /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=upload]"); + /** @type {HTMLElement} */ let incomingshares_btn = html_scene.querySelector("[data-name=incomingshares]"); /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let collections_req = null; @@ -74,6 +76,16 @@ export class CollectionsScene { return false; } + function onincomingshares() { + try { + let incoming_sharing_scene = new IncomingSharingScene(user, password); + push_scene(incoming_sharing_scene, false); + } catch (err) { + console.error(err); + } + return false; + } + function onedit(collection) { try { let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); @@ -191,6 +203,7 @@ export class CollectionsScene { html_scene.classList.remove("hidden"); new_btn.onclick = onnew; upload_btn.onclick = onupload; + incomingshares_btn.onclick = onincomingshares; if (collections === null) { update(); discover_server_features(user, password, maybe_enable_sharing_options); @@ -204,6 +217,7 @@ export class CollectionsScene { scene_index = scene_stack.length - 1; new_btn.onclick = null; upload_btn.onclick = null; + incomingshares_btn.onclick = null; collections = null; // remove collection nodes.forEach(function (node) { diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js new file mode 100644 index 00000000..f424c9bc --- /dev/null +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -0,0 +1,175 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * Copyright © 2026-2026 Max Berger + * + * 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 { Share, reload_sharing_list, update_incoming_share } from "../api/sharing.js"; +import { ErrorHandler } from "../utils/error.js"; +import { LoadingScene } from "./LoadingScene.js"; +import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; + +/** + * @implements {Scene} + */ +export class IncomingSharingScene { + /** + * @param {string} user + * @param {string} password + */ + constructor(user, password) { + /** @type {HTMLElement} */ let html_scene = document.getElementById("incomingsharingscene"); + /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {HTMLElement} */ let error_element = html_scene.querySelector("[data-name=error]"); + /** @type {HTMLElement} */ let tbody = html_scene.querySelector("tbody[data-name=incomingsharesbody]"); + /** @type {HTMLElement} */ let template = tbody.querySelector("[data-name=incomingsharerowtemplate]"); + + let error_handler = new ErrorHandler(error_element); + + /** @type {?number} */ let scene_index = null; + /** @type {Array} */ let nodes = []; + /** @type {?Array} */ let shares_cache = null; + + function on_cancel() { + pop_scene(scene_index - 1); + } + + /** + * @param {Share} share + * @param {HTMLElement} node + */ + function toggle_share(share, node) { + let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); + let hidden_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=hidden]")); + + // disable checkboxes while updating + enabled_cb.disabled = true; + hidden_cb.disabled = true; + + let old_enabled = share.EnabledByUser; + let old_hidden = share.HiddenByUser; + + share.EnabledByUser = enabled_cb.checked; + share.HiddenByUser = hidden_cb.checked; + error_handler.clearError(); + + update_incoming_share(user, password, share, function (error) { + enabled_cb.disabled = false; + hidden_cb.disabled = false; + + if (error) { + error_handler.setError(error); + enabled_cb.checked = old_enabled; + hidden_cb.checked = old_hidden; + } + }); + } + + /** + * @param {Share[]} shares + */ + function render_shares(shares) { + // clear old nodes + nodes.forEach(function (node) { + node.parentNode.removeChild(node); + }); + nodes = []; + + let prefix = "/" + user + "/"; + let filtered_shares = shares.filter(share => share.ShareType === "map" && share.PathOrToken.startsWith(prefix)); + + filtered_shares.forEach(function (share) { + let node = /** @type {HTMLElement} */(template.cloneNode(true)); + node.classList.remove("hidden"); + + let pathortoken_td = node.querySelector("[data-name=pathortoken]"); + let owner_td = node.querySelector("[data-name=owner]"); + let permissions_td = node.querySelector("[data-name=permissions]"); + let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); + let hidden_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=hidden]")); + + let displayPath = share.PathOrToken.substring(prefix.length); + if (displayPath.endsWith("/")) { + displayPath = displayPath.substring(0, displayPath.length - 1); + } + + pathortoken_td.textContent = displayPath; + owner_td.textContent = share.Owner; + permissions_td.textContent = share.Permissions; + + enabled_cb.checked = share.EnabledByUser; + hidden_cb.checked = share.HiddenByUser; + + enabled_cb.onchange = function () { toggle_share(share, node); }; + hidden_cb.onchange = function () { toggle_share(share, node); }; + + nodes.push(node); + tbody.appendChild(node); + }); + } + + function update() { + let loading_scene = new LoadingScene(); + push_scene(loading_scene, false); + + error_handler.clearError(); + + reload_sharing_list(user, password, null, function (shares, error) { + if (scene_index === null) { + return; + } + if (error) { + error_handler.setError(error); + pop_scene(scene_index - 1); + } else { + shares_cache = shares; + pop_scene(scene_index); + } + }); + } + + this.show = function () { + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + cancel_btn.onclick = on_cancel; + if (shares_cache === null) { + update(); + } else { + render_shares(shares_cache); + } + }; + + this.hide = function () { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + error_handler.clearError(); + + nodes.forEach(function (node) { + node.parentNode.removeChild(node); + }); + nodes = []; + shares_cache = null; + }; + + this.release = function () { + scene_index = null; + error_handler.clearError(); + shares_cache = null; + }; + } +}