diff --git a/radicale/web/internal_data/js/api/api.js b/radicale/web/internal_data/js/api/api.js index fa6f2035..4754d61f 100644 --- a/radicale/web/internal_data/js/api/api.js +++ b/radicale/web/internal_data/js/api/api.js @@ -22,23 +22,7 @@ import { COLOR_RE, ROOT_PATH, SERVER } from "../constants.js"; import { Collection, CollectionType } from "../models/collection.js"; import { escape_xml } from "../utils/misc.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 = {}; /** * Find the principal collection. @@ -360,317 +344,4 @@ export function create_collection(user, password, collection, callback) { export function edit_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, false, 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; -} - -/** - * @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); - }, - ); -} - -/** - * @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(Array, ?string):void} callback - */ -export function reload_sharing_list(user, password, collection, callback) { - call_sharing_api( - user, - password, - "all/list", - { PathMapped: collection.href }, - function (response) { - let parsed = JSON.parse(response); - callback(parsed["Content"] || [], null); - }, - null, // on_not_found - function (error) { - callback([], error); - } - ); -} - - -/** - * Property keys for different collection types. - * Map to OVERLAY_PROPERTIES_WHITELIST in radicale/sharing/__init__.py - */ -export const OVERLAY_PROPERTIES = { - CALENDAR: { - DESCRIPTION: "C:calendar-description", - COLOR: "ICAL:calendar-color", - }, - ADDRESSBOOK: { - DESCRIPTION: "CR:addressbook-description", - COLOR: "INF:addressbook-color", - } -}; - -/** - * Returns the correct internal property key for a given collection type and property name. - * @param {string} type Collection type (ADDRESSBOOK, CALENDAR, etc.) - * @param {"DESCRIPTION" | "COLOR"} property Property name - * @returns {string | null} Internal property key or null if not supported - */ -export function get_property_key(type, property) { - if (type === CollectionType.ADDRESSBOOK) { - return OVERLAY_PROPERTIES.ADDRESSBOOK[property]; - } else if (CollectionType.is_subset(CollectionType.CALENDAR, type)) { - return OVERLAY_PROPERTIES.CALENDAR[property]; - } - return null; -} -/** - * @param {string} user - * @param {string} password - * @param {string} pathMapped - * @param {string} permissions - * @param {boolean} enabled - * @param {boolean} hidden - * @param {Object} properties - * @param {function(?string):void} callback - */ -export function add_share_by_token( - user, - password, - 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") { - callback(json_response["Status"] || "Unknown error"); - } else { - callback(null); - } - }, - null, - function (error) { - callback(error); - } - ); -} - -/** - * @param {string} user - * @param {string} password - * @param {string} pathMapped - * @param {string} permissions - * @param {boolean} enabled - * @param {boolean} hidden - * @param {Object} properties - * @param {string} share_user - * @param {string} href - * @param {function(?string):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") { - callback(json_response["Status"] || "Unknown error"); - } else { - callback(null); - } - }, - null, - function (error) { - callback(error); - } - ); -} - -/** - * @param {string} user - * @param {string} password - * @param {string} token - * @param {function(?string):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") { - callback(json_response["Status"] || "Unknown error"); - } else { - callback(null); - } - }, - null, - function (error) { - callback(error); - } - ); -} - -/** - * @param {string} user - * @param {string} password - * @param {string} pathortoken - * @param {function(?string):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") { - callback(json_response["Status"] || "Unknown error"); - } else { - callback(null); - } - }, - null, - function (error) { - callback(error); - } - ); -} diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js new file mode 100644 index 00000000..e8f160fb --- /dev/null +++ b/radicale/web/internal_data/js/api/sharing.js @@ -0,0 +1,351 @@ +/** + * This file is part of Radicale Server - Calendar Server + * 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 { ROOT_PATH, SERVER } from "../constants.js"; +import { CollectionType } from "../models/collection.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 = {}; + +/** + * @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; +} + +/** + * @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); + }, + ); +} + +/** + * @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 {import("../models/collection.js").Collection} collection + * @param {function(Array, ?string):void} callback + */ +export function reload_sharing_list(user, password, collection, callback) { + call_sharing_api( + user, + password, + "all/list", + { PathMapped: collection.href }, + function (response) { + let parsed = JSON.parse(response); + callback(parsed["Content"] || [], null); + }, + null, // on_not_found + function (error) { + callback([], error); + } + ); +} + +/** + * Property keys for different collection types. + * Map to OVERLAY_PROPERTIES_WHITELIST in radicale/sharing/__init__.py + */ +export const OVERLAY_PROPERTIES = { + CALENDAR: { + DESCRIPTION: "C:calendar-description", + COLOR: "ICAL:calendar-color", + }, + ADDRESSBOOK: { + DESCRIPTION: "CR:addressbook-description", + COLOR: "INF:addressbook-color", + } +}; + +/** + * Returns the correct internal property key for a given collection type and property name. + * @param {string} type Collection type (ADDRESSBOOK, CALENDAR, etc.) + * @param {"DESCRIPTION" | "COLOR"} property Property name + * @returns {string | null} Internal property key or null if not supported + */ +export function get_property_key(type, property) { + if (type === CollectionType.ADDRESSBOOK) { + return OVERLAY_PROPERTIES.ADDRESSBOOK[property]; + } else if (CollectionType.is_subset(CollectionType.CALENDAR, type)) { + return OVERLAY_PROPERTIES.CALENDAR[property]; + } + return null; +} + +/** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {string} permissions + * @param {boolean} enabled + * @param {boolean} hidden + * @param {Object} properties + * @param {function(?string):void} callback + */ +export function add_share_by_token( + user, + password, + 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") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} + +/** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {string} permissions + * @param {boolean} enabled + * @param {boolean} hidden + * @param {Object} properties + * @param {string} share_user + * @param {string} href + * @param {function(?string):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") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} + +/** + * @param {string} user + * @param {string} password + * @param {string} token + * @param {function(?string):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") { + callback(json_response["Status"] || "Unknown error"); + } else { + callback(null); + } + }, + null, + function (error) { + callback(error); + } + ); +} + +/** + * @param {string} user + * @param {string} password + * @param {string} pathortoken + * @param {function(?string):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") { + 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 3921d90b..03514119 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -19,7 +19,9 @@ * along with this program. If not, see . */ -import { discover_server_features, get_collections } from "../api/api.js"; +import { get_collections } from "../api/api.js"; +import { discover_server_features } from "../api/sharing.js"; + import { SERVER } from "../constants.js"; import { Collection, CollectionType } from "../models/collection.js"; import { bytesToHumanReadable } from "../utils/misc.js"; diff --git a/radicale/web/internal_data/js/scenes/NewShareScene.js b/radicale/web/internal_data/js/scenes/NewShareScene.js index 4d9e746f..72aa2e1d 100644 --- a/radicale/web/internal_data/js/scenes/NewShareScene.js +++ b/radicale/web/internal_data/js/scenes/NewShareScene.js @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -import { add_share_by_map, add_share_by_token, get_property_key } from "../api/api.js"; +import { add_share_by_map, add_share_by_token, get_property_key } from "../api/sharing.js"; import { CollectionType } from "../models/collection.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_href, validate_non_empty } from "../utils/form_validator.js"; diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 00d0c418..c9ff30e4 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -24,7 +24,7 @@ import { delete_share_by_token, reload_sharing_list, server_features, -} from "../api/api.js"; +} from "../api/sharing.js"; import { Collection } from "../models/collection.js"; import { ErrorHandler } from "../utils/error.js"; import { NewShareScene } from "./NewShareScene.js"; @@ -164,7 +164,7 @@ function update_share_list(user, password, collection, errorHandler) { * @param {string} user * @param {string} password * @param {Collection} collection - * @param {import('../api/api.js').Share} share + * @param {import('../api/sharing.js').Share} share * @param {HTMLElement} template * @param {string} delete_label * @param {function(string, string, string, function(?string):void):void} delete_action @@ -219,7 +219,7 @@ function add_share_row_node(user, password, collection, share, template, delete_ * @param {string} user * @param {string} password * @param {Collection} collection - * @param {Array} shares + * @param {Array} shares * @param {ErrorHandler} errorHandler */ function add_share_rows(user, password, collection, shares, errorHandler) {