From 55c04a1345b4ccc48c96c3ddb985fc96a9b51b0d Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Tue, 14 Apr 2026 11:52:35 +0200 Subject: [PATCH 01/15] Introduce the PageLinkQuery --- .../page_links/filter/provider_filter.rb | 55 +++++++++++++++++++ .../wikis/page_links/page_link_query.rb | 44 +++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb create mode 100644 modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb diff --git a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb new file mode 100644 index 000000000000..3160c4d6e7ef --- /dev/null +++ b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Queries + module Wikis + module PageLinks + module Filter + class StorageFilter < Filters::Base + self.model = ::Wikis::PageLink + + def type = :list + + def human_name + ::Wikis::PageLink.human_attribute_name(name) + end + + def allowed_values + ::Wikis::Provider.pluck(:id).map { |id| [id, id.to_s] } + end + + def where + operator_strategy.sql_for_field(values, ::Storages::FileLink.table_name, "provider_id") + end + end + end + end + end +end diff --git a/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb new file mode 100644 index 000000000000..8a2c28cfe128 --- /dev/null +++ b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Queries + module Wikis + module PageLinks + class PageLinkQuery + include BaseQuery + include UnpersistedQuery + + def model + @model ||= ::Wikis::PageLink + end + end + end + end +end From a2b5fe50b55f1e677788e1e74e4c18e9e804ddb0 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Mon, 20 Apr 2026 11:19:58 +0200 Subject: [PATCH 02/15] Update page link query and some copyright --- modules/backlogs/app/contracts/backlog_buckets/base_contract.rb | 2 +- .../app/models/queries/wikis/page_links/page_link_query.rb | 2 +- modules/wikis/app/services/wikis/page_link_service.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/app/contracts/backlog_buckets/base_contract.rb b/modules/backlogs/app/contracts/backlog_buckets/base_contract.rb index e1f8d6d6aa9d..d006b91ecc36 100644 --- a/modules/backlogs/app/contracts/backlog_buckets/base_contract.rb +++ b/modules/backlogs/app/contracts/backlog_buckets/base_contract.rb @@ -23,7 +23,7 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ diff --git a/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb index 8a2c28cfe128..865359ca72ab 100644 --- a/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb +++ b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb @@ -35,7 +35,7 @@ class PageLinkQuery include BaseQuery include UnpersistedQuery - def model + def self.model @model ||= ::Wikis::PageLink end end diff --git a/modules/wikis/app/services/wikis/page_link_service.rb b/modules/wikis/app/services/wikis/page_link_service.rb index 3445b22c038b..c99015a23c60 100644 --- a/modules/wikis/app/services/wikis/page_link_service.rb +++ b/modules/wikis/app/services/wikis/page_link_service.rb @@ -23,7 +23,7 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ From feed45d2c86964bb5a70f69801f40fa015f60d9c Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Mon, 20 Apr 2026 12:02:07 +0200 Subject: [PATCH 03/15] Adds PageLinkCollectionRepresenter --- COPYRIGHT | 2 +- COPYRIGHT_short | 2 +- .../page_link_collection_representer.rb | 39 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb diff --git a/COPYRIGHT b/COPYRIGHT index 08f7c7b939c1..30d0c1cb7eb7 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -58,4 +58,4 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. diff --git a/COPYRIGHT_short b/COPYRIGHT_short index d4773d9e4760..0693c77611e6 100644 --- a/COPYRIGHT_short +++ b/COPYRIGHT_short @@ -20,6 +20,6 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. diff --git a/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb new file mode 100644 index 000000000000..d76bc76dd00f --- /dev/null +++ b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module PageLinks + class PageLinkCollectionRepresenter < Decorators::OffsetPaginatedCollection + property :count, getter: ->(*) { count(:id) } + end + end + end +end From 86d12cb4f78f97c8dbf2110de4acd4324655288e Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Mon, 20 Apr 2026 16:25:19 +0200 Subject: [PATCH 04/15] Introduces the endpoint /work_packages/id/wiki_page_links # Conflicts: # modules/wikis/lib/open_project/wikis/engine.rb --- .../page_links/filter/provider_filter.rb | 4 +- .../wikis/page_link_metadata_service.rb | 78 +++++++++++++++++++ .../work_package_wiki_page_links_api.rb | 69 ++++++++++++++++ .../wikis/lib/open_project/wikis/engine.rb | 10 +++ .../api/v3/page_links/page_links_api_spec.rb | 74 ++++++++++++++++++ .../wikis/page_link_metadata_service_spec.rb | 0 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 modules/wikis/app/services/wikis/page_link_metadata_service.rb create mode 100644 modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb create mode 100644 modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb create mode 100644 modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb diff --git a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb index 3160c4d6e7ef..97f625276c87 100644 --- a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb +++ b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb @@ -32,7 +32,7 @@ module Queries module Wikis module PageLinks module Filter - class StorageFilter < Filters::Base + class ProviderFilter < Filters::Base self.model = ::Wikis::PageLink def type = :list @@ -46,7 +46,7 @@ def allowed_values end def where - operator_strategy.sql_for_field(values, ::Storages::FileLink.table_name, "provider_id") + operator_strategy.sql_for_field(values, ::Wikis::PageLink.table_name, "provider_id") end end end diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb new file mode 100644 index 000000000000..54bec321ae89 --- /dev/null +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Wikis + class PageLinkMetadataService + def initialize(page_links) + @page_links = page_links + @result = ServiceResult.success(errors: ActiveModel::Errors.new(self)) + end + + def call + metadata = @page_links.group_by(&:provider).filter_map do |provider, pages| + result = provider.resolve("queries.pages").call(provider:, page_identifiers: pages.map(&:identifiers)) + result.value_or { add_wiki_error(it) and next } + end + + @result.result = enrich_models(@page_links, metadata.flatten) + @result + end + + private + + def add_wiki_error(error) + @result.add_error(:base, error.message) + end + + def enrich_models(page_links, metadata) + # Expectation is that the result from the PagesQuery is an array of "Result::Pages" + identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } + variable_placeholders = build_placeholders(identifier_title_map.size) + + result_scope(page_links.pluck(:id), metadata_join_sql(variable_placeholders, identifier_title_map)) + end + + def result_scope(ids, join_expression) + PageLink.where(id: ids).order(:id).joins(join_expression).select("page_links.*, metadata.tile as title") + end + + def metadata_join_sql(variable_placeholders, identifier_title_map) + ActiveRecord::Base.sanitize_sql_array([variable_placeholders, *identifier_title_map.flatten]) + end + + def build_placeholders(amount) + variable_placeholders = Array.new(amount, "(?,?)").join(",") + <<~SQL.squish + LEFT JOIN (VALUES #{variable_placeholders}) AS metadata (identifier, title) + ON metadata.tile = page_links.identifier + SQL + end + end +end diff --git a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb new file mode 100644 index 000000000000..07122dcc7865 --- /dev/null +++ b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module PageLinks + class WorkPackageWikiPageLinksAPI < OpenProjectAPI + def self.enrich_models_with_wiki_metadata(relation) + Wikis::PageLinkMetadataService.new(relation).call + end + + resources :wiki_page_links do + get do + query = ParamsToQueryService.new( + ::Wikis::PageLink, + current_user, + query_class: ::Queries::Wikis::PageLinks::PageLinkQuery + ).call(params) + + unless query.valid? + message = I18n.t("api_v3.errors.missing_or_malformed_parameter", parameter: "filters") + raise ::API::Errors::InvalidQuery.new(message) + end + + relation = if current_user.allowed_in_project?(:view_wiki_page_links, @work_package.project) + query.results.where(linkable: @work_package) + else + [] + end + + PageLinkCollectionRepresenter.new( + relation, + per_page: params[:pageSize], + self_link: api_v3_paths.work_package_page_links(@work_package.id), + current_user: + ) + end + end + end + end + end +end diff --git a/modules/wikis/lib/open_project/wikis/engine.rb b/modules/wikis/lib/open_project/wikis/engine.rb index ecd3a452579e..aa9cd4e8ce81 100644 --- a/modules/wikis/lib/open_project/wikis/engine.rb +++ b/modules/wikis/lib/open_project/wikis/engine.rb @@ -60,6 +60,11 @@ class Engine < ::Rails::Engine ) OpenProject::TextFormatting::Filters::PatternMatcherFilter.append_matcher ::Wikis::TextFormatting::WikiLinkMatcher + + # Registering queries and filters + ::Queries::Register.register(::Queries::Wikis::PageLinks::PageLinkQuery) do + filter ::Queries::Wikis::PageLinks::Filter::ProviderFilter + end end replace_principal_references "Wikis::PageLink" => %i[author_id] @@ -99,5 +104,10 @@ class Engine < ::Rails::Engine add_api_path(:wiki_page_link) { |page_link_id| "#{root}/wiki_page_links/#{page_link_id}" } add_api_path(:wiki_provider) { |provider_id| "#{root}/wiki_providers/#{provider_id}" } + add_api_path(:work_package_page_links) { |work_package_id| "#{work_package(work_package_id)}/wiki_page_links" } + + add_api_endpoint "API::V3::WorkPackages::WorkPackagesAPI", :id do + mount ::API::V3::PageLinks::WorkPackageWikiPageLinksAPI + end end end diff --git a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb new file mode 100644 index 000000000000..533a13bf8ed5 --- /dev/null +++ b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "API v3 wiki page links resource" do + include API::V3::Utilities::PathHelper + + let(:work_package) { create(:work_package) } + let(:internal_wiki) { create(:internal_wiki_provider) } + + let(:project) { work_package.project } + + let(:user) { create(:user, member_with_permissions: { project => %i(view_work_packages view_wiki_page_links) }) } + + let(:relation_page_links) { create_list(:relation_wiki_page_link, 3, provider: internal_wiki, linkable: work_package) } + + before do + login_as user + relation_page_links + end + + describe "GET /api/v3/work_packages/:id/wiki_page_links" do + let(:path) { api_v3_paths.work_package_page_links(work_package.id) } + + context "with all preconditions met (happy path)" do + before { get path } + + it_behaves_like "API V3 collection response", 3, 3, "RelationPageLink", "Collection" do + let(:elements) { relation_page_links.reverse } + end + end + + context "when filtered by provider" do + let(:filter) { [{ provider: { operator: "=", values: [internal_wiki.id] } }] } + + before do + create_list(:relation_wiki_page_link, 3, provider: create(:xwiki_provider), linkable: work_package) + get "#{path}?filters=#{CGI.escape(filter.to_json)}" + end + + it_behaves_like "API V3 collection response", 3, 3, "RelationPageLink", "Collection" do + let(:elements) { relation_page_links.reverse } + end + end + end +end diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb new file mode 100644 index 000000000000..e69de29bb2d1 From 6ca9a275afcd5e2e81817c5c7d994d2fe3a8153a Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Mon, 20 Apr 2026 17:05:40 +0200 Subject: [PATCH 05/15] MetadaService tests, most of it is a work of fiction --- .../wikis/page_link_metadata_service.rb | 8 +- .../wikis/page_link_metadata_service_spec.rb | 73 +++++++++++++++++++ modules/wikis/spec/spec_helper.rb | 43 +++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 modules/wikis/spec/spec_helper.rb diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index 54bec321ae89..ca518f5fb21c 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -37,7 +37,7 @@ def initialize(page_links) def call metadata = @page_links.group_by(&:provider).filter_map do |provider, pages| - result = provider.resolve("queries.pages").call(provider:, page_identifiers: pages.map(&:identifiers)) + result = provider.resolve("queries.pages").call(page_identifiers: pages.map(&:identifier)) result.value_or { add_wiki_error(it) and next } end @@ -60,7 +60,7 @@ def enrich_models(page_links, metadata) end def result_scope(ids, join_expression) - PageLink.where(id: ids).order(:id).joins(join_expression).select("page_links.*, metadata.tile as title") + PageLink.where(id: ids).order(:id).joins(join_expression).select("wiki_page_links.*, metadata.title as title") end def metadata_join_sql(variable_placeholders, identifier_title_map) @@ -70,8 +70,8 @@ def metadata_join_sql(variable_placeholders, identifier_title_map) def build_placeholders(amount) variable_placeholders = Array.new(amount, "(?,?)").join(",") <<~SQL.squish - LEFT JOIN (VALUES #{variable_placeholders}) AS metadata (identifier, title) - ON metadata.tile = page_links.identifier + LEFT JOIN (VALUES #{variable_placeholders}) AS metadata(identifier, title) + ON metadata.identifier = wiki_page_links.identifier SQL end end diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb index e69de29bb2d1..2692ae9407cc 100644 --- a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb +++ b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +module Wikis + FakeMetadata = Data.define(:title, :identifier) + RSpec.describe PageLinkMetadataService do + let(:relation) { PageLink.where(provider:) } + + let(:provider) { create(:internal_wiki_provider) } + let(:page_links) { create_list(:relation_wiki_page_link, 3, provider:) } + + let(:metadata) do + page_links.map { FakeMetadata.new("Wikis, now with more cheese! Part #{it.id}", it.identifier) } + end + + subject(:service) { described_class.new(relation) } + + before do + page_links + + query_double = instance_double(Adapters::Providers::Internal::Queries::PageInfo) + allow(Adapters::Providers::Internal::Queries::PageInfo).to receive(:new).and_return(query_double) + + allow(query_double).to receive(:call).and_return(Success(metadata)) + end + + it "returns a new relation" do + service_result = service.call + + expect(service_result).to be_success + expect(service_result.errors).to be_empty + expect(service_result.result).to be_an(ActiveRecord::Relation) + end + + it "adds the title attribute to the metadata association" do + service_result = service.call + expect(service_result).to be_success + + page_links = service_result.result + expect(page_links.first.title).to match(/Wikis, now with more cheese! Part \d+/) + end + end +end diff --git a/modules/wikis/spec/spec_helper.rb b/modules/wikis/spec/spec_helper.rb new file mode 100644 index 000000000000..72f308d2e316 --- /dev/null +++ b/modules/wikis/spec/spec_helper.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "dry/container/stub" +require "dry/monads" + +RSpec.configure do |config| + config.include Dry::Monads[:result] + + config.prepend_before do + Wikis::Adapters::Registry.enable_stubs! + end + config.append_after do + Wikis::Adapters::Registry.unstub + end +end From 9eecbef52150543924f4ce7c3e2c859668b3244d Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Mon, 27 Apr 2026 11:15:28 +0200 Subject: [PATCH 06/15] Address feedback, move provider handling to use universal identifiers --- .../queries/wikis/page_links/filter/provider_filter.rb | 6 ++++-- .../models/queries/wikis/page_links/page_link_query.rb | 4 ++++ .../app/services/wikis/page_link_metadata_service.rb | 4 ++-- modules/wikis/config/locales/en.yml | 2 ++ .../lib/api/v3/page_links/page_link_representer.rb | 4 +++- .../wikis/lib/api/v3/providers/provider_representer.rb | 4 ++-- .../api/v3/page_links/page_link_representer_spec.rb | 2 +- .../lib/api/v3/providers/provider_representer_spec.rb | 10 +++++++++- .../requests/api/v3/page_links/page_links_api_spec.rb | 2 +- 9 files changed, 28 insertions(+), 10 deletions(-) diff --git a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb index 97f625276c87..ec1441dff4a9 100644 --- a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb +++ b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb @@ -42,11 +42,13 @@ def human_name end def allowed_values - ::Wikis::Provider.pluck(:id).map { |id| [id, id.to_s] } + ::Wikis::Provider.enabled.pluck(:universal_identifier).map { |uid| [uid, uid.to_s] } end + def left_outer_joins = :provider + def where - operator_strategy.sql_for_field(values, ::Wikis::PageLink.table_name, "provider_id") + operator_strategy.sql_for_field(values, ::Wikis::Provider.table_name, "universal_identifier") end end end diff --git a/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb index 865359ca72ab..8b493e789fd1 100644 --- a/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb +++ b/modules/wikis/app/models/queries/wikis/page_links/page_link_query.rb @@ -38,6 +38,10 @@ class PageLinkQuery def self.model @model ||= ::Wikis::PageLink end + + def default_scope + ::Wikis::PageLink.includes(:provider).all + end end end end diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index ca518f5fb21c..3702f1e85b22 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -36,8 +36,8 @@ def initialize(page_links) end def call - metadata = @page_links.group_by(&:provider).filter_map do |provider, pages| - result = provider.resolve("queries.pages").call(page_identifiers: pages.map(&:identifier)) + metadata = @page_links.group_by(&:provider).filter_map do |provider, links| + result = provider.resolve("queries.pages").call(page_identifiers: links.map(&:identifier)) result.value_or { add_wiki_error(it) and next } end diff --git a/modules/wikis/config/locales/en.yml b/modules/wikis/config/locales/en.yml index 4ee55a28be5c..ad8a3c5320d0 100644 --- a/modules/wikis/config/locales/en.yml +++ b/modules/wikis/config/locales/en.yml @@ -2,6 +2,8 @@ en: activerecord: attributes: + wikis/page_link: + provider: Wiki Provider wikis/xwiki_provider: authentication_method: Authentication method authentication_methods: diff --git a/modules/wikis/lib/api/v3/page_links/page_link_representer.rb b/modules/wikis/lib/api/v3/page_links/page_link_representer.rb index a0c80d5f2a6a..945fd64f0ab9 100644 --- a/modules/wikis/lib/api/v3/page_links/page_link_representer.rb +++ b/modules/wikis/lib/api/v3/page_links/page_link_representer.rb @@ -61,7 +61,9 @@ class PageLinkRepresenter < Decorators::Single } end - associated_resource :provider, v3_path: :wiki_provider + associated_resource :provider, v3_path: :wiki_provider, link: ->(*) { + { href: api_v3_paths.wiki_provider(represented.provider.universal_identifier), title: represented.provider.name } + } # TODO: Make this truly polymorphic - @mereghost 2026-04-13 associated_resource :linkable, diff --git a/modules/wikis/lib/api/v3/providers/provider_representer.rb b/modules/wikis/lib/api/v3/providers/provider_representer.rb index 4dfaee7065ba..bd29de35de33 100644 --- a/modules/wikis/lib/api/v3/providers/provider_representer.rb +++ b/modules/wikis/lib/api/v3/providers/provider_representer.rb @@ -35,13 +35,13 @@ class ProviderRepresenter < Decorators::Single include Decorators::LinkedResource include Decorators::DateProperty - property :id + property :universal_identifier property :name date_time_property :created_at date_time_property :updated_at - self_link(path: :wiki_provider) + self_link(path: :wiki_provider, id_attribute: :universal_identifier) end end end diff --git a/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb b/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb index d915b5247130..d3ffe8869498 100644 --- a/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb +++ b/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb @@ -60,7 +60,7 @@ module PageLinks describe "provider" do it_behaves_like "has a titled link" do let(:link) { "provider" } - let(:href) { "/api/v3/wiki_providers/#{represented.provider_id}" } + let(:href) { "/api/v3/wiki_providers/#{represented.provider.universal_identifier}" } let(:title) { represented.provider.name } end end diff --git a/modules/wikis/spec/lib/api/v3/providers/provider_representer_spec.rb b/modules/wikis/spec/lib/api/v3/providers/provider_representer_spec.rb index 602bae7b9635..80fcaa18f4c9 100644 --- a/modules/wikis/spec/lib/api/v3/providers/provider_representer_spec.rb +++ b/modules/wikis/spec/lib/api/v3/providers/provider_representer_spec.rb @@ -48,13 +48,21 @@ module Providers describe "self" do it_behaves_like "has a titled link" do let(:link) { "self" } - let(:href) { "/api/v3/wiki_providers/#{represented.id}" } + let(:href) { "/api/v3/wiki_providers/#{represented.universal_identifier}" } let(:title) { represented.name } end end end describe "properties" do + it_behaves_like "property", :name do + let(:value) { represented.name } + end + + it_behaves_like "property", :universalIdentifier do + let(:value) { represented.universal_identifier } + end + it_behaves_like "datetime property", :createdAt do let(:value) { represented.created_at } end diff --git a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb index 533a13bf8ed5..40a5c5afedd7 100644 --- a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb +++ b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb @@ -59,7 +59,7 @@ end context "when filtered by provider" do - let(:filter) { [{ provider: { operator: "=", values: [internal_wiki.id] } }] } + let(:filter) { [{ provider: { operator: "=", values: [internal_wiki.universal_identifier] } }] } before do create_list(:relation_wiki_page_link, 3, provider: create(:xwiki_provider), linkable: work_package) From b1e88b7b7bc118643d11ad7898f4e6fd58568edb Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Tue, 28 Apr 2026 17:38:27 +0200 Subject: [PATCH 07/15] Adds urns for page link type improve some tests --- .../wikis/page_link_metadata_service.rb | 30 ++++++++--- .../page_link_collection_representer.rb | 2 + .../v3/page_links/page_link_representer.rb | 13 ++++- .../work_package_wiki_page_links_api.rb | 8 +-- .../page_links/page_link_representer_spec.rb | 20 ++++++- .../api/v3/page_links/page_links_api_spec.rb | 53 ++++++++++++++++--- .../wikis/page_link_metadata_service_spec.rb | 41 ++++++++------ 7 files changed, 130 insertions(+), 37 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index 3702f1e85b22..f12a86fc507f 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -30,25 +30,39 @@ module Wikis class PageLinkMetadataService - def initialize(page_links) - @page_links = page_links + # @param relation [ActiveRecord::Relation] + # @return [ServiceResult] + def self.call(relation) = new.call(relation) + + def initialize @result = ServiceResult.success(errors: ActiveModel::Errors.new(self)) end - def call - metadata = @page_links.group_by(&:provider).filter_map do |provider, links| - result = provider.resolve("queries.pages").call(page_identifiers: links.map(&:identifier)) - result.value_or { add_wiki_error(it) and next } + def call(relation) + metadata = relation.group_by(&:provider).filter_map do |provider, page_links| + build_inputs(page_links).filter_map do |input_data| + provider.resolve("queries.page_info").call(input_data).value_or { add_wiki_error(it) and next } + end end - @result.result = enrich_models(@page_links, metadata.flatten) + @result.result = enrich_models(relation, metadata.flatten) @result end private + def build_inputs(page_links) + page_links.filter_map do |page_link| + Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or { add_validation_error(it) } + end + end + def add_wiki_error(error) - @result.add_error(:base, error.message) + @result.errors.add(:base, error.code) + end + + def add_validation_error(error) + @result.errors.add(:identifiers, error.message, options: error.to_h) end def enrich_models(page_links, metadata) diff --git a/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb index d76bc76dd00f..e153493b199d 100644 --- a/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb +++ b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb @@ -33,6 +33,8 @@ module V3 module PageLinks class PageLinkCollectionRepresenter < Decorators::OffsetPaginatedCollection property :count, getter: ->(*) { count(:id) } + + def _type = "WikiPageLinkCollection" end end end diff --git a/modules/wikis/lib/api/v3/page_links/page_link_representer.rb b/modules/wikis/lib/api/v3/page_links/page_link_representer.rb index 945fd64f0ab9..aa0986b0bf24 100644 --- a/modules/wikis/lib/api/v3/page_links/page_link_representer.rb +++ b/modules/wikis/lib/api/v3/page_links/page_link_representer.rb @@ -31,6 +31,14 @@ module API module V3 module PageLinks + URN_INLINE_PAGE_LINK = "#{URN_PREFIX}wikiPageLinks:Inline".freeze + URN_RELATION_PAGE_LINK = "#{URN_PREFIX}wikiPageLinks:Relation".freeze + + URN_PAGE_LINK_TYPE = { + "Wikis::RelationPageLink" => URN_RELATION_PAGE_LINK, + "Wikis::InlinePageLink" => URN_INLINE_PAGE_LINK + }.freeze + class PageLinkRepresenter < Decorators::Single include Decorators::LinkedResource include Decorators::DateProperty @@ -38,6 +46,7 @@ class PageLinkRepresenter < Decorators::Single property :id property :identifier + property :wiki_page_link_type, exec_context: :decorator date_time_property :created_at date_time_property :updated_at @@ -71,7 +80,9 @@ class PageLinkRepresenter < Decorators::Single representer: ::API::V3::WorkPackages::WorkPackageRepresenter, skip_render: ->(*) { represented.linkable_id.nil? || represented.linkable_type != "WorkPackage" } - def _type = represented.class.name.demodulize + def _type = "WikiPageLink" + + def wiki_page_link_type = URN_PAGE_LINK_TYPE[represented.class.name] private diff --git a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb index 07122dcc7865..8a202d50c150 100644 --- a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb +++ b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb @@ -32,8 +32,10 @@ module API module V3 module PageLinks class WorkPackageWikiPageLinksAPI < OpenProjectAPI - def self.enrich_models_with_wiki_metadata(relation) - Wikis::PageLinkMetadataService.new(relation).call + helpers do + def enrich_models_with_wiki_metadata(relation) + Wikis::PageLinkMetadataService.call(relation) + end end resources :wiki_page_links do @@ -56,7 +58,7 @@ def self.enrich_models_with_wiki_metadata(relation) end PageLinkCollectionRepresenter.new( - relation, + enrich_models_with_wiki_metadata(relation).result, per_page: params[:pageSize], self_link: api_v3_paths.work_package_page_links(@work_package.id), current_user: diff --git a/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb b/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb index d3ffe8869498..6457e66a8a2b 100644 --- a/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb +++ b/modules/wikis/spec/lib/api/v3/page_links/page_link_representer_spec.rb @@ -113,18 +113,34 @@ module PageLinks end describe "properties" do + describe "wiki_page_link_type" do + context "when InlinePageLink" do + let(:represented) { inline_page_link } + + it_behaves_like "property", :wikiPageLinkType do + let(:value) { URN_INLINE_PAGE_LINK } + end + end + + context "when RelationPageLink" do + it_behaves_like "property", :wikiPageLinkType do + let(:value) { URN_RELATION_PAGE_LINK } + end + end + end + describe "_type" do context "when InlinePageLink" do let(:represented) { inline_page_link } it_behaves_like "property", :_type do - let(:value) { "InlinePageLink" } + let(:value) { "WikiPageLink" } end end context "when RelationPageLink" do it_behaves_like "property", :_type do - let(:value) { "RelationPageLink" } + let(:value) { "WikiPageLink" } end end end diff --git a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb index 40a5c5afedd7..19db836cd0b3 100644 --- a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb +++ b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb @@ -29,22 +29,30 @@ #++ require "spec_helper" +require_module_spec_helper RSpec.describe "API v3 wiki page links resource" do include API::V3::Utilities::PathHelper let(:work_package) { create(:work_package) } let(:internal_wiki) { create(:internal_wiki_provider) } + let(:xwiki_provider) { create(:xwiki_provider) } let(:project) { work_package.project } let(:user) { create(:user, member_with_permissions: { project => %i(view_work_packages view_wiki_page_links) }) } - let(:relation_page_links) { create_list(:relation_wiki_page_link, 3, provider: internal_wiki, linkable: work_package) } + let(:relation_page_links) { create_list(:relation_wiki_page_link, 3, provider: xwiki_provider, linkable: work_package) } + let(:inline_page_links) { create_list(:inline_wiki_page_link, 3, provider: internal_wiki, linkable: work_package) } + + let(:unrelated_page_links) do + create_list(:inline_wiki_page_link, 3, provider: internal_wiki, linkable: create(:work_package, project: project)) + end before do login_as user - relation_page_links + stub_provider_queries + unrelated_page_links end describe "GET /api/v3/work_packages/:id/wiki_page_links" do @@ -53,8 +61,8 @@ context "with all preconditions met (happy path)" do before { get path } - it_behaves_like "API V3 collection response", 3, 3, "RelationPageLink", "Collection" do - let(:elements) { relation_page_links.reverse } + it_behaves_like "API V3 collection response", 6, 6, "WikiPageLink", "WikiPageLinkCollection" do + let(:elements) { Wikis::PageLink.where(linkable: work_package).order(id: :asc).all } end end @@ -62,13 +70,44 @@ let(:filter) { [{ provider: { operator: "=", values: [internal_wiki.universal_identifier] } }] } before do - create_list(:relation_wiki_page_link, 3, provider: create(:xwiki_provider), linkable: work_package) get "#{path}?filters=#{CGI.escape(filter.to_json)}" end - it_behaves_like "API V3 collection response", 3, 3, "RelationPageLink", "Collection" do - let(:elements) { relation_page_links.reverse } + it_behaves_like "API V3 collection response", 3, 3, "WikiPageLink", "WikiPageLinkCollection" do + let(:elements) { Wikis::PageLink.where(linkable: work_package, provider: internal_wiki).order(id: :asc).all } + end + end + end + + private + + def stub_provider_queries + internal_class = class_double(Wikis::Adapters::Providers::Internal::Queries::PageInfo) + xwiki_class = class_double(Wikis::Adapters::Providers::XWiki::Queries::PageInfo) + + internal_query = instance_double(Wikis::Adapters::Providers::Internal::Queries::PageInfo) + xwiki_query = instance_double(Wikis::Adapters::Providers::XWiki::Queries::PageInfo) + + Wikis::Adapters::Registry.stub("internal.queries.page_info", internal_class) + Wikis::Adapters::Registry.stub("xwiki.queries.page_info", xwiki_class) + + allow(internal_class).to receive(:new).and_return(internal_query) + allow(xwiki_class).to receive(:new).and_return(xwiki_query) + stub_query_calls(inline_page_links, internal_query) + stub_query_calls(relation_page_links, xwiki_query) + end + + def stub_query_calls(links, query) + links.each do |link| + Wikis::Adapters::Input::PageInfo.build(identifier: link.identifier).bind do |input| + allow(query).to receive(:call).with(input).and_return(Success(build_page_info(link))) end end end + + def build_page_info(link) + Wikis::Adapters::Results::PageInfo.new( + identifier: link.identifier, href: "valid_uri", title: "Title of #{link.identifier}", provider: link.provider + ) + end end diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb index 2692ae9407cc..10f2330c94e1 100644 --- a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb +++ b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb @@ -32,30 +32,33 @@ require_module_spec_helper module Wikis - FakeMetadata = Data.define(:title, :identifier) RSpec.describe PageLinkMetadataService do - let(:relation) { PageLink.where(provider:) } + let(:relation) { PageLink.limit(30) } - let(:provider) { create(:internal_wiki_provider) } - let(:page_links) { create_list(:relation_wiki_page_link, 3, provider:) } + shared_let(:provider) { create(:internal_wiki_provider) } + shared_let(:page_links) { create_list(:relation_wiki_page_link, 3, provider:) } - let(:metadata) do - page_links.map { FakeMetadata.new("Wikis, now with more cheese! Part #{it.id}", it.identifier) } - end - - subject(:service) { described_class.new(relation) } + subject(:service) { described_class } before do - page_links - query_double = instance_double(Adapters::Providers::Internal::Queries::PageInfo) - allow(Adapters::Providers::Internal::Queries::PageInfo).to receive(:new).and_return(query_double) + query_class_double = class_double(Adapters::Providers::Internal::Queries::PageInfo, new: query_double) + Adapters::Registry.stub("internal.queries.page_info", query_class_double) - allow(query_double).to receive(:call).and_return(Success(metadata)) + build_inputs.each do |input| + allow(query_double).to receive(:call).with(input).and_return( + Success( + Adapters::Results::PageInfo.new(title: "Wikis, now with more cheese! Part #{input.identifier}", + identifier: input.identifier, + href: "totally_valid_url", + provider:) + ) + ) + end end it "returns a new relation" do - service_result = service.call + service_result = service.call(relation) expect(service_result).to be_success expect(service_result.errors).to be_empty @@ -63,11 +66,17 @@ module Wikis end it "adds the title attribute to the metadata association" do - service_result = service.call + service_result = service.call(relation) expect(service_result).to be_success page_links = service_result.result - expect(page_links.first.title).to match(/Wikis, now with more cheese! Part \d+/) + expect(page_links.first.title).to eq("Wikis, now with more cheese! Part #{page_links.first.identifier}") + end + + private + + def build_inputs + page_links.filter_map { Adapters::Input::PageInfo.build(identifier: it.identifier).value_or(nil) } end end end From ac872faad96c9dad59006f962973ad1afefaf42a Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 10:59:09 +0200 Subject: [PATCH 08/15] Removes mentions to view_wiki_page_links permissions --- .../wikis/page_link_metadata_service.rb | 21 ++++++++++++------- .../page_link_collection_representer.rb | 2 -- .../work_package_wiki_page_links_api.rb | 2 +- .../api/v3/page_links/page_links_api_spec.rb | 6 +++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index f12a86fc507f..326ed66ba41b 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -39,13 +39,16 @@ def initialize end def call(relation) - metadata = relation.group_by(&:provider).filter_map do |provider, page_links| + metadata = relation.group_by(&:provider).flat_map do |provider, page_links| build_inputs(page_links).filter_map do |input_data| - provider.resolve("queries.page_info").call(input_data).value_or { add_wiki_error(it) and next } + provider.resolve("queries.page_info").call(input_data).value_or do |error| + add_wiki_error(error) + next + end end end - @result.result = enrich_models(relation, metadata.flatten) + @result.result = enrich_models(relation, metadata) @result end @@ -53,7 +56,10 @@ def call(relation) def build_inputs(page_links) page_links.filter_map do |page_link| - Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or { add_validation_error(it) } + Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or do |validation_failure| + add_validation_error(validation_failure) + next + end end end @@ -66,15 +72,14 @@ def add_validation_error(error) end def enrich_models(page_links, metadata) - # Expectation is that the result from the PagesQuery is an array of "Result::Pages" identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } variable_placeholders = build_placeholders(identifier_title_map.size) - result_scope(page_links.pluck(:id), metadata_join_sql(variable_placeholders, identifier_title_map)) + result_scope(page_links, metadata_join_sql(variable_placeholders, identifier_title_map)) end - def result_scope(ids, join_expression) - PageLink.where(id: ids).order(:id).joins(join_expression).select("wiki_page_links.*, metadata.title as title") + def result_scope(page_links, join_expression) + page_links.joins(join_expression).select("wiki_page_links.*, metadata.title as title") end def metadata_join_sql(variable_placeholders, identifier_title_map) diff --git a/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb index e153493b199d..15243476718b 100644 --- a/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb +++ b/modules/wikis/lib/api/v3/page_links/page_link_collection_representer.rb @@ -32,8 +32,6 @@ module API module V3 module PageLinks class PageLinkCollectionRepresenter < Decorators::OffsetPaginatedCollection - property :count, getter: ->(*) { count(:id) } - def _type = "WikiPageLinkCollection" end end diff --git a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb index 8a202d50c150..91c4e3b5c79e 100644 --- a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb +++ b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb @@ -51,7 +51,7 @@ def enrich_models_with_wiki_metadata(relation) raise ::API::Errors::InvalidQuery.new(message) end - relation = if current_user.allowed_in_project?(:view_wiki_page_links, @work_package.project) + relation = if current_user.allowed_in_project?(:view_work_packages, @work_package.project) query.results.where(linkable: @work_package) else [] diff --git a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb index 19db836cd0b3..ccd224e09778 100644 --- a/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb +++ b/modules/wikis/spec/requests/api/v3/page_links/page_links_api_spec.rb @@ -40,7 +40,7 @@ let(:project) { work_package.project } - let(:user) { create(:user, member_with_permissions: { project => %i(view_work_packages view_wiki_page_links) }) } + let(:user) { create(:user, member_with_permissions: { project => %i(view_work_packages) }) } let(:relation_page_links) { create_list(:relation_wiki_page_link, 3, provider: xwiki_provider, linkable: work_package) } let(:inline_page_links) { create_list(:inline_wiki_page_link, 3, provider: internal_wiki, linkable: work_package) } @@ -62,7 +62,7 @@ before { get path } it_behaves_like "API V3 collection response", 6, 6, "WikiPageLink", "WikiPageLinkCollection" do - let(:elements) { Wikis::PageLink.where(linkable: work_package).order(id: :asc).all } + let(:elements) { Wikis::PageLink.where(linkable: work_package).order(id: :desc).all } end end @@ -74,7 +74,7 @@ end it_behaves_like "API V3 collection response", 3, 3, "WikiPageLink", "WikiPageLinkCollection" do - let(:elements) { Wikis::PageLink.where(linkable: work_package, provider: internal_wiki).order(id: :asc).all } + let(:elements) { Wikis::PageLink.where(linkable: work_package, provider: internal_wiki).order(id: :desc).all } end end end From 03967316dce44268a23c5561e5c158f408127144 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 11:30:40 +0200 Subject: [PATCH 09/15] Remove permission check as it is already checked and error handling from service --- .../wikis/page_link_metadata_service.rb | 18 ++---------------- .../work_package_wiki_page_links_api.rb | 6 +----- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index 326ed66ba41b..1c3033dab391 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -41,10 +41,7 @@ def initialize def call(relation) metadata = relation.group_by(&:provider).flat_map do |provider, page_links| build_inputs(page_links).filter_map do |input_data| - provider.resolve("queries.page_info").call(input_data).value_or do |error| - add_wiki_error(error) - next - end + provider.resolve("queries.page_info").call(input_data).value_or(nil) end end @@ -56,21 +53,10 @@ def call(relation) def build_inputs(page_links) page_links.filter_map do |page_link| - Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or do |validation_failure| - add_validation_error(validation_failure) - next - end + Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or(nil) end end - def add_wiki_error(error) - @result.errors.add(:base, error.code) - end - - def add_validation_error(error) - @result.errors.add(:identifiers, error.message, options: error.to_h) - end - def enrich_models(page_links, metadata) identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } variable_placeholders = build_placeholders(identifier_title_map.size) diff --git a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb index 91c4e3b5c79e..b28d99749288 100644 --- a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb +++ b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb @@ -51,11 +51,7 @@ def enrich_models_with_wiki_metadata(relation) raise ::API::Errors::InvalidQuery.new(message) end - relation = if current_user.allowed_in_project?(:view_work_packages, @work_package.project) - query.results.where(linkable: @work_package) - else - [] - end + relation = query.results.where(linkable: @work_package) PageLinkCollectionRepresenter.new( enrich_models_with_wiki_metadata(relation).result, From 586768592184bbd14b42fc8b09af054b460416a5 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 11:38:23 +0200 Subject: [PATCH 10/15] Remove unnecessary `to_s`. Co-authored-by: Jan Sandbrink --- .../models/queries/wikis/page_links/filter/provider_filter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb index ec1441dff4a9..101be290e85a 100644 --- a/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb +++ b/modules/wikis/app/models/queries/wikis/page_links/filter/provider_filter.rb @@ -42,7 +42,7 @@ def human_name end def allowed_values - ::Wikis::Provider.enabled.pluck(:universal_identifier).map { |uid| [uid, uid.to_s] } + ::Wikis::Provider.enabled.pluck(:universal_identifier).map { |uid| [uid, uid] } end def left_outer_joins = :provider From 836e0bf9c18fe601f265239d35b06d6771d5cde8 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 11:59:01 +0200 Subject: [PATCH 11/15] Revert changes to the MetadataService interface as they made little sense with the current implementation. --- .../wikis/page_link_metadata_service.rb | 23 ++++++++++--------- .../work_package_wiki_page_links_api.rb | 2 +- .../wikis/page_link_metadata_service_spec.rb | 6 ++--- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index 1c3033dab391..50ba7e0621ae 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -30,42 +30,43 @@ module Wikis class PageLinkMetadataService - # @param relation [ActiveRecord::Relation] - # @return [ServiceResult] - def self.call(relation) = new.call(relation) - - def initialize + # @param page_links [ActiveRecord::Relation] + def initialize(page_links) @result = ServiceResult.success(errors: ActiveModel::Errors.new(self)) + @relation = page_links end - def call(relation) + # @return [ServiceResult] + def call metadata = relation.group_by(&:provider).flat_map do |provider, page_links| build_inputs(page_links).filter_map do |input_data| provider.resolve("queries.page_info").call(input_data).value_or(nil) end end - @result.result = enrich_models(relation, metadata) + @result.result = enrich_models(metadata) @result end private + attr_reader :relation + def build_inputs(page_links) page_links.filter_map do |page_link| Adapters::Input::PageInfo.build(identifier: page_link.identifier).value_or(nil) end end - def enrich_models(page_links, metadata) + def enrich_models(metadata) identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } variable_placeholders = build_placeholders(identifier_title_map.size) - result_scope(page_links, metadata_join_sql(variable_placeholders, identifier_title_map)) + result_scope(metadata_join_sql(variable_placeholders, identifier_title_map)) end - def result_scope(page_links, join_expression) - page_links.joins(join_expression).select("wiki_page_links.*, metadata.title as title") + def result_scope(join_expression) + relation.joins(join_expression).select("wiki_page_links.*, metadata.title as title") end def metadata_join_sql(variable_placeholders, identifier_title_map) diff --git a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb index b28d99749288..30dc11451e64 100644 --- a/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb +++ b/modules/wikis/lib/api/v3/page_links/work_package_wiki_page_links_api.rb @@ -34,7 +34,7 @@ module PageLinks class WorkPackageWikiPageLinksAPI < OpenProjectAPI helpers do def enrich_models_with_wiki_metadata(relation) - Wikis::PageLinkMetadataService.call(relation) + Wikis::PageLinkMetadataService.new(relation).call end end diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb index 10f2330c94e1..4f01fa7fa272 100644 --- a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb +++ b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb @@ -38,7 +38,7 @@ module Wikis shared_let(:provider) { create(:internal_wiki_provider) } shared_let(:page_links) { create_list(:relation_wiki_page_link, 3, provider:) } - subject(:service) { described_class } + subject(:service) { described_class.new(relation) } before do query_double = instance_double(Adapters::Providers::Internal::Queries::PageInfo) @@ -58,7 +58,7 @@ module Wikis end it "returns a new relation" do - service_result = service.call(relation) + service_result = service.call expect(service_result).to be_success expect(service_result.errors).to be_empty @@ -66,7 +66,7 @@ module Wikis end it "adds the title attribute to the metadata association" do - service_result = service.call(relation) + service_result = service.call expect(service_result).to be_success page_links = service_result.result From 07673624c0a33c1d97bf1b187b92639e8fdc568a Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 18:54:56 +0200 Subject: [PATCH 12/15] Unify all the SQL generation on a single method --- .../wikis/page_link_metadata_service.rb | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index 50ba7e0621ae..ab224978a7f7 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -60,25 +60,15 @@ def build_inputs(page_links) def enrich_models(metadata) identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } - variable_placeholders = build_placeholders(identifier_title_map.size) - - result_scope(metadata_join_sql(variable_placeholders, identifier_title_map)) - end - - def result_scope(join_expression) - relation.joins(join_expression).select("wiki_page_links.*, metadata.title as title") - end - - def metadata_join_sql(variable_placeholders, identifier_title_map) - ActiveRecord::Base.sanitize_sql_array([variable_placeholders, *identifier_title_map.flatten]) - end - - def build_placeholders(amount) - variable_placeholders = Array.new(amount, "(?,?)").join(",") - <<~SQL.squish + variable_placeholders = Array.new(identifier_title_map.size, "(?,?)").join(",") + join_string = <<~SQL.squish LEFT JOIN (VALUES #{variable_placeholders}) AS metadata(identifier, title) ON metadata.identifier = wiki_page_links.identifier SQL + + join_expression = ActiveRecord::Base.sanitize_sql_array([join_string, *identifier_title_map.flatten]) + + relation.joins(join_expression).select("wiki_page_links.*, metadata.title as title") end end end From e48a24a2579c09e39dbca280092639f946f6a438 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Thu, 7 May 2026 19:24:31 +0200 Subject: [PATCH 13/15] Include provider_id into the JOIN clause --- .../app/services/wikis/page_link_metadata_service.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/wikis/app/services/wikis/page_link_metadata_service.rb b/modules/wikis/app/services/wikis/page_link_metadata_service.rb index ab224978a7f7..41f531edb81f 100644 --- a/modules/wikis/app/services/wikis/page_link_metadata_service.rb +++ b/modules/wikis/app/services/wikis/page_link_metadata_service.rb @@ -59,11 +59,11 @@ def build_inputs(page_links) end def enrich_models(metadata) - identifier_title_map = metadata.sort_by(&:identifier).to_h { [it.identifier, it.title] } - variable_placeholders = Array.new(identifier_title_map.size, "(?,?)").join(",") + identifier_title_map = metadata.map { [it.identifier, it.title, it.provider.id] } + variable_placeholders = Array.new(identifier_title_map.size, "(?,?,?)").join(",") join_string = <<~SQL.squish - LEFT JOIN (VALUES #{variable_placeholders}) AS metadata(identifier, title) - ON metadata.identifier = wiki_page_links.identifier + LEFT JOIN (VALUES #{variable_placeholders}) AS metadata(identifier, title, provider_id) + ON metadata.identifier = wiki_page_links.identifier AND metadata.provider_id = wiki_page_links.provider_id SQL join_expression = ActiveRecord::Base.sanitize_sql_array([join_string, *identifier_title_map.flatten]) From 94b094ebf0ae6fb11392ed99a25d389642a8aed2 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Fri, 8 May 2026 11:04:36 +0200 Subject: [PATCH 14/15] Adds a test for multiple providers and same identifiers --- .../wikis/page_link_metadata_service_spec.rb | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb index 4f01fa7fa272..8031a996b320 100644 --- a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb +++ b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb @@ -34,6 +34,8 @@ module Wikis RSpec.describe PageLinkMetadataService do let(:relation) { PageLink.limit(30) } + let(:query_double) { instance_double(Adapters::Providers::Internal::Queries::PageInfo) } + let(:query_class_double) { class_double(Adapters::Providers::Internal::Queries::PageInfo) } shared_let(:provider) { create(:internal_wiki_provider) } shared_let(:page_links) { create_list(:relation_wiki_page_link, 3, provider:) } @@ -41,8 +43,7 @@ module Wikis subject(:service) { described_class.new(relation) } before do - query_double = instance_double(Adapters::Providers::Internal::Queries::PageInfo) - query_class_double = class_double(Adapters::Providers::Internal::Queries::PageInfo, new: query_double) + allow(query_class_double).to receive(:new).with(model: provider).and_return(query_double) Adapters::Registry.stub("internal.queries.page_info", query_class_double) build_inputs.each do |input| @@ -73,10 +74,52 @@ module Wikis expect(page_links.first.title).to eq("Wikis, now with more cheese! Part #{page_links.first.identifier}") end + context "when page links have the same identifier but different providers" do + shared_let(:xwiki_provider) { create(:xwiki_provider) } + let(:new_page_links) do + page_links.map do |pl| + create(:relation_wiki_page_link, provider: xwiki_provider, identifier: pl) + end + end + + before do + allow(query_class_double).to receive(:new).with(model: xwiki_provider).and_return(query_double) + Adapters::Registry.stub("xwiki.queries.page_info", query_class_double) + + new_page_links.map do |pl| + input = Adapters::Input::PageInfo.build(identifier: pl.identifier).value_or(nil) + allow(query_double).to receive(:call).with(input).and_return( + Success( + Adapters::Results::PageInfo.new(title: "Wikis, now with more cheese! Part #{pl.id}", + identifier: input.identifier, + href: "totally_valid_url", + provider: pl.provider) + ) + ) + end + end + + it "maps the titles to the correct page link" do + service_result = service.call + + expect(service_result).to be_success + relation = service_result.result + + relation.find_each do |page_link| + case page_link.provider_id + when xwiki_provider.id + expect(page_link.title).to eq("Wikis, now with more cheese! Part #{page_link.id}") + else + expect(page_link.title).to eq("Wikis, now with more cheese! Part #{page_link.identifier}") + end + end + end + end + private - def build_inputs - page_links.filter_map { Adapters::Input::PageInfo.build(identifier: it.identifier).value_or(nil) } + def build_inputs(relation = page_links) + relation.filter_map { Adapters::Input::PageInfo.build(identifier: it.identifier).value_or(nil) } end end end From 1f42c3f604601d830ea1d0438298354aaad95974 Mon Sep 17 00:00:00 2001 From: Marcello Rocha Date: Fri, 8 May 2026 11:18:04 +0200 Subject: [PATCH 15/15] Update modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb Co-authored-by: Jan Sandbrink --- .../wikis/page_link_metadata_service_spec.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb index 8031a996b320..d55a37179035 100644 --- a/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb +++ b/modules/wikis/spec/services/wikis/page_link_metadata_service_spec.rb @@ -78,22 +78,23 @@ module Wikis shared_let(:xwiki_provider) { create(:xwiki_provider) } let(:new_page_links) do page_links.map do |pl| - create(:relation_wiki_page_link, provider: xwiki_provider, identifier: pl) + create(:relation_wiki_page_link, provider: xwiki_provider, identifier: pl.identifier) end end before do - allow(query_class_double).to receive(:new).with(model: xwiki_provider).and_return(query_double) + new_double = instance_double(Adapters::Providers::Internal::Queries::PageInfo) + allow(query_class_double).to receive(:new).with(model: xwiki_provider).and_return(new_double) Adapters::Registry.stub("xwiki.queries.page_info", query_class_double) new_page_links.map do |pl| input = Adapters::Input::PageInfo.build(identifier: pl.identifier).value_or(nil) - allow(query_double).to receive(:call).with(input).and_return( + allow(new_double).to receive(:call).with(input).and_return( Success( Adapters::Results::PageInfo.new(title: "Wikis, now with more cheese! Part #{pl.id}", identifier: input.identifier, href: "totally_valid_url", - provider: pl.provider) + provider: xwiki_provider) ) ) end @@ -103,9 +104,9 @@ module Wikis service_result = service.call expect(service_result).to be_success - relation = service_result.result + page_links = service_result.result - relation.find_each do |page_link| + page_links.find_each do |page_link| case page_link.provider_id when xwiki_provider.id expect(page_link.title).to eq("Wikis, now with more cheese! Part #{page_link.id}")