diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 047e76b5..445383db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -186,6 +186,29 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: coveralls --service=github --finish + js-test: + name: JS Type Check + runs-on: ubuntu-latest + needs: test-ubuntu-python-newest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: JS Type Check + run: | + set -o pipefail + npx -p typescript tsc -p radicale/web/jsconfig.json | npx typescript-xunit-xml > tsc-results.xml + - uses: mikepenz/action-junit-report@v6 + if: ${{ failure() && (github.event.pull_request.head.repo.full_name != github.repository) }} + with: + report_paths: 'tsc-results.xml' + annotate_only: true # forked repo cannot write to checks so just do annotations + - uses: mikepenz/action-junit-report@v6 + if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} + with: + report_paths: 'tsc-results.xml' + lint: name: Lint runs-on: ubuntu-latest @@ -220,6 +243,6 @@ jobs: report_paths: 'pytest-results.xml' annotate_only: true # forked repo cannot write to checks so just do annotations - uses: mikepenz/action-junit-report@v6 - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} with: report_paths: 'pytest-results.xml' 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 9c28486c..4cb26b88 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) @@ -46,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/README.md b/radicale/web/README.md new file mode 100644 index 00000000..869f8873 --- /dev/null +++ b/radicale/web/README.md @@ -0,0 +1,8 @@ +# Built-in web UI + +If you have tsc installed, you can type-check all JavaScript using + +``` lang=shell +tsc -p radicale/web/jsconfig.json --noEmit --pretty +``` + 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/CreateEditCollectionScene.js b/radicale/web/internal_data/CreateEditCollectionScene.js index 903c7e13..d68a9c80 100644 --- a/radicale/web/internal_data/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/CreateEditCollectionScene.js @@ -23,35 +23,31 @@ import { create_collection, edit_collection } from "./api.js"; import { COLOR_RE } from "./constants.js"; import { Collection, CollectionType } from "./models.js"; import { Scene, pop_scene, push_scene, scene_stack } from "./scene_manager.js"; -import { cleanHREFinput, isValidHREF, random_hex, random_uuid } from "./utils.js"; +import { cleanHREFinput, isValidHREF, onCleanHREFinput, random_hex, random_uuid } from "./utils.js"; /** - * @constructor * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection if it's a principal collection, a new - * collection will be created inside of it. - * Otherwise the collection will be edited. */ export class CreateEditCollectionScene { + /** + * @param {string} user + * @param {string} password + * @param {Collection} collection if it's a principal collection, a new + * collection will be created inside of it. + * Otherwise the collection will be edited. + */ constructor(user, password, collection) { let edit = collection.type !== CollectionType.PRINCIPAL; let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); /** @type {HTMLElement} */ let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; /** @type {HTMLElement} */ let error_form = html_scene.querySelector("[data-name=error]"); /** @type {HTMLInputElement} */ let href_form = html_scene.querySelector("[data-name=href]"); - /** @type {HTMLElement} */ let href_label = html_scene.querySelector("label[for=href]"); /** @type {HTMLInputElement} */ let displayname_form = html_scene.querySelector("[data-name=displayname]"); - /** @type {HTMLElement} */ let displayname_label = html_scene.querySelector("label[for=displayname]"); /** @type {HTMLInputElement} */ let description_form = html_scene.querySelector("[data-name=description]"); - /** @type {HTMLElement} */ let description_label = html_scene.querySelector("label[for=description]"); /** @type {HTMLInputElement} */ let source_form = html_scene.querySelector("[data-name=source]"); /** @type {HTMLElement} */ let source_label = html_scene.querySelector("label[for=source]"); /** @type {HTMLSelectElement} */ let type_form = html_scene.querySelector("[data-name=type]"); - /** @type {HTMLElement} */ let type_label = html_scene.querySelector("label[for=type]"); /** @type {HTMLInputElement} */ let color_form = html_scene.querySelector("[data-name=color]"); - /** @type {HTMLElement} */ let color_label = html_scene.querySelector("label[for=color]"); /** @type {HTMLElement} */ let submit_btn = html_scene.querySelector("[data-name=submit]"); /** @type {HTMLElement} */ let cancel_btn = html_scene.querySelector("[data-name=cancel]"); @@ -69,7 +65,7 @@ export class CreateEditCollectionScene { let color = edit && collection.color ? collection.color : "#" + random_hex(6); if (!edit) { - href_form.addEventListener("keydown", cleanHREFinput); + href_form.addEventListener("input", onCleanHREFinput); } function remove_invalid_types() { @@ -118,7 +114,7 @@ export class CreateEditCollectionScene { error_form.classList.remove("hidden"); } error_form.classList.add("hidden"); - onTypeChange(); + onTypeChange(null); type_form.addEventListener("change", onTypeChange); } @@ -172,8 +168,10 @@ export class CreateEditCollectionScene { return false; } - - function onTypeChange(e) { + /** + * @param {Event} _e + */ + function onTypeChange(_e) { if (type_form.value == CollectionType.WEBCAL) { source_label.classList.remove("hidden"); source_form.classList.remove("hidden"); diff --git a/radicale/web/internal_data/LoadingScene.js b/radicale/web/internal_data/LoadingScene.js index 4251147d..1e7b8cfb 100644 --- a/radicale/web/internal_data/LoadingScene.js +++ b/radicale/web/internal_data/LoadingScene.js @@ -21,16 +21,17 @@ import { Scene } from "./scene_manager.js"; /** - * @constructor * @implements {Scene} */ -export function LoadingScene() { - let html_scene = document.getElementById("loadingscene"); - this.show = function() { - html_scene.classList.remove("hidden"); - }; - this.hide = function() { - html_scene.classList.add("hidden"); - }; - this.release = function() {}; +export class LoadingScene { + constructor() { + let html_scene = document.getElementById("loadingscene"); + this.show = function () { + html_scene.classList.remove("hidden"); + }; + this.hide = function () { + html_scene.classList.add("hidden"); + }; + this.release = function () { }; + } } \ No newline at end of file diff --git a/radicale/web/internal_data/NewShareScene.js b/radicale/web/internal_data/NewShareScene.js new file mode 100644 index 00000000..7672885f --- /dev/null +++ b/radicale/web/internal_data/NewShareScene.js @@ -0,0 +1,124 @@ +/** + * 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_map, add_share_by_token } from "./api.js"; +import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; +import { onCleanHREFinput } from "./utils.js"; + +/** + * @implements {Scene} + */ +export class NewShareScene { + /** + * @param {string} user + * @param {string} password + * @param {string} pathMapped + * @param {string} shareType + * @param {function():void} 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")); + 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]"); + + sharehref_input.addEventListener("input", onCleanHREFinput); + + /** @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; + + 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); + } + 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; + + 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; + 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..834c7a41 100644 --- a/radicale/web/internal_data/ShareCollectionScene.js +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -19,32 +19,41 @@ */ import { - add_share_by_token, + delete_share_by_map, 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 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_token_btn_rw = html_scene.querySelector( - "[data-name=sharebytoken_rw]" + /** @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]" + ); + /** @type {HTMLElement} */ let share_by_map_div = html_scene.querySelector( + "div[data-name=sharebymap]" ); /** @type {HTMLElement} */ let title = html_scene.querySelector("[data-name=title]"); @@ -58,16 +67,18 @@ export class CreateShareCollectionScene { return false; } - function onsharebytoken_rw() { - add_share_by_token(user, password, collection, "rw", function () { + function onsharebytoken() { + 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 onsharebytoken_ro() { - add_share_by_token(user, password, collection, "r", function () { + 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); } this.show = function () { @@ -75,15 +86,36 @@ export class CreateShareCollectionScene { scene_index = scene_stack.length - 1; 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; + 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_ro.classList.add("hidden"); - share_by_token_btn_rw.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.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 { + if (share_by_map_div) share_by_map_div.classList.add("hidden"); + } + title.textContent = collection.displayname || collection.href; update_share_list(user, password, collection); }; @@ -97,9 +129,14 @@ export class CreateShareCollectionScene { } } +/** + * @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")) { @@ -107,62 +144,91 @@ 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); }); } +/** + * + * @param {string} user + * @param {string} password + * @param {Collection} collection + * @param {import('./api.js').Share} 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 {Array} 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); } }); } 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/UploadCollectionScene.js b/radicale/web/internal_data/UploadCollectionScene.js index 2704f461..fcaf612f 100644 --- a/radicale/web/internal_data/UploadCollectionScene.js +++ b/radicale/web/internal_data/UploadCollectionScene.js @@ -20,195 +20,198 @@ import { Scene, pop_scene, scene_stack } from "./scene_manager.js"; import { Collection } from "./models.js"; -import { cleanHREFinput, isValidHREF, random_uuid } from "./utils.js"; +import { cleanHREFinput, isValidHREF, onCleanHREFinput, random_uuid } from "./utils.js"; import { upload_collection } from "./api.js"; /** - * @constructor * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection parent collection */ -export function UploadCollectionScene(user, password, collection) { - /** @type {HTMLElement} */ let html_scene = document.getElementById("uploadcollectionscene"); - /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=filetemplate]"); - /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=submit]"); - /** @type {HTMLElement} */ let close_btn = html_scene.querySelector("[data-name=close]"); - /** @type {HTMLInputElement} */ let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); - /** @type {HTMLElement} */ let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); - /** @type {HTMLInputElement} */ let href_form = html_scene.querySelector("[data-name=href]"); - /** @type {HTMLElement} */ let href_label = html_scene.querySelector("label[for=href]"); - /** @type {HTMLElement} */ let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); - /** @type {HTMLElement} */ let pending_html = html_scene.querySelector("[data-name=pending]"); +export class UploadCollectionScene { + /** + * @param {string} user + * @param {string} password + * @param {Collection} collection parent collection + */ + constructor(user, password, collection) { + /** @type {HTMLElement} */ let html_scene = document.getElementById("uploadcollectionscene"); + /** @type {HTMLElement} */ let template = html_scene.querySelector("[data-name=filetemplate]"); + /** @type {HTMLElement} */ let upload_btn = html_scene.querySelector("[data-name=submit]"); + /** @type {HTMLElement} */ let close_btn = html_scene.querySelector("[data-name=close]"); + /** @type {HTMLInputElement} */ let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); + /** @type {HTMLElement} */ let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); + /** @type {HTMLInputElement} */ let href_form = html_scene.querySelector("[data-name=href]"); + /** @type {HTMLElement} */ let href_label = html_scene.querySelector("label[for=href]"); + /** @type {HTMLElement} */ let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); + /** @type {HTMLElement} */ let pending_html = html_scene.querySelector("[data-name=pending]"); - let files = uploadfile_form.files; - href_form.addEventListener("keydown", cleanHREFinput); - upload_btn.onclick = upload_start; - uploadfile_form.onchange = onfileschange; + let files = uploadfile_form.files; + href_form.addEventListener("input", onCleanHREFinput); + upload_btn.onclick = upload_start; + uploadfile_form.onchange = onfileschange; - href_form.value = ""; - let href = ""; + href_form.value = ""; + let href = ""; - /** @type {?number} */ let scene_index = null; - /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let results = []; - /** @type {?Array} */ let nodes = null; + /** @type {?number} */ let scene_index = null; + /** @type {?XMLHttpRequest} */ let upload_req = null; + /** @type {Array} */ let results = []; + /** @type {?Array} */ let nodes = null; - function upload_start() { - try { - if(!read_form()){ + function upload_start() { + try { + if (!read_form()) { + return false; + } + uploadfile_form.classList.add("hidden"); + uploadfile_lbl.classList.add("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + hreflimitmsg_html.classList.add("hidden"); + upload_btn.classList.add("hidden"); + close_btn.classList.add("hidden"); + + pending_html.classList.remove("hidden"); + + nodes = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + let node = /** @type {HTMLElement} */ (template.cloneNode(true)); + node.classList.remove("hidden"); + let name_form = node.querySelector("[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + nodes.push(node); + updateFileStatus(i); + template.parentNode.insertBefore(node, template); + } + upload_next(); + } catch (err) { + console.error(err); + } + return false; + } + + function upload_next() { + try { + if (files.length === results.length) { + pending_html.classList.add("hidden"); + close_btn.classList.remove("hidden"); + return; + } else { + let file = files[results.length]; + if (files.length > 1 || href.length == 0) { + href = random_uuid(); + } + let upload_href = collection.href + href + "/"; + upload_req = upload_collection(user, password, upload_href, file, function (result) { + upload_req = null; + results.push(result); + updateFileStatus(results.length - 1); + upload_next(); + }); + } + } catch (err) { + console.error(err); + } + } + + function onclose() { + try { + pop_scene(scene_index - 1); + } catch (err) { + console.error(err); + } + return false; + } + + function updateFileStatus(i) { + if (nodes === null) { + return; + } + let success_form = nodes[i].querySelector("[data-name=success]"); + let error_form = nodes[i].querySelector("[data-name=error]"); + if (results.length > i) { + if (results[i]) { + success_form.classList.add("hidden"); + error_form.textContent = "Error: " + results[i]; + error_form.classList.remove("hidden"); + } else { + success_form.classList.remove("hidden"); + error_form.classList.add("hidden"); + } + } else { + success_form.classList.add("hidden"); + error_form.classList.add("hidden"); + } + } + + function read_form() { + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if (!isValidHREF(newhreftxtvalue)) { + alert("You must enter a valid HREF"); return false; } - uploadfile_form.classList.add("hidden"); - uploadfile_lbl.classList.add("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - hreflimitmsg_html.classList.add("hidden"); - upload_btn.classList.add("hidden"); - close_btn.classList.add("hidden"); + href = newhreftxtvalue; - pending_html.classList.remove("hidden"); - - nodes = []; - for (let i = 0; i < files.length; i++) { - let file = files[i]; - /** @type {HTMLElement} */ let node = template.cloneNode(true); - node.classList.remove("hidden"); - let name_form = node.querySelector("[data-name=name]"); - name_form.textContent = file.name; - node.classList.remove("hidden"); - nodes.push(node); - updateFileStatus(i); - template.parentNode.insertBefore(node, template); + if (uploadfile_form.files.length == 0) { + alert("You must select at least one file to upload"); + return false; } - upload_next(); - } catch(err) { - console.error(err); + files = uploadfile_form.files; + return true; } - return false; - } - function upload_next(){ - try{ - if (files.length === results.length) { - pending_html.classList.add("hidden"); - close_btn.classList.remove("hidden"); - return; + function onfileschange() { + files = uploadfile_form.files; + if (files.length > 1) { + hreflimitmsg_html.classList.remove("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + href_form.value = random_uuid(); // dummy, will be replaced on upload } else { - let file = files[results.length]; - if(files.length > 1 || href.length == 0){ - href = random_uuid(); - } - let upload_href = collection.href + href + "/"; - upload_req = upload_collection(user, password, upload_href, file, function(result) { - upload_req = null; - results.push(result); - updateFileStatus(results.length - 1); - upload_next(); - }); + hreflimitmsg_html.classList.add("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + href_form.value = files[0].name.replace(/\.(ics|vcf)$/, ''); } - }catch(err){ - console.error(err); - } - } - - function onclose() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - function updateFileStatus(i) { - if (nodes === null) { - return; - } - let success_form = nodes[i].querySelector("[data-name=success]"); - let error_form = nodes[i].querySelector("[data-name=error]"); - if (results.length > i) { - if (results[i]) { - success_form.classList.add("hidden"); - error_form.textContent = "Error: " + results[i]; - error_form.classList.remove("hidden"); - } else { - success_form.classList.remove("hidden"); - error_form.classList.add("hidden"); - } - } else { - success_form.classList.add("hidden"); - error_form.classList.add("hidden"); - } - } - - function read_form() { - cleanHREFinput(href_form); - let newhreftxtvalue = href_form.value.trim().toLowerCase(); - if(!isValidHREF(newhreftxtvalue)){ - alert("You must enter a valid HREF"); return false; } - href = newhreftxtvalue; - if(uploadfile_form.files.length == 0){ - alert("You must select at least one file to upload"); - return false; - } - files = uploadfile_form.files; - return true; - } + this.show = function () { + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + close_btn.onclick = onclose; + }; - function onfileschange() { - files = uploadfile_form.files; - if(files.length > 1){ - hreflimitmsg_html.classList.remove("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - href_form.value = random_uuid(); // dummy, will be replaced on upload - }else{ - hreflimitmsg_html.classList.add("hidden"); + this.hide = function () { + html_scene.classList.add("hidden"); + close_btn.classList.remove("hidden"); + upload_btn.classList.remove("hidden"); + uploadfile_form.classList.remove("hidden"); + uploadfile_lbl.classList.remove("hidden"); href_form.classList.remove("hidden"); href_label.classList.remove("hidden"); - href_form.value = files[0].name.replace(/\.(ics|vcf)$/, ''); - } - return false; + hreflimitmsg_html.classList.add("hidden"); + pending_html.classList.add("hidden"); + close_btn.onclick = null; + upload_btn.onclick = null; + href_form.value = ""; + uploadfile_form.value = ""; + if (nodes == null) { + return; + } + nodes.forEach(function (node) { + node.parentNode.removeChild(node); + }); + nodes = null; + }; + this.release = function () { + scene_index = null; + if (upload_req !== null) { + upload_req.abort(); + upload_req = null; + } + }; } - - this.show = function() { - scene_index = scene_stack.length - 1; - html_scene.classList.remove("hidden"); - close_btn.onclick = onclose; - }; - - this.hide = function() { - html_scene.classList.add("hidden"); - close_btn.classList.remove("hidden"); - upload_btn.classList.remove("hidden"); - uploadfile_form.classList.remove("hidden"); - uploadfile_lbl.classList.remove("hidden"); - href_form.classList.remove("hidden"); - href_label.classList.remove("hidden"); - hreflimitmsg_html.classList.add("hidden"); - pending_html.classList.add("hidden"); - close_btn.onclick = null; - upload_btn.onclick = null; - href_form.value = ""; - uploadfile_form.value = ""; - if(nodes == null){ - return; - } - nodes.forEach(function(node) { - node.parentNode.removeChild(node); - }); - nodes = null; - }; - this.release = function() { - scene_index = null; - if (upload_req !== null) { - upload_req.abort(); - upload_req = null; - } - }; } \ No newline at end of file diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 46d52ff1..7b7c3647 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -18,10 +18,25 @@ * 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"; +/** + * @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 = {}; /** @@ -34,7 +49,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; } @@ -50,6 +65,7 @@ export function get_principal(user, password, callback) { "", "", 0, + 0, ""), null); } else { callback(null, "Internal error"); @@ -59,12 +75,12 @@ export function get_principal(user, password, callback) { } }; request.send('' + - '' + - '' + - '' + - '' + - '' + - ''); + '' + + '' + + '' + + '' + + '' + + ''); return request; } @@ -80,7 +96,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 +171,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 +182,28 @@ export function get_collections(user, password, collection, callback) { } }; request.send('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''); + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''); return request; } @@ -202,7 +218,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 +243,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 +268,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 +311,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 +361,265 @@ 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); + }, + ); } +/** + * @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):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) { + let parsed = JSON.parse(response); + callback(parsed["Content"] || []); + }, + ); } +/** + * @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(); + } + }, + ); } -export function delete_share_by_token( - user, - password, - token, - callback, -) { - call_sharing_api( +/** + * @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, - "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(); - } - }, - ); + 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 + * @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(); + } + }, + ); +} + +/** + * @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/css/main.css b/radicale/web/internal_data/css/main.css index 8c4086f9..7ba2e92b 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,56 +32,52 @@ 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; color: #484848; - font-size: 1.5em; + font-size: 14pt; } -#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,18 @@ main{ margin-top: 15px; } -.deleteconfirmationtxt{ +#newshare input[type=text] { + margin-bottom: 0 !important; +} + + +.deleteconfirmationtxt { text-align: center; font-size: 1em; font-weight: bold; } -.fabcontainer{ +.fabcontainer { display: flex; flex-direction: column-reverse; position: fixed; @@ -281,7 +282,7 @@ main{ right: 0; } -.fabcontainer a{ +.fabcontainer a { width: 30px; height: 30px; text-decoration: none; @@ -300,29 +301,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 +335,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 +368,7 @@ img.loading{ text-align: center; } -button{ +button { font-size: 1em; padding: 7px 21px; color: white; @@ -379,7 +380,8 @@ button{ position: relative; } -input, select{ +input, +select { width: 100%; height: 3em; border-style: solid; @@ -396,56 +398,77 @@ 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{ +input[type=radio], +input[type=checkbox] { + width: auto; + height: 1.5em; + padding: 0; + margin: 2px; +} + +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 +480,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 diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 8689fc38..43fe4ac0 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -6,244 +6,310 @@ * Copyright © 2024-2025 Peter Bieringer --> - - - - - Radicale Web Interface - - - - - - - + + + + + Radicale Web Interface + + + + + -
-
- Loading... -

Loading

-

Please wait...

- -
+ + - - + - - - - + - +
+ - - - + \ No newline at end of file diff --git a/radicale/web/internal_data/models.js b/radicale/web/internal_data/models.js index 9dcdc5e9..4ba4ecea 100644 --- a/radicale/web/internal_data/models.js +++ b/radicale/web/internal_data/models.js @@ -18,21 +18,32 @@ * along with this program. If not, see . */ -/** - * @enum {string} - */ -export const CollectionType = { - PRINCIPAL: "PRINCIPAL", - ADDRESSBOOK: "ADDRESSBOOK", - CALENDAR_JOURNAL_TASKS: "CALENDAR_JOURNAL_TASKS", - CALENDAR_JOURNAL: "CALENDAR_JOURNAL", - CALENDAR_TASKS: "CALENDAR_TASKS", - JOURNAL_TASKS: "JOURNAL_TASKS", - CALENDAR: "CALENDAR", - JOURNAL: "JOURNAL", - TASKS: "TASKS", - WEBCAL: "WEBCAL", - is_subset: function(a, b) { +export class CollectionType { + // Private Fields + static #_PRINCIPAL = "PRINCIPAL"; + static #_ADDRESSBOOK = "ADDRESSBOOK"; + static #_CALENDAR_JOURNAL_TASKS = "CALENDAR_JOURNAL_TASKS"; + static #_CALENDAR_JOURNAL = "CALENDAR_JOURNAL"; + static #_CALENDAR_TASKS = "CALENDAR_TASKS"; + static #_JOURNAL_TASKS = "JOURNAL_TASKS"; + static #_CALENDAR = "CALENDAR"; + static #_JOURNAL = "JOURNAL"; + static #_TASKS = "TASKS"; + static #_WEBCAL = "WEBCAL"; + + // Accessors for "get" functions only (no "set" functions) + static get PRINCIPAL() { return this.#_PRINCIPAL; } + static get ADDRESSBOOK() { return this.#_ADDRESSBOOK; } + static get CALENDAR_JOURNAL_TASKS() { return this.#_CALENDAR_JOURNAL_TASKS; } + static get CALENDAR_JOURNAL() { return this.#_CALENDAR_JOURNAL; } + static get CALENDAR_TASKS() { return this.#_CALENDAR_TASKS; } + static get JOURNAL_TASKS() { return this.#_JOURNAL_TASKS; } + static get CALENDAR() { return this.#_CALENDAR; } + static get JOURNAL() { return this.#_JOURNAL; } + static get TASKS() { return this.#_TASKS; } + static get WEBCAL() { return this.#_WEBCAL; } + + static is_subset(/** @type {string} */ a, /** @type {string} */ b) { let components = a.split("_"); for (let i = 0; i < components.length; i++) { if (b.search(components[i]) === -1) { @@ -40,8 +51,9 @@ export const CollectionType = { } } return true; - }, - union: function(a, b) { + } + + static union(/** @type {string} */ a, /** @type {string} */ b) { if (a.search(this.ADDRESSBOOK) !== -1 || b.search(this.ADDRESSBOOK) !== -1) { if (a && a !== this.ADDRESSBOOK || b && b !== this.ADDRESSBOOK) { throw "Invalid union: " + a + " " + b; @@ -62,10 +74,14 @@ export const CollectionType = { union.push(this.WEBCAL); } return union.join("_"); - }, - valid_options_for_type: function(a){ + } + + /** + * @param {string} a + */ + static valid_options_for_type(a) { a = a.trim().toUpperCase(); - switch(a){ + switch (a) { case CollectionType.CALENDAR_JOURNAL_TASKS: case CollectionType.CALENDAR_JOURNAL: case CollectionType.CALENDAR_TASKS: @@ -80,24 +96,28 @@ export const CollectionType = { return [a]; } } -}; +} -/** - * @constructor - * @struct - * @param {string} href Must always start and end with /. - * @param {CollectionType} type - * @param {string} displayname - * @param {string} description - * @param {string} color - */ -export function Collection(href, type, displayname, description, color, contentcount, size, source) { - this.href = href; - this.type = type; - this.displayname = displayname; - this.color = color; - this.description = description; - this.source = source; - this.contentcount = contentcount; - this.size = size; + +export class Collection { + /** + * @param {string} href Must always start and end with /. + * @param {string} type + * @param {string} displayname + * @param {string} description + * @param {string} color + * @param {number} contentcount + * @param {number} size + * @param {string} source + */ + constructor(href, type, displayname, description, color, contentcount, size, source) { + this.href = href; + this.type = type; + this.displayname = displayname; + this.color = color; + this.description = description; + this.source = source; + this.contentcount = contentcount; + this.size = size; + } } \ No newline at end of file diff --git a/radicale/web/internal_data/utils.js b/radicale/web/internal_data/utils.js index ad709f55..0e5ed015 100644 --- a/radicale/web/internal_data/utils.js +++ b/radicale/web/internal_data/utils.js @@ -25,11 +25,11 @@ */ export function escape_xml(s) { return (s - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">")); + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">")); } /** @@ -47,19 +47,18 @@ export function random_uuid() { export function random_hex(length) { let bytes = new Uint8Array(Math.ceil(length / 2)); window.crypto.getRandomValues(bytes); - return bytes.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "").substring(0, length); + // Fallback for compatibility with older browsers which may not have padStart + return bytes.reduce((s, b) => { + let hex = b.toString(16); + return s + (String.prototype["padStart"] ? hex["padStart"](2, "0") : ("0" + hex).slice(-2)); + }, "").substring(0, length); } /** * Removed invalid HREF characters for a collection HREF. - * - * @param a A valid Input element or an onchange Event of an Input element. + * @param {HTMLInputElement} href_form A valid Input element or an onchange Event of an Input element. */ -export function cleanHREFinput(a) { - let href_form = a; - if (a.target) { - href_form = a.target; - } +export function cleanHREFinput(href_form) { let currentTxtVal = href_form.value.trim().toLowerCase(); //Clean the HREF to remove not permitted chars currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_\.])./g, ''); @@ -68,11 +67,19 @@ export function cleanHREFinput(a) { href_form.value = currentTxtVal; } +/** + * Event listener for cleaning HREF input. + * @param {Event} event + */ +export function onCleanHREFinput(event) { + if (event.target instanceof HTMLInputElement) { + cleanHREFinput(event.target); + } +} + /** * Checks if a proposed HREF for a collection has a valid format and syntax. - * - * @param href String of the porposed HREF. - * + * @param {string} href String of the proposed HREF. * @return Boolean results if the HREF is valid. */ export function isValidHREF(href) { @@ -88,16 +95,15 @@ export function isValidHREF(href) { /** * Format bytes to human-readable text. - * - * @param bytes Number of bytes. - * + * @param {number} bytes Number of bytes. * @return Formatted string. */ -export function bytesToHumanReadable(bytes, dp=1) { - let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); - if(!isNumber){ +export function bytesToHumanReadable(bytes) { + if (isNaN(bytes - 0)) { return ""; } - var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; + const units = ['b', 'kb', 'mb', 'gb', 'tb']; + let i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); + i = Math.min(i, units.length - 1); + return (bytes / Math.pow(1024, i)) + ' ' + units[i]; } \ No newline at end of file diff --git a/radicale/web/jsconfig.json b/radicale/web/jsconfig.json index 06467fa5..a14b6171 100644 --- a/radicale/web/jsconfig.json +++ b/radicale/web/jsconfig.json @@ -2,7 +2,10 @@ "compilerOptions": { "module": "CommonJS", "target": "ES6", - "checkJs": true + "checkJs": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": [ "internal_data/**/*.js"