diff --git a/api/src/api/storage.ts b/api/src/api/storage.ts
index d64f8771e..b6b1585d4 100644
--- a/api/src/api/storage.ts
+++ b/api/src/api/storage.ts
@@ -118,4 +118,20 @@ export interface Storage {
* @since 0.11
*/
delete(page: string): Promise<{ success: boolean; error?: string }>;
+
+ /**
+ * Move a page.
+ *
+ * @param page - the page to move
+ * @param newPage - the new location for the page
+ * @param preserveChildren - whether to move children
+ * @returns true if the move was successful, false with the reason otherwise
+ *
+ * @since 0.14
+ */
+ move(
+ page: string,
+ newPage: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }>;
}
diff --git a/core/backends/backend-api/src/abstractStorage.ts b/core/backends/backend-api/src/abstractStorage.ts
index 68e4dbc15..97ce7be0f 100644
--- a/core/backends/backend-api/src/abstractStorage.ts
+++ b/core/backends/backend-api/src/abstractStorage.ts
@@ -124,4 +124,20 @@ export abstract class AbstractStorage implements Storage {
* @since 0.11
*/
abstract delete(page: string): Promise<{ success: boolean; error?: string }>;
+
+ /**
+ * Move a page.
+ *
+ * @param page - the page to move
+ * @param newPage - the new location for the page
+ * @param preserveChildren - whether to move children
+ * @returns true if the move was successful, false with the reason otherwise
+ *
+ * @since 0.14
+ */
+ abstract move(
+ page: string,
+ newPage: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }>;
}
diff --git a/core/backends/backend-dexie/src/wrappingOfflineStorage.ts b/core/backends/backend-dexie/src/wrappingOfflineStorage.ts
index 4769b955f..4d2501410 100644
--- a/core/backends/backend-dexie/src/wrappingOfflineStorage.ts
+++ b/core/backends/backend-dexie/src/wrappingOfflineStorage.ts
@@ -221,4 +221,12 @@ export class WrappingOfflineStorage implements WrappingStorage {
async delete(page: string): Promise<{ success: boolean; error?: string }> {
return this.storage.delete(page);
}
+
+ async move(
+ page: string,
+ newPage: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }> {
+ return this.storage.move(page, newPage, preserveChildren);
+ }
}
diff --git a/core/backends/backend-github/src/githubStorage.ts b/core/backends/backend-github/src/githubStorage.ts
index d381b480a..820e75b32 100644
--- a/core/backends/backend-github/src/githubStorage.ts
+++ b/core/backends/backend-github/src/githubStorage.ts
@@ -143,4 +143,9 @@ export class GitHubStorage extends AbstractStorage {
// TODO: to be implemented
throw new Error("Delete not supported");
}
+
+ async move(): Promise<{ success: boolean; error?: string }> {
+ // TODO: to be implemented in CRISTAL-436.
+ throw new Error("Move not supported");
+ }
}
diff --git a/core/backends/backend-nextcloud/src/nextcloudStorage.ts b/core/backends/backend-nextcloud/src/nextcloudStorage.ts
index f181aebf4..cdfc8a590 100644
--- a/core/backends/backend-nextcloud/src/nextcloudStorage.ts
+++ b/core/backends/backend-nextcloud/src/nextcloudStorage.ts
@@ -286,6 +286,11 @@ export class NextcloudStorage extends AbstractStorage {
return success;
}
+ async move(): Promise<{ success: boolean; error?: string }> {
+ // TODO: to be implemented in CRISTAL-435.
+ throw new Error("Move not supported");
+ }
+
async isStorageReady(): Promise {
return true;
}
diff --git a/core/backends/backend-xwiki/src/xwikiStorage.ts b/core/backends/backend-xwiki/src/xwikiStorage.ts
index 4be0089e4..29ef7cf48 100644
--- a/core/backends/backend-xwiki/src/xwikiStorage.ts
+++ b/core/backends/backend-xwiki/src/xwikiStorage.ts
@@ -374,6 +374,11 @@ export class XWikiStorage extends AbstractStorage {
return success;
}
+ async move(): Promise<{ success: boolean; error?: string }> {
+ // TODO: to be implemented in CRISTAL-434.
+ throw new Error("Move not supported");
+ }
+
private async getCredentials(): Promise<{ Authorization?: string }> {
const authorizationHeader = await this.authenticationManagerProvider
.get()
diff --git a/core/page-actions/page-actions-api/src/index.ts b/core/page-actions/page-actions-api/src/index.ts
index f5303ce8c..4d180f786 100644
--- a/core/page-actions/page-actions-api/src/index.ts
+++ b/core/page-actions/page-actions-api/src/index.ts
@@ -77,6 +77,12 @@ interface PageAction {
*/
order: number;
+ /**
+ * Compute if the page action should be displayed.
+ * @since 0.14
+ */
+ enabled(): Promise;
+
/**
* Get a Vue component for this action.
* The component should handle the following props:
@@ -97,7 +103,7 @@ interface PageActionService {
*
* @param categoryId - the id of the category
*/
- list(categoryId: string): PageAction[];
+ list(categoryId: string): Promise;
}
/**
diff --git a/core/page-actions/page-actions-default/src/DefaultPageActionService.ts b/core/page-actions/page-actions-default/src/DefaultPageActionService.ts
index cb5fa751e..b60da48ee 100644
--- a/core/page-actions/page-actions-default/src/DefaultPageActionService.ts
+++ b/core/page-actions/page-actions-default/src/DefaultPageActionService.ts
@@ -21,7 +21,7 @@
import { PageAction, PageActionService } from "@xwiki/cristal-page-actions-api";
import { injectable, multiInject } from "inversify";
-import { filter, sortBy } from "lodash";
+import { sortBy } from "lodash";
/**
* @since 0.11
@@ -30,11 +30,20 @@ import { filter, sortBy } from "lodash";
class DefaultPageActionService implements PageActionService {
constructor(
@multiInject("PageAction")
- private action: PageAction[],
+ private actions: PageAction[],
) {}
- list(categoryId: string): PageAction[] {
- return sortBy(filter(this.action, { categoryId: categoryId }), ["order"]);
+ async list(categoryId: string): Promise {
+ const enabledActions: boolean[] = await Promise.all(
+ this.actions.map(
+ async (action) =>
+ (await action.enabled()) && action.categoryId == categoryId,
+ ),
+ );
+ return sortBy(
+ this.actions.filter((_, i) => enabledActions[i]),
+ ["order"],
+ );
}
}
diff --git a/core/page-actions/page-actions-ui/langs/translation-en.json b/core/page-actions/page-actions-ui/langs/translation-en.json
index b1806ffa5..5ac78eaa9 100644
--- a/core/page-actions/page-actions-ui/langs/translation-en.json
+++ b/core/page-actions/page-actions-ui/langs/translation-en.json
@@ -1,8 +1,24 @@
{
"page.action.category.page.management.title": "Manage",
"page.action.action.delete.page.title": "Delete",
+ "page.action.action.delete.page.cancel": "Cancel",
"page.action.action.delete.page.dialog.title": "Delete Page",
"page.action.action.delete.page.success": "The page \"{page}\" has been deleted.",
"page.action.action.delete.page.error": "Failed to delete the page \"{page}\". Reason: [{reason}]",
- "page.action.action.delete.page.confirm": "This operation will delete the page \"{page}\". Are you sure?"
+ "page.action.action.delete.page.confirm": "This operation will delete the page \"{page}\". Are you sure?",
+ "page.action.action.move.page.title": "Move",
+ "page.action.action.move.page.cancel": "Cancel",
+ "page.action.action.move.page.dialog.title": "Move Page",
+ "page.action.action.move.page.alert.content": "The page {pageName} already exists.",
+ "page.action.action.move.page.preserve.children.label": "Preserve Children",
+ "page.action.action.move.page.preserve.children.help": "Preserve child pages by updating their paths and moving them to the new location.",
+ "page.action.action.move.page.success": "Page \"{page}\" has been moved to \"{newPage}\".",
+ "page.action.action.move.page.error": "Failed to move page \"{page}\". Reason: [{reason}]",
+ "page.action.action.rename.page.title": "Rename",
+ "page.action.action.rename.page.cancel": "Cancel",
+ "page.action.action.rename.page.dialog.title": "Rename Page",
+ "page.action.action.rename.page.alert.content": "Page {pageName} already exists.",
+ "page.action.action.rename.page.name.label": "Name",
+ "page.action.action.rename.page.success": "Page \"{page}\" has been renamed to \"{newPage}\".",
+ "page.action.action.rename.page.error": "Failed to rename page \"{page}\". Reason: [{reason}]"
}
diff --git a/core/page-actions/page-actions-ui/langs/translation-fr.json b/core/page-actions/page-actions-ui/langs/translation-fr.json
index 9a9ee72f7..af036fcf6 100644
--- a/core/page-actions/page-actions-ui/langs/translation-fr.json
+++ b/core/page-actions/page-actions-ui/langs/translation-fr.json
@@ -1,8 +1,24 @@
{
"page.action.category.page.management.title": "Gérer",
"page.action.action.delete.page.title": "Supprimer",
+ "page.action.action.delete.page.cancel": "Annuler",
"page.action.action.delete.page.dialog.title": "Supprimer la page",
"page.action.action.delete.page.success": "La page \"{page}\" a été supprimée.",
"page.action.action.delete.page.error": "La suppression de la page \"{page}\" a échoué. Raison : [{reason}]",
- "page.action.action.delete.page.confirm": "Cette opération supprimera la page \"{page}\". Voulez-vous continuer ?"
+ "page.action.action.delete.page.confirm": "Cette opération supprimera la page \"{page}\". Voulez-vous continuer ?",
+ "page.action.action.move.page.title": "Déplacer",
+ "page.action.action.move.page.cancel": "Annuler",
+ "page.action.action.move.page.dialog.title": "Déplacer la page",
+ "page.action.action.move.page.alert.content": "La page {pageName} existe déjà.",
+ "page.action.action.move.page.preserve.children.label": "Préserver les enfants",
+ "page.action.action.move.page.preserve.children.help": "Préserver les pages enfants en mettant à jour leurs chemins et en les déplaçant vers le nouvel emplacement.",
+ "page.action.action.move.page.success": "La page \"{page}\" a été déplacée vers \"{newPage}\".",
+ "page.action.action.move.page.error": "Le déplacement de la page \"{page}\" a échoué. Raison : [{reason}]",
+ "page.action.action.rename.page.title": "Renommer",
+ "page.action.action.rename.page.cancel": "Annuler",
+ "page.action.action.rename.page.dialog.title": "Renommer la page",
+ "page.action.action.rename.page.alert.content": "La page {pageName} existe déjà.",
+ "page.action.action.rename.page.name.label": "Nom",
+ "page.action.action.rename.page.success": "La page \"{page}\" a été renommée en \"{newPage}\".",
+ "page.action.action.rename.page.error": "Le renommage de la page \"{page}\" a échoué. Raison : [{reason}]"
}
diff --git a/core/page-actions/page-actions-ui/package.json b/core/page-actions/page-actions-ui/package.json
index a6c749672..a6ad7e3dc 100644
--- a/core/page-actions/page-actions-ui/package.json
+++ b/core/page-actions/page-actions-ui/package.json
@@ -29,7 +29,11 @@
"@xwiki/cristal-document-api": "workspace:*",
"@xwiki/cristal-hierarchy-api": "workspace:*",
"@xwiki/cristal-icons": "workspace:*",
+ "@xwiki/cristal-model-api": "workspace:*",
+ "@xwiki/cristal-model-reference-api": "workspace:*",
+ "@xwiki/cristal-navigation-tree-api": "workspace:*",
"@xwiki/cristal-page-actions-api": "workspace:*",
+ "@xwiki/cristal-rename-api": "workspace:*",
"inversify": "6.2.1",
"vue": "3.5.13",
"vue-i18n": "11.0.1"
diff --git a/core/page-actions/page-actions-ui/src/PageManagement.ts b/core/page-actions/page-actions-ui/src/PageManagement.ts
index 52a793827..d12dd3d11 100644
--- a/core/page-actions/page-actions-ui/src/PageManagement.ts
+++ b/core/page-actions/page-actions-ui/src/PageManagement.ts
@@ -19,10 +19,10 @@
*/
import messages from "./translations";
-import DeletePage from "./vue/DeletePage.vue";
import { AbstractPageActionCategory } from "@xwiki/cristal-page-actions-api";
-import { injectable } from "inversify";
+import { inject, injectable } from "inversify";
import type { PageAction } from "@xwiki/cristal-page-actions-api";
+import type { PageRenameManagerProvider } from "@xwiki/cristal-rename-api";
import type { Component } from "vue";
const PAGE_MANAGEMENT_ID: string = "page-management";
@@ -38,15 +38,72 @@ class PageManagementActionCategory extends AbstractPageActionCategory {
order = 1000;
}
+/**
+ * {@link PageAction} to change the parent of a page.
+ * @since 0.14
+ */
+@injectable()
+class PageMoveAction implements PageAction {
+ constructor(
+ @inject("PageRenameManagerProvider")
+ private readonly pageRenameManagerProvider: PageRenameManagerProvider,
+ ) {}
+
+ id = "page-move";
+ categoryId: string = PAGE_MANAGEMENT_ID;
+ order = 3000;
+
+ async enabled(): Promise {
+ return this.pageRenameManagerProvider.has();
+ }
+
+ async component(): Promise {
+ return (await import("./vue/MovePage.vue")).default;
+ }
+}
+
+/**
+ * {@link PageAction} to change the name part of a page reference.
+ * @since 0.14
+ */
+@injectable()
+class PageRenameAction implements PageAction {
+ constructor(
+ @inject("PageRenameManagerProvider")
+ private readonly pageRenameManagerProvider: PageRenameManagerProvider,
+ ) {}
+
+ id = "page-rename";
+ categoryId: string = PAGE_MANAGEMENT_ID;
+ order = 4000;
+
+ async enabled(): Promise {
+ return this.pageRenameManagerProvider.has();
+ }
+
+ async component(): Promise {
+ return (await import("./vue/RenamePage.vue")).default;
+ }
+}
+
@injectable()
class PageDeleteAction implements PageAction {
id = "page-delete";
categoryId: string = PAGE_MANAGEMENT_ID;
order = 5000;
+ async enabled(): Promise {
+ return true;
+ }
+
async component(): Promise {
- return DeletePage;
+ return (await import("./vue/DeletePage.vue")).default;
}
}
-export { PageDeleteAction, PageManagementActionCategory };
+export {
+ PageDeleteAction,
+ PageManagementActionCategory,
+ PageMoveAction,
+ PageRenameAction,
+};
diff --git a/core/page-actions/page-actions-ui/src/index.ts b/core/page-actions/page-actions-ui/src/index.ts
index 9aca5feb8..49fdb2f21 100644
--- a/core/page-actions/page-actions-ui/src/index.ts
+++ b/core/page-actions/page-actions-ui/src/index.ts
@@ -21,6 +21,8 @@
import {
PageDeleteAction,
PageManagementActionCategory,
+ PageMoveAction,
+ PageRenameAction,
} from "./PageManagement";
import PageActions from "./vue/PageActions.vue";
import type {
@@ -35,6 +37,14 @@ class ComponentInit {
.bind("PageActionCategory")
.to(PageManagementActionCategory)
.whenTargetIsDefault();
+ container
+ .bind("PageAction")
+ .to(PageMoveAction)
+ .whenTargetIsDefault();
+ container
+ .bind("PageAction")
+ .to(PageRenameAction)
+ .whenTargetIsDefault();
container
.bind("PageAction")
.to(PageDeleteAction)
diff --git a/core/page-actions/page-actions-ui/src/vue/DeletePage.vue b/core/page-actions/page-actions-ui/src/vue/DeletePage.vue
index ef832f774..595914328 100644
--- a/core/page-actions/page-actions-ui/src/vue/DeletePage.vue
+++ b/core/page-actions/page-actions-ui/src/vue/DeletePage.vue
@@ -105,7 +105,12 @@ async function deletePage() {
t("page.action.action.delete.page.confirm", { page: currentPageName })
}}
-
+
+
+
+ {{ t("page.action.action.delete.page.cancel") }}
+
+
{{ t("page.action.action.delete.page.title") }}
diff --git a/core/page-actions/page-actions-ui/src/vue/MovePage.vue b/core/page-actions/page-actions-ui/src/vue/MovePage.vue
new file mode 100644
index 000000000..474319505
--- /dev/null
+++ b/core/page-actions/page-actions-ui/src/vue/MovePage.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+ {{ t("page.action.action.move.page.title") }}
+
+
+
+
+
+ {{ newDocumentReference }}
+
+
+
+
+
+
+
+ {{ t("page.action.action.move.page.cancel") }}
+
+
+ {{ t("page.action.action.move.page.title") }}
+
+
+
+
+
+
diff --git a/core/page-actions/page-actions-ui/src/vue/PageActionsCategory.vue b/core/page-actions/page-actions-ui/src/vue/PageActionsCategory.vue
index 330c8f26d..022e2e724 100644
--- a/core/page-actions/page-actions-ui/src/vue/PageActionsCategory.vue
+++ b/core/page-actions/page-actions-ui/src/vue/PageActionsCategory.vue
@@ -43,7 +43,7 @@ const actions: Ref<{ action: PageAction; component: ShallowRef }[]> =
ref([]);
onMounted(async () => {
- for (const currAction of actionService.list(props.category.id)) {
+ for (const currAction of await actionService.list(props.category.id)) {
actions.value.push({
action: currAction,
component: shallowRef(await currAction.component()),
diff --git a/core/page-actions/page-actions-ui/src/vue/RenamePage.vue b/core/page-actions/page-actions-ui/src/vue/RenamePage.vue
new file mode 100644
index 000000000..acf22a7e7
--- /dev/null
+++ b/core/page-actions/page-actions-ui/src/vue/RenamePage.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+ {{ t("page.action.action.rename.page.title") }}
+
+
+
+
+
+ {{ newDocumentReference }}
+
+
+
+
+
+
+
+
+
+ {{ t("page.action.action.rename.page.cancel") }}
+
+
+ {{ t("page.action.action.rename.page.title") }}
+
+
+
+
+
+
diff --git a/core/page-actions/page-actions-ui/tsdoc.json b/core/page-actions/page-actions-ui/tsdoc.json
new file mode 100644
index 000000000..81c5a8a2a
--- /dev/null
+++ b/core/page-actions/page-actions-ui/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../../tsdoc.json"]
+}
diff --git a/core/rename/rename-api/package.json b/core/rename/rename-api/package.json
new file mode 100644
index 000000000..66cbfd74a
--- /dev/null
+++ b/core/rename/rename-api/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@xwiki/cristal-rename-api",
+ "version": "0.13.0",
+ "license": "LGPL 2.1",
+ "author": "XWiki Org Community ",
+ "homepage": "https://cristal.xwiki.org/",
+ "repository": {
+ "type": "git",
+ "directory": "core/rename/rename-api",
+ "url": "https://github.com/xwiki-contrib/cristal/"
+ },
+ "bugs": {
+ "url": "https://jira.xwiki.org/projects/CRISTAL/"
+ },
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "main": "./src/index.ts",
+ "scripts": {
+ "build": "tsc --project tsconfig.json && vite build",
+ "clean": "rimraf dist",
+ "lint": "eslint \"./src/**/*.{ts,tsx,vue}\" --max-warnings=0"
+ },
+ "dependencies": {
+ "@xwiki/cristal-api": "workspace:*"
+ },
+ "devDependencies": {
+ "@xwiki/cristal-dev-config": "workspace:*",
+ "typescript": "5.7.3",
+ "vite": "6.0.11"
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "import": "./dist/index.es.js",
+ "require": "./dist/index.umd.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.es.js",
+ "types": "./dist/index.d.ts"
+ }
+}
diff --git a/core/rename/rename-api/src/index.ts b/core/rename/rename-api/src/index.ts
new file mode 100644
index 000000000..fee9baf78
--- /dev/null
+++ b/core/rename/rename-api/src/index.ts
@@ -0,0 +1,77 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import type { PageData } from "@xwiki/cristal-api";
+
+/**
+ * A PageRenameManager can handle page rename operations.
+ *
+ * @since 0.14
+ **/
+interface PageRenameManager {
+ /**
+ * Change the reference of a given page.
+ *
+ * @param pageData - the page for which to get the revisions
+ * @param newReference - the new reference for the page
+ * @param preserveChildren - whether to also affect children
+ * @returns true if this was successful, false with the reason otherwise
+ */
+ updateReference(
+ page: PageData,
+ newReference: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }>;
+
+ /* TODO: Fix CRISTAL-84 and add operations to update backlinks and set-up
+ automatic redirects. */
+}
+
+/**
+ * A PageRenameManagerProvider returns the instance of {@link PageRenameManager}
+ * matching the current wiki configuration.
+ *
+ * @since 0.14
+ **/
+interface PageRenameManagerProvider {
+ /**
+ * Check whether an instance of PageRenameManager matching the current wiki
+ * configuration exists.
+ *
+ * @returns whether or not an instance exists
+ */
+ has(): boolean;
+
+ /**
+ * Return the instance of PageRenameManager matching the current wiki
+ * configuration.
+ *
+ * @returns the instance of PageRenameManager
+ */
+ get(): PageRenameManager;
+}
+
+/**
+ * The component id of PageRenameManager.
+ * @since 0.14
+ */
+const name = "PageRenameManager";
+
+export { type PageRenameManager, type PageRenameManagerProvider, name };
diff --git a/core/rename/rename-api/tsconfig.json b/core/rename/rename-api/tsconfig.json
new file mode 100644
index 000000000..ed596594a
--- /dev/null
+++ b/core/rename/rename-api/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "resolveJsonModule": true
+ },
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "./src/index.ts",
+ "./src/**/*"
+ ],
+ "exclude": [
+ "**/*.spec.ts",
+ "**/*.test.ts"
+ ]
+}
diff --git a/core/rename/rename-api/tsdoc.json b/core/rename/rename-api/tsdoc.json
new file mode 100644
index 000000000..81c5a8a2a
--- /dev/null
+++ b/core/rename/rename-api/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../../tsdoc.json"]
+}
diff --git a/core/rename/rename-api/vite.config.ts b/core/rename/rename-api/vite.config.ts
new file mode 100644
index 000000000..c31aed033
--- /dev/null
+++ b/core/rename/rename-api/vite.config.ts
@@ -0,0 +1,23 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { generateConfig } from "../../../vite.config";
+
+export default generateConfig(import.meta.url);
diff --git a/core/rename/rename-default/package.json b/core/rename/rename-default/package.json
new file mode 100644
index 000000000..a55514fe1
--- /dev/null
+++ b/core/rename/rename-default/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@xwiki/cristal-rename-default",
+ "version": "0.13.0",
+ "license": "LGPL 2.1",
+ "author": "XWiki Org Community ",
+ "homepage": "https://cristal.xwiki.org/",
+ "repository": {
+ "type": "git",
+ "directory": "core/rename/rename-default",
+ "url": "https://github.com/xwiki-contrib/cristal/"
+ },
+ "bugs": {
+ "url": "https://jira.xwiki.org/projects/CRISTAL/"
+ },
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "main": "./src/index.ts",
+ "scripts": {
+ "build": "tsc --project tsconfig.json && vite build",
+ "clean": "rimraf dist",
+ "lint": "eslint \"./src/**/*.{ts,tsx,vue}\" --max-warnings=0"
+ },
+ "dependencies": {
+ "@xwiki/cristal-api": "workspace:*",
+ "@xwiki/cristal-rename-api": "workspace:*",
+ "inversify": "6.2.1"
+ },
+ "peerDependencies": {
+ "reflect-metadata": "0.2.2"
+ },
+ "devDependencies": {
+ "@xwiki/cristal-dev-config": "workspace:*",
+ "reflect-metadata": "0.2.2",
+ "typescript": "5.7.3",
+ "vite": "6.0.11"
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "import": "./dist/index.es.js",
+ "require": "./dist/index.umd.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.es.js",
+ "types": "./dist/index.d.ts"
+ }
+}
diff --git a/core/rename/rename-default/src/components/componentsInit.ts b/core/rename/rename-default/src/components/componentsInit.ts
new file mode 100644
index 000000000..b4fa35ab5
--- /dev/null
+++ b/core/rename/rename-default/src/components/componentsInit.ts
@@ -0,0 +1,56 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { name as pageRenameManagerName } from "@xwiki/cristal-rename-api";
+import { inject, injectable } from "inversify";
+import type { CristalApp } from "@xwiki/cristal-api";
+import type {
+ PageRenameManager,
+ PageRenameManagerProvider,
+} from "@xwiki/cristal-rename-api";
+
+/**
+ * Default implementation for {@link PageRenameManagerProvider}.
+ *
+ * @since 0.14
+ **/
+@injectable()
+class DefaultPageRenameManagerProvider implements PageRenameManagerProvider {
+ constructor(
+ @inject("CristalApp") private readonly cristalApp: CristalApp,
+ ) {}
+
+ has(): boolean {
+ const container = this.cristalApp.getContainer();
+ const wikiConfigType = this.cristalApp.getWikiConfig().getType();
+ return container.isBoundNamed(pageRenameManagerName, wikiConfigType);
+ }
+
+ get(): PageRenameManager {
+ const container = this.cristalApp.getContainer();
+ const wikiConfigType = this.cristalApp.getWikiConfig().getType();
+ return container.getNamed(
+ pageRenameManagerName,
+ wikiConfigType,
+ );
+ }
+}
+
+export { DefaultPageRenameManagerProvider };
diff --git a/core/rename/rename-default/src/index.ts b/core/rename/rename-default/src/index.ts
new file mode 100644
index 000000000..d14750337
--- /dev/null
+++ b/core/rename/rename-default/src/index.ts
@@ -0,0 +1,34 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { DefaultPageRenameManagerProvider } from "./components/componentsInit";
+import { name as pageRenameManagerName } from "@xwiki/cristal-rename-api";
+import type { PageRenameManagerProvider } from "@xwiki/cristal-rename-api";
+import type { Container } from "inversify";
+
+export class ComponentInit {
+ constructor(container: Container) {
+ container
+ .bind(`${pageRenameManagerName}Provider`)
+ .to(DefaultPageRenameManagerProvider)
+ .inSingletonScope()
+ .whenTargetIsDefault();
+ }
+}
diff --git a/core/rename/rename-default/tsconfig.json b/core/rename/rename-default/tsconfig.json
new file mode 100644
index 000000000..ed596594a
--- /dev/null
+++ b/core/rename/rename-default/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "resolveJsonModule": true
+ },
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "./src/index.ts",
+ "./src/**/*"
+ ],
+ "exclude": [
+ "**/*.spec.ts",
+ "**/*.test.ts"
+ ]
+}
diff --git a/core/rename/rename-default/tsdoc.json b/core/rename/rename-default/tsdoc.json
new file mode 100644
index 000000000..81c5a8a2a
--- /dev/null
+++ b/core/rename/rename-default/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../../tsdoc.json"]
+}
diff --git a/core/rename/rename-default/vite.config.ts b/core/rename/rename-default/vite.config.ts
new file mode 100644
index 000000000..c31aed033
--- /dev/null
+++ b/core/rename/rename-default/vite.config.ts
@@ -0,0 +1,23 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { generateConfig } from "../../../vite.config";
+
+export default generateConfig(import.meta.url);
diff --git a/core/rename/rename-filesystem/package.json b/core/rename/rename-filesystem/package.json
new file mode 100644
index 000000000..b3c1e103c
--- /dev/null
+++ b/core/rename/rename-filesystem/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@xwiki/cristal-rename-filesystem",
+ "version": "0.13.0",
+ "license": "LGPL 2.1",
+ "author": "XWiki Org Community ",
+ "homepage": "https://cristal.xwiki.org/",
+ "repository": {
+ "type": "git",
+ "directory": "core/rename/rename-filesystem",
+ "url": "https://github.com/xwiki-contrib/cristal/"
+ },
+ "bugs": {
+ "url": "https://jira.xwiki.org/projects/CRISTAL/"
+ },
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "main": "./src/index.ts",
+ "scripts": {
+ "build": "tsc --project tsconfig.json && vite build",
+ "clean": "rimraf dist",
+ "lint": "eslint \"./src/**/*.{ts,tsx,vue}\" --max-warnings=0"
+ },
+ "dependencies": {
+ "@xwiki/cristal-api": "workspace:*",
+ "@xwiki/cristal-backend-api": "workspace:*",
+ "@xwiki/cristal-rename-api": "workspace:*",
+ "inversify": "6.2.1"
+ },
+ "peerDependencies": {
+ "reflect-metadata": "0.2.2"
+ },
+ "devDependencies": {
+ "@xwiki/cristal-dev-config": "workspace:*",
+ "reflect-metadata": "0.2.2",
+ "typescript": "5.7.3",
+ "vite": "6.0.11"
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "import": "./dist/index.es.js",
+ "require": "./dist/index.umd.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.es.js",
+ "types": "./dist/index.d.ts"
+ }
+}
diff --git a/core/rename/rename-filesystem/src/components/componentsInit.ts b/core/rename/rename-filesystem/src/components/componentsInit.ts
new file mode 100644
index 000000000..fe5a7c46b
--- /dev/null
+++ b/core/rename/rename-filesystem/src/components/componentsInit.ts
@@ -0,0 +1,59 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { inject, injectable } from "inversify";
+import type { PageData } from "@xwiki/cristal-api";
+import type { StorageProvider } from "@xwiki/cristal-backend-api";
+import type { PageRenameManager } from "@xwiki/cristal-rename-api";
+
+/**
+ * Implementation of {@link PageRenameManager} for FileSystem backend.
+ *
+ * @since 0.14
+ **/
+@injectable()
+class FileSystemPageRenameManager implements PageRenameManager {
+ constructor(
+ @inject("StorageProvider")
+ private readonly storageProvider: StorageProvider,
+ ) {}
+
+ /**
+ * Change the reference of a given page.
+ *
+ * @param pageData - the page for which to get the revisions
+ * @param newReference - the new reference for the page
+ * @param preserveChildren - whether to also affect children
+ * @returns true if this was successful, false with the reason otherwise
+ */
+ async updateReference(
+ page: PageData,
+ newReference: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }> {
+ return await this.storageProvider
+ .get()
+ .move(page.id, newReference, preserveChildren);
+ }
+
+ // TODO: add operations to update backlinks and set-up automatic redirects.
+}
+
+export { FileSystemPageRenameManager };
diff --git a/core/rename/rename-filesystem/src/index.ts b/core/rename/rename-filesystem/src/index.ts
new file mode 100644
index 000000000..68648e276
--- /dev/null
+++ b/core/rename/rename-filesystem/src/index.ts
@@ -0,0 +1,34 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { FileSystemPageRenameManager } from "./components/componentsInit";
+import { name as pageRenameManagerName } from "@xwiki/cristal-rename-api";
+import type { PageRenameManager } from "@xwiki/cristal-rename-api";
+import type { Container } from "inversify";
+
+export class ComponentInit {
+ constructor(container: Container) {
+ container
+ .bind(pageRenameManagerName)
+ .to(FileSystemPageRenameManager)
+ .inSingletonScope()
+ .whenTargetNamed("FileSystem");
+ }
+}
diff --git a/core/rename/rename-filesystem/tsconfig.json b/core/rename/rename-filesystem/tsconfig.json
new file mode 100644
index 000000000..ed596594a
--- /dev/null
+++ b/core/rename/rename-filesystem/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "resolveJsonModule": true
+ },
+ "extends": "../../../tsconfig.json",
+ "include": [
+ "./src/index.ts",
+ "./src/**/*"
+ ],
+ "exclude": [
+ "**/*.spec.ts",
+ "**/*.test.ts"
+ ]
+}
diff --git a/core/rename/rename-filesystem/tsdoc.json b/core/rename/rename-filesystem/tsdoc.json
new file mode 100644
index 000000000..81c5a8a2a
--- /dev/null
+++ b/core/rename/rename-filesystem/tsdoc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
+ "extends": ["../../../tsdoc.json"]
+}
diff --git a/core/rename/rename-filesystem/vite.config.ts b/core/rename/rename-filesystem/vite.config.ts
new file mode 100644
index 000000000..c31aed033
--- /dev/null
+++ b/core/rename/rename-filesystem/vite.config.ts
@@ -0,0 +1,23 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+import { generateConfig } from "../../../vite.config";
+
+export default generateConfig(import.meta.url);
diff --git a/ds/api/src/XCheckbox.ts b/ds/api/src/XCheckbox.ts
new file mode 100644
index 000000000..977c6d57c
--- /dev/null
+++ b/ds/api/src/XCheckbox.ts
@@ -0,0 +1,30 @@
+/*
+ * See the LICENSE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+
+/**
+ * @since 0.14
+ */
+type CheckboxProps = {
+ label: string;
+ help: string;
+ modelValue?: boolean;
+};
+
+export type { CheckboxProps };
diff --git a/ds/api/src/index.ts b/ds/api/src/index.ts
index 7a9ba08e9..3355996ed 100644
--- a/ds/api/src/index.ts
+++ b/ds/api/src/index.ts
@@ -35,6 +35,7 @@ import type {
} from "./XBreadcrumb";
import type { BtnProps } from "./XBtn";
import type { CardProps } from "./XCard";
+import type { CheckboxProps } from "./XCheckbox";
import type { DialogProps } from "./XDialog";
import type { DividerProps } from "./XDivider";
import type { FileInputModel, FileInputProps } from "./XFileInput";
@@ -62,6 +63,7 @@ type AbstractElements = {
XBtn: DefineComponent;
XBreadcrumb: DefineComponent;
XCard: DefineComponent;
+ XCheckbox: DefineComponent;
XDialog: DefineComponent;
XDivider: DefineComponent;
XFileInput: DefineComponent;
@@ -90,6 +92,7 @@ export type {
BreadcrumbProps,
BtnProps,
CardProps,
+ CheckboxProps,
DialogProps,
DividerProps,
FileInputModel,
diff --git a/ds/shoelace/src/components/shoelaceDesignSystemLoader.ts b/ds/shoelace/src/components/shoelaceDesignSystemLoader.ts
index d4ead0021..cd7b4a56f 100644
--- a/ds/shoelace/src/components/shoelaceDesignSystemLoader.ts
+++ b/ds/shoelace/src/components/shoelaceDesignSystemLoader.ts
@@ -95,5 +95,10 @@ export class ShoelaceDesignSystemLoader implements DesignSystemLoader {
"XNavigationTree",
() => import("../vue/x-navigation-tree.vue"),
);
+ registerAsyncComponent(
+ app,
+ "XCheckbox",
+ () => import("../vue/form/x-checkbox.vue"),
+ );
}
}
diff --git a/ds/shoelace/src/vue/form/x-checkbox.vue b/ds/shoelace/src/vue/form/x-checkbox.vue
new file mode 100644
index 000000000..6488631cf
--- /dev/null
+++ b/ds/shoelace/src/vue/form/x-checkbox.vue
@@ -0,0 +1,42 @@
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/ds/shoelace/src/vue/x-dialog.vue b/ds/shoelace/src/vue/x-dialog.vue
index da144dfa5..8a519711a 100644
--- a/ds/shoelace/src/vue/x-dialog.vue
+++ b/ds/shoelace/src/vue/x-dialog.vue
@@ -39,25 +39,28 @@ const open = defineModel();
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/ds/shoelace/src/vue/x-navigation-tree-item.vue b/ds/shoelace/src/vue/x-navigation-tree-item.vue
index fbf998d87..975d3282f 100644
--- a/ds/shoelace/src/vue/x-navigation-tree-item.vue
+++ b/ds/shoelace/src/vue/x-navigation-tree-item.vue
@@ -153,6 +153,10 @@ async function onDocumentUpdate(parents: string[]) {
}
// New page
+ if (current.value?.lazy) {
+ // We don't do anything because this node will be loaded lazily anyway.
+ return;
+ }
const newItems = await treeSource.getChildNodes(props.node.id);
newItemsLoop: for (const newItem of newItems) {
for (const i of nodes.value.keys()) {
@@ -174,11 +178,12 @@ async function onDocumentUpdate(parents: string[]) {
>
{{ node.label }}
- {{ node.label }}
+ {{ node.label }}
diff --git a/ds/shoelace/src/vue/x-navigation-tree.vue b/ds/shoelace/src/vue/x-navigation-tree.vue
index e90d0f85c..817187c7e 100644
--- a/ds/shoelace/src/vue/x-navigation-tree.vue
+++ b/ds/shoelace/src/vue/x-navigation-tree.vue
@@ -156,6 +156,7 @@ async function onDocumentUpdate(page: PageData) {
}
rootNodes.value.push(newItem);
}
+ await expandTree();
}
@@ -172,14 +173,3 @@ async function onDocumentUpdate(page: PageData) {
-
-
diff --git a/ds/vuetify/src/components/vuetifyDesignSystemLoader.ts b/ds/vuetify/src/components/vuetifyDesignSystemLoader.ts
index 4055b5626..0043d5397 100644
--- a/ds/vuetify/src/components/vuetifyDesignSystemLoader.ts
+++ b/ds/vuetify/src/components/vuetifyDesignSystemLoader.ts
@@ -141,5 +141,10 @@ export class VuetifyDesignSystemLoader implements DesignSystemLoader {
"XNavigationTree",
() => import("../vue/x-navigation-tree.vue"),
);
+ registerAsyncComponent(
+ app,
+ "XCheckbox",
+ () => import("../vue/form/x-checkbox.vue"),
+ );
}
}
diff --git a/ds/vuetify/src/vue/form/x-checkbox.vue b/ds/vuetify/src/vue/form/x-checkbox.vue
new file mode 100644
index 000000000..ab86286c3
--- /dev/null
+++ b/ds/vuetify/src/vue/form/x-checkbox.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/ds/vuetify/src/vue/x-navigation-tree.vue b/ds/vuetify/src/vue/x-navigation-tree.vue
index 287bf1131..c4845d718 100644
--- a/ds/vuetify/src/vue/x-navigation-tree.vue
+++ b/ds/vuetify/src/vue/x-navigation-tree.vue
@@ -165,20 +165,28 @@ function clearSelection() {
// eslint-disable-next-line max-statements
async function onDocumentDelete(page: PageData) {
const parents = treeSource.getParentNodesId(page);
- let currentItems: TreeItem[] | undefined = rootNodes.value;
- while (currentItems) {
- for (const i of currentItems.keys()) {
- if (currentItems[i].id == parents[0]) {
+ let currentItem: TreeItem | undefined = undefined;
+ let currentItemChildren: TreeItem[] | undefined = rootNodes.value;
+ let notFound = false;
+
+ currentItemsLoop: while (currentItemChildren && !notFound) {
+ for (const i of currentItemChildren.keys()) {
+ if (currentItemChildren[i].id == parents[0]) {
if (parents.length == 1) {
- currentItems.splice(i, 1);
+ currentItemChildren.splice(i, 1);
+ if (currentItem && currentItemChildren.length == 0) {
+ currentItem!.children = undefined;
+ }
return;
} else {
- currentItems = currentItems[i].children;
+ currentItem = currentItemChildren[i];
+ currentItemChildren = currentItem.children;
parents.shift();
- break;
+ continue currentItemsLoop;
}
}
}
+ notFound = true;
}
}
diff --git a/electron/renderer/package.json b/electron/renderer/package.json
index 0eff8421b..cc3f790f9 100644
--- a/electron/renderer/package.json
+++ b/electron/renderer/package.json
@@ -29,6 +29,7 @@
"@xwiki/cristal-model-reference-filesystem": "workspace:*",
"@xwiki/cristal-model-remote-url-filesystem-default": "workspace:*",
"@xwiki/cristal-navigation-tree-filesystem": "workspace:*",
+ "@xwiki/cristal-rename-filesystem": "workspace:*",
"inversify": "6.2.1",
"reflect-metadata": "0.2.2",
"vue": "3.5.13"
diff --git a/electron/renderer/src/index.ts b/electron/renderer/src/index.ts
index 20c081948..5777b60dc 100644
--- a/electron/renderer/src/index.ts
+++ b/electron/renderer/src/index.ts
@@ -29,6 +29,7 @@ import { ComponentInit as FileSystemLinkSuggestComponentInit } from "@xwiki/cris
import { ComponentInit as ModelReferenceFilesystemComponentInit } from "@xwiki/cristal-model-reference-filesystem";
import { ComponentInit as ModelRemoteURLFilesystemComponentInit } from "@xwiki/cristal-model-remote-url-filesystem-default";
import { ComponentInit as FileSystemNavigationTreeComponentInit } from "@xwiki/cristal-navigation-tree-filesystem";
+import { ComponentInit as FileSystemRenameComponentInit } from "@xwiki/cristal-rename-filesystem";
import { Container } from "inversify";
CristalAppLoader.init(
@@ -56,5 +57,6 @@ CristalAppLoader.init(
new ModelReferenceFilesystemComponentInit(container);
new ModelRemoteURLFilesystemComponentInit(container);
new FileSystemLinkSuggestComponentInit(container);
+ new FileSystemRenameComponentInit(container);
},
);
diff --git a/electron/storage/src/components/fileSystemStorage.ts b/electron/storage/src/components/fileSystemStorage.ts
index 34d06e707..aca0c1a69 100644
--- a/electron/storage/src/components/fileSystemStorage.ts
+++ b/electron/storage/src/components/fileSystemStorage.ts
@@ -107,6 +107,21 @@ export default class FileSystemStorage extends AbstractStorage {
}
}
+ async move(
+ page: string,
+ newPage: string,
+ preserveChildren: boolean,
+ ): Promise<{ success: boolean; error?: string }> {
+ try {
+ const path = await fileSystemStorage.resolvePath(page);
+ const newPath = await fileSystemStorage.resolvePath(newPage);
+ await fileSystemStorage.movePage(path, newPath, preserveChildren);
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+ }
+
private async saveAttachment(page: string, file: File): Promise {
const path = await fileSystemStorage.resolveAttachmentPath(page, file.name);
await fileSystemStorage.saveAttachment(path, file);
diff --git a/electron/storage/src/electron/main/index.ts b/electron/storage/src/electron/main/index.ts
index 66501f12f..9faa67036 100644
--- a/electron/storage/src/electron/main/index.ts
+++ b/electron/storage/src/electron/main/index.ts
@@ -359,6 +359,107 @@ async function createMinimalContent() {
);
}
+async function movePage(
+ path: string,
+ newPath: string,
+ preserveChildren: boolean,
+): Promise {
+ if (preserveChildren) {
+ const directory = dirname(path);
+ const newDirectory = dirname(newPath);
+
+ // TODO: Fix CRISTAL-437 instead of doing this check here.
+ const success: boolean = await movePageDeep(directory, newDirectory);
+ if (!success) {
+ throw "Some child pages were not moved because they overlapped with children of the target.";
+ }
+ } else {
+ await movePageSingle(path, newPath);
+ }
+}
+
+async function movePageDeep(
+ directory: string,
+ newDirectory: string,
+): Promise {
+ let success = true;
+
+ // We start by removing the directory from the arborescence.
+ const tempPath = resolvePath(`temp-${Math.random().toString(16).slice(2)}`);
+ await fs.promises.rename(directory, tempPath);
+
+ await fs.promises.mkdir(dirname(newDirectory), { recursive: true });
+
+ if (await pathExists(newDirectory)) {
+ // If the target directory already exists, we move the content instead.
+ success = await movePageDeepRecursive(tempPath, newDirectory);
+ // We put the (possible) unmoved content back to where it was.
+ await fs.promises.rename(tempPath, directory);
+ } else {
+ await fs.promises.rename(tempPath, newDirectory);
+ }
+ await cleanEmptyArborescence(directory);
+
+ return success;
+}
+
+async function movePageDeepRecursive(
+ directory: string,
+ newDirectory: string,
+): Promise {
+ let success: boolean = true;
+
+ for (const file of await fs.promises.readdir(directory)) {
+ const filePath = join(directory, file);
+ const newFilePath = join(newDirectory, file);
+ if (await isDirectory(filePath)) {
+ success =
+ (await movePageDeepRecursive(filePath, join(newDirectory, file))) &&
+ success;
+ await cleanEmptyArborescence(filePath);
+ } else if (!(await pathExists(newFilePath))) {
+ await fs.promises.rename(filePath, newFilePath);
+ } else {
+ success = false;
+ }
+ }
+
+ return success;
+}
+
+async function movePageSingle(path: string, newPath: string) {
+ const directory = dirname(path);
+ const newDirectory = dirname(newPath);
+
+ await fs.promises.mkdir(newDirectory, { recursive: true });
+ await fs.promises.rename(path, newPath);
+
+ if (await isDirectory(`${directory}/attachments`)) {
+ await fs.promises.rename(
+ `${directory}/attachments`,
+ `${newDirectory}/attachments`,
+ );
+ }
+
+ await cleanEmptyArborescence(directory);
+}
+
+/**
+ * Recursively delete empty directories, starting from the leaf.
+ * @param directory - the starting leaf directory
+ * @since 0.14
+ */
+async function cleanEmptyArborescence(directory: string): Promise {
+ if (await isWithin(HOME_PATH_FULL, directory)) {
+ if (!(await pathExists(directory))) {
+ await cleanEmptyArborescence(dirname(directory));
+ } else if ((await isDirectory(directory)) && (await isEmpty(directory))) {
+ await fs.promises.rmdir(directory);
+ await cleanEmptyArborescence(dirname(directory));
+ }
+ }
+}
+
// TODO: reduce the number of statements in the following method and reactivate the disabled eslint rule.
// eslint-disable-next-line max-statements
export default async function load(): Promise {
@@ -415,4 +516,7 @@ export default async function load(): Promise {
ipcMain.handle("search", (event, { query, type, mimetype }) => {
return search(query, type, mimetype);
});
+ ipcMain.handle("movePage", (event, { path, newPath, preserveChildren }) => {
+ return movePage(path, newPath, preserveChildren);
+ });
}
diff --git a/electron/storage/src/electron/preload/apiTypes.ts b/electron/storage/src/electron/preload/apiTypes.ts
index a893e2682..fb0ac5933 100644
--- a/electron/storage/src/electron/preload/apiTypes.ts
+++ b/electron/storage/src/electron/preload/apiTypes.ts
@@ -60,4 +60,19 @@ export interface APITypes {
| { type: EntityType.DOCUMENT; value: PageData }
)[]
>;
+
+ /**
+ * Move a page.
+ *
+ * @param path - the path to the page to move
+ * @param newPath - the new path for the page
+ * @param preserveChildren - whether to move children
+ *
+ * @since 0.14
+ */
+ movePage(
+ path: string,
+ newPath: string,
+ preserveChildren: boolean,
+ ): Promise;
}
diff --git a/electron/storage/src/electron/preload/index.ts b/electron/storage/src/electron/preload/index.ts
index 8243bf515..19a04c97d 100644
--- a/electron/storage/src/electron/preload/index.ts
+++ b/electron/storage/src/electron/preload/index.ts
@@ -73,5 +73,12 @@ const api: APITypes = {
> {
return ipcRenderer.invoke("search", { query, type, mimetype });
},
+ movePage(
+ path: string,
+ newPath: string,
+ preserveChildren: boolean,
+ ): Promise {
+ return ipcRenderer.invoke("movePage", { path, newPath, preserveChildren });
+ },
};
contextBridge.exposeInMainWorld("fileSystemStorage", api);
diff --git a/lib/package.json b/lib/package.json
index 24a7bbad4..d08b3a262 100644
--- a/lib/package.json
+++ b/lib/package.json
@@ -73,6 +73,7 @@
"@xwiki/cristal-navigation-tree-xwiki": "workspace:*",
"@xwiki/cristal-page-actions-default": "workspace:*",
"@xwiki/cristal-page-actions-ui": "workspace:*",
+ "@xwiki/cristal-rename-default": "workspace:*",
"@xwiki/cristal-rendering": "workspace:*",
"@xwiki/cristal-sharedworker-impl": "workspace:*",
"@xwiki/cristal-skin": "workspace:*",
diff --git a/lib/src/defaultComponentsList.ts b/lib/src/defaultComponentsList.ts
index 119d3b809..5e0673e57 100644
--- a/lib/src/defaultComponentsList.ts
+++ b/lib/src/defaultComponentsList.ts
@@ -58,6 +58,7 @@ import { ComponentInit as NextcloudNavigationTreeComponentInit } from "@xwiki/cr
import { ComponentInit as XWikiNavigationTreeComponentInit } from "@xwiki/cristal-navigation-tree-xwiki";
import { ComponentInit as ActionsPagesComponentInit } from "@xwiki/cristal-page-actions-default";
import { ComponentInit as ActionsPagesUIComponentInit } from "@xwiki/cristal-page-actions-ui";
+import { ComponentInit as RenameComponentInit } from "@xwiki/cristal-rename-default";
import { ComponentInit as RenderingComponentInit } from "@xwiki/cristal-rendering";
import { ComponentInit as QueueWorkerComponentInit } from "@xwiki/cristal-sharedworker-impl";
import { ComponentInit as SkinComponentInit } from "@xwiki/cristal-skin";
@@ -116,4 +117,5 @@ export function defaultComponentsList(container: Container): void {
new ModelReferenceXWikiComponentInit(container);
new DateAPIComponentInit(container);
new MarkdownDefaultComponentInit(container);
+ new RenameComponentInit(container);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b575d38a..ea860493c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2080,9 +2080,21 @@ importers:
'@xwiki/cristal-icons':
specifier: workspace:*
version: link:../../icons
+ '@xwiki/cristal-model-api':
+ specifier: workspace:*
+ version: link:../../model/model-api
+ '@xwiki/cristal-model-reference-api':
+ specifier: workspace:*
+ version: link:../../model/model-reference/model-reference-api
+ '@xwiki/cristal-navigation-tree-api':
+ specifier: workspace:*
+ version: link:../../navigation-tree/navigation-tree-api
'@xwiki/cristal-page-actions-api':
specifier: workspace:*
version: link:../page-actions-api
+ '@xwiki/cristal-rename-api':
+ specifier: workspace:*
+ version: link:../../rename/rename-api
inversify:
specifier: 6.2.1
version: 6.2.1(reflect-metadata@0.2.2)
@@ -2112,6 +2124,75 @@ importers:
specifier: 2.2.0
version: 2.2.0(typescript@5.7.3)
+ core/rename/rename-api:
+ dependencies:
+ '@xwiki/cristal-api':
+ specifier: workspace:*
+ version: link:../../../api
+ devDependencies:
+ '@xwiki/cristal-dev-config':
+ specifier: workspace:*
+ version: link:../../../dev/config
+ typescript:
+ specifier: 5.7.3
+ version: 5.7.3
+ vite:
+ specifier: 6.0.11
+ version: 6.0.11(@types/node@22.10.7)(tsx@4.19.2)(yaml@2.6.1)
+
+ core/rename/rename-default:
+ dependencies:
+ '@xwiki/cristal-api':
+ specifier: workspace:*
+ version: link:../../../api
+ '@xwiki/cristal-rename-api':
+ specifier: workspace:*
+ version: link:../rename-api
+ inversify:
+ specifier: 6.2.1
+ version: 6.2.1(reflect-metadata@0.2.2)
+ devDependencies:
+ '@xwiki/cristal-dev-config':
+ specifier: workspace:*
+ version: link:../../../dev/config
+ reflect-metadata:
+ specifier: 0.2.2
+ version: 0.2.2
+ typescript:
+ specifier: 5.7.3
+ version: 5.7.3
+ vite:
+ specifier: 6.0.11
+ version: 6.0.11(@types/node@22.10.7)(tsx@4.19.2)(yaml@2.6.1)
+
+ core/rename/rename-filesystem:
+ dependencies:
+ '@xwiki/cristal-api':
+ specifier: workspace:*
+ version: link:../../../api
+ '@xwiki/cristal-backend-api':
+ specifier: workspace:*
+ version: link:../../backends/backend-api
+ '@xwiki/cristal-rename-api':
+ specifier: workspace:*
+ version: link:../rename-api
+ inversify:
+ specifier: 6.2.1
+ version: 6.2.1(reflect-metadata@0.2.2)
+ devDependencies:
+ '@xwiki/cristal-dev-config':
+ specifier: workspace:*
+ version: link:../../../dev/config
+ reflect-metadata:
+ specifier: 0.2.2
+ version: 0.2.2
+ typescript:
+ specifier: 5.7.3
+ version: 5.7.3
+ vite:
+ specifier: 6.0.11
+ version: 6.0.11(@types/node@22.10.7)(tsx@4.19.2)(yaml@2.6.1)
+
core/tiptap-extensions/tiptap-extension-image:
dependencies:
'@tiptap/extension-image':
@@ -2965,6 +3046,9 @@ importers:
'@xwiki/cristal-navigation-tree-filesystem':
specifier: workspace:*
version: link:../../core/navigation-tree/navigation-tree-filesystem
+ '@xwiki/cristal-rename-filesystem':
+ specifier: workspace:*
+ version: link:../../core/rename/rename-filesystem
inversify:
specifier: 6.2.1
version: 6.2.1(reflect-metadata@0.2.2)
@@ -3227,6 +3311,9 @@ importers:
'@xwiki/cristal-page-actions-ui':
specifier: workspace:*
version: link:../core/page-actions/page-actions-ui
+ '@xwiki/cristal-rename-default':
+ specifier: workspace:*
+ version: link:../core/rename/rename-default
'@xwiki/cristal-rendering':
specifier: workspace:*
version: link:../rendering/rendering
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3fd590579..5cfae6725 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -41,6 +41,7 @@ packages:
- "core/model/model-remote-url/model-remote-url-filesystem/*"
- "core/navigation-tree/*"
- "core/page-actions/*"
+ - "core/rename/*"
- "core/tiptap-extensions/*"
- "core/uiextension/*"
- "core/user/*"