diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java index feea5160..6e90e93d 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java @@ -158,4 +158,22 @@ private List siteMenus() { ) public void list_unpublished_pages() { } + + @MenuEntry( + parent = "toolMenu", + id = "search_pages", + name = "Search pages", + permissions = {Permissions.CONTENT_EDIT}, + position = 5, + scriptAction = @ScriptAction(module = "/manager/actions/page/search-pages") + ) + @ShortCut( + id = "search_pages", + title = "Search pages", + permissions = {Permissions.CONTENT_EDIT}, + section = "tools", + scriptAction = @ScriptAction(module = "/manager/actions/page/search-pages") + ) + public void search_pages() { + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java index 8e49039d..3aa35b73 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java @@ -25,6 +25,7 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; @@ -57,7 +58,42 @@ @Extension(UIRemoteMethodExtensionPoint.class) public class RemotePageEnpoints extends AbstractRemoteMethodeExtension { - @RemoteMethod(name = "pages.filter", permissions = {Permissions.CONTENT_EDIT}) + public record SearchResultDto(String uri, String title) { + } + + @RemoteMethod(name = "pages.search", permissions = {Permissions.CONTENT_EDIT}) + public Object searchPages (Map parameters) throws RPCException { + String query = ""; + + if (parameters.get("query") instanceof String stringValue) { + query = stringValue; + } + + final DB db = getContext().get(DBFeature.class).db(); + var contentBase = db.getFileSystem().contentBase(); + + var hits = db.getContent().searchByTitle(query).stream() + .map(node -> { + var contentFile = contentBase.resolve(node.uri()); + var url = PathUtil.toURL(contentFile, contentBase); + url = HTTPUtil.modifyUrl(url, context); + + String title = ""; + if (node.data().get(Constants.MetaFields.TITLE) instanceof String titleValue) { + title = titleValue; + } + + return new SearchResultDto(url, title); + }) + .toList(); + + Map result = new HashMap<>(); + result.put("result", hits); + + return result; + } + + @RemoteMethod(name = "pages.filter", permissions = {Permissions.CONTENT_EDIT}) public Object filterPages (Map parameters) throws RPCException { final DB db = getDB(parameters); diff --git a/modules/ui-module/src/main/resources/manager/actions/page/search-pages.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.d.ts new file mode 100644 index 00000000..053bf8a5 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.d.ts @@ -0,0 +1,24 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +interface SearchPagesActionOptions { +} +export declare const runAction: (options?: SearchPagesActionOptions) => Promise; +export {}; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js new file mode 100644 index 00000000..2f805b2b --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js @@ -0,0 +1,108 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { openModal } from '@cms/modules/modal.js'; +import { i18n } from '@cms/modules/localization.js'; +import { searchPages } from '@cms/modules/rpc/rpc-page'; +import { loadPreview } from '@cms/modules/preview.utils'; +const MIN_QUERY_LENGTH = 3; +const renderResultsHtml = (results, query) => { + if (query.trim().length < MIN_QUERY_LENGTH) { + return `

${i18n.t('page.search.minLength', 'Enter at least 3 characters and press Enter to search.')}

`; + } + if (results.length === 0) { + return `

${i18n.t('page.search.noResults', 'No pages found.')}

`; + } + return ` + + + + + + + + + + ${results.map(result => ` + + + + + + `).join('')} + +
${i18n.t('page.search.columnTitle', 'Title')}${i18n.t('page.search.columnUri', 'URI')}
${result.title}${result.uri} + + ${i18n.t('page.search.loadLink', 'Load')} + +
+ `; +}; +const state = { + modal: null +}; +const bindResultLinks = (resultsElement) => { + resultsElement.querySelectorAll('a[data-cms-page-uri]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + state.modal.hide(); + loadPreview(link.dataset.cmsPageUri || ''); + }); + }); +}; +const runSearch = async (query, resultsElement) => { + if (query.trim().length < MIN_QUERY_LENGTH) { + resultsElement.innerHTML = renderResultsHtml([], query); + return; + } + try { + const response = await searchPages({ query: query.trim() }); + resultsElement.innerHTML = renderResultsHtml(response.result, query); + bindResultLinks(resultsElement); + } + catch (e) { + resultsElement.innerHTML = `

${i18n.t('page.search.loadError', 'Could not search pages.')}

`; + } +}; +export const runAction = async (options = {}) => { + state.modal = openModal({ + title: i18n.t('page.search.title', 'Search pages'), + body: ` + +
+ `, + fullscreen: false, + onCancel: () => { }, + onOk: () => { }, + onShow: (modalElement) => { + const inputElement = modalElement.querySelector('#cms-search-pages-input'); + const resultsElement = modalElement.querySelector('#cms-search-pages-results'); + resultsElement.innerHTML = renderResultsHtml([], ''); + inputElement.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(inputElement.value, resultsElement); + } + }); + inputElement.focus(); + } + }); +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts index 1061b15e..8e0175fe 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts @@ -35,6 +35,14 @@ export namespace MODULE_LOCALIZATIONS { "page.unpublished.title": string; "page.unpublished.noPages": string; "page.unpublished.editLink": string; + "page.search.title": string; + "page.search.placeholder": string; + "page.search.minLength": string; + "page.search.noResults": string; + "page.search.columnTitle": string; + "page.search.columnUri": string; + "page.search.loadLink": string; + "page.search.loadError": string; "pagination.previous": string; "pagination.next": string; }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js index 84189b7c..77c895d5 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js @@ -35,6 +35,14 @@ export const MODULE_LOCALIZATIONS = { "page.unpublished.title": "Unveröffentlichte Seiten", "page.unpublished.noPages": "Keine unveröffentlichten Seiten gefunden.", "page.unpublished.editLink": "Bearbeiten", + "page.search.title": "Seiten suchen", + "page.search.placeholder": "Nach Titel suchen...", + "page.search.minLength": "Mindestens 3 Zeichen eingeben und Enter drücken, um zu suchen.", + "page.search.noResults": "Keine Seiten gefunden.", + "page.search.columnTitle": "Titel", + "page.search.columnUri": "URI", + "page.search.loadLink": "Laden", + "page.search.loadError": "Seiten konnten nicht durchsucht werden.", "pagination.previous": "Zurück", "pagination.next": "Weiter" } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.d.ts index d76c41e1..60adf1b6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.d.ts @@ -55,4 +55,15 @@ export interface FilterPagesResponse { } declare const filterPages: (options: FilterPagesOptions) => Promise; declare const deletePage: (options: any) => Promise; -export { createPage, deletePage, filterPages }; +export interface SearchPagesOptions { + query: string; +} +export interface SearchResultDto { + uri: string; + title: string; +} +export interface SearchPagesResponse { + result: SearchResultDto[]; +} +declare const searchPages: (options: SearchPagesOptions) => Promise; +export { createPage, deletePage, filterPages, searchPages }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.js index 5976bf42..6859ded6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-page.js @@ -40,4 +40,11 @@ const deletePage = async (options) => { }; return await executeRemoteCall(data); }; -export { createPage, deletePage, filterPages }; +const searchPages = async (options) => { + var data = { + method: "pages.search", + parameters: options + }; + return (await executeRemoteCall(data)).result; +}; +export { createPage, deletePage, filterPages, searchPages }; diff --git a/modules/ui-module/src/main/ts/src/actions/page/search-pages.ts b/modules/ui-module/src/main/ts/src/actions/page/search-pages.ts new file mode 100644 index 00000000..e26121c2 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/search-pages.ts @@ -0,0 +1,122 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { openModal } from '@cms/modules/modal.js' +import { i18n } from '@cms/modules/localization.js'; +import { searchPages, SearchResultDto } from '@cms/modules/rpc/rpc-page' +import { loadPreview } from '@cms/modules/preview.utils'; + +interface SearchPagesActionOptions { +} + +const MIN_QUERY_LENGTH = 3; + +const renderResultsHtml = (results: SearchResultDto[], query: string) => { + if (query.trim().length < MIN_QUERY_LENGTH) { + return `

${i18n.t('page.search.minLength', 'Enter at least 3 characters and press Enter to search.')}

`; + } + + if (results.length === 0) { + return `

${i18n.t('page.search.noResults', 'No pages found.')}

`; + } + + return ` + + + + + + + + + + ${results.map(result => ` + + + + + + `).join('')} + +
${i18n.t('page.search.columnTitle', 'Title')}${i18n.t('page.search.columnUri', 'URI')}
${result.title}${result.uri} + + ${i18n.t('page.search.loadLink', 'Load')} + +
+ `; +}; + +const state: any = { + modal: null +}; + +const bindResultLinks = (resultsElement: HTMLElement) => { + resultsElement.querySelectorAll('a[data-cms-page-uri]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + state.modal.hide(); + loadPreview((link as HTMLElement).dataset.cmsPageUri || ''); + }); + }); +}; + +const runSearch = async (query: string, resultsElement: HTMLElement) => { + if (query.trim().length < MIN_QUERY_LENGTH) { + resultsElement.innerHTML = renderResultsHtml([], query); + return; + } + + try { + const response = await searchPages({ query: query.trim() }); + resultsElement.innerHTML = renderResultsHtml(response.result, query); + bindResultLinks(resultsElement); + } catch (e) { + resultsElement.innerHTML = `

${i18n.t('page.search.loadError', 'Could not search pages.')}

`; + } +}; + +export const runAction = async (options: SearchPagesActionOptions = {}) => { + state.modal = openModal({ + title: i18n.t('page.search.title', 'Search pages'), + body: ` + +
+ `, + fullscreen: false, + onCancel: () => { /* Optional: handle cancel */ }, + onOk: () => { /* Optional: handle OK */ }, + onShow: (modalElement: any) => { + const inputElement = modalElement.querySelector('#cms-search-pages-input') as HTMLInputElement; + const resultsElement = modalElement.querySelector('#cms-search-pages-results') as HTMLElement; + + resultsElement.innerHTML = renderResultsHtml([], ''); + + inputElement.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(inputElement.value, resultsElement); + } + }); + + inputElement.focus(); + } + }); +}; diff --git a/modules/ui-module/src/main/ts/src/js/modules/localization-modules.js b/modules/ui-module/src/main/ts/src/js/modules/localization-modules.js index e3069588..cabc93d6 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/localization-modules.js +++ b/modules/ui-module/src/main/ts/src/js/modules/localization-modules.js @@ -36,6 +36,14 @@ export const MODULE_LOCALIZATIONS = { "page.unpublished.title": "Unveröffentlichte Seiten", "page.unpublished.noPages": "Keine unveröffentlichten Seiten gefunden.", "page.unpublished.editLink": "Bearbeiten", + "page.search.title": "Seiten suchen", + "page.search.placeholder": "Nach Titel suchen...", + "page.search.minLength": "Mindestens 3 Zeichen eingeben und Enter drücken, um zu suchen.", + "page.search.noResults": "Keine Seiten gefunden.", + "page.search.columnTitle": "Titel", + "page.search.columnUri": "URI", + "page.search.loadLink": "Laden", + "page.search.loadError": "Seiten konnten nicht durchsucht werden.", "pagination.previous": "Zurück", "pagination.next": "Weiter" } diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-page.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-page.ts index 9de75f74..dd5dcfbb 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-page.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-page.ts @@ -79,4 +79,22 @@ const deletePage = async (options: any) => { return await executeRemoteCall(data); }; -export { createPage, deletePage, filterPages }; +export interface SearchPagesOptions { + query: string; +} +export interface SearchResultDto { + uri: string; + title: string; +} +export interface SearchPagesResponse { + result: SearchResultDto[]; +} +const searchPages = async (options: SearchPagesOptions) : Promise => { + var data = { + method: "pages.search", + parameters: options + } + return (await executeRemoteCall(data)).result as SearchPagesResponse; +} + +export { createPage, deletePage, filterPages, searchPages }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java index be51429f..776aced8 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java @@ -21,6 +21,7 @@ * #L% */ +import com.condation.cms.api.Constants; import com.condation.cms.api.SiteProperties; import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; @@ -161,4 +162,68 @@ public void testFilterPages_WithPagination() throws RPCException { assertThat(result).isEqualTo(expectedPage); verify(query).page(1L, 10L); } + + @Test + public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { + // Arrange + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); + + Map meta = new HashMap<>(); + meta.put(Constants.MetaFields.TITLE, "Superman Returns"); + ContentNode node = new ContentNode("test/test1.md", "test1.md", meta); + + when(content.searchByTitle("superman")).thenReturn(List.of(node)); + when(contentBase.resolve("test/test1.md")).thenReturn(contentFile); + when(contentBase.relativize(contentFile)).thenReturn(contentFile); + when(contentFile.toString()).thenReturn("test/test1.md"); + + Map parameters = new HashMap<>(); + parameters.put("query", "superman"); + + // Act + Object result = pageEndpoints.searchPages(parameters); + + // Assert + assertThat(result).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + @SuppressWarnings("unchecked") + List hits = (List) resultMap.get("result"); + + assertThat(hits).hasSize(1); + assertThat(hits.get(0).title()).isEqualTo("Superman Returns"); + assertThat(hits.get(0).uri()).isNotNull(); + } + + @Test + public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCException { + // Arrange + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); + + ContentNode node = new ContentNode("test/test2.md", "test2.md", new HashMap<>()); + + when(content.searchByTitle("")).thenReturn(List.of(node)); + when(contentBase.resolve("test/test2.md")).thenReturn(contentFile); + when(contentBase.relativize(contentFile)).thenReturn(contentFile); + when(contentFile.toString()).thenReturn("test/test2.md"); + + Map parameters = new HashMap<>(); + parameters.put("query", ""); + + // Act + Object result = pageEndpoints.searchPages(parameters); + + // Assert + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + @SuppressWarnings("unchecked") + List hits = (List) resultMap.get("result"); + + assertThat(hits).hasSize(1); + assertThat(hits.get(0).title()).isEqualTo(""); + } }