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 }) }}

- + + 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 @@ + + + + + + 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 @@ + + + + + + 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 @@ + + + + + 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/*"