Work package results on the search page build their link through the
acts_as_event url proc, which passed the numeric primary key instead of
the work package's display id. In semantic mode this rendered
/work_packages/<id> even though the row showed the semantic identifier,
unlike Rails URL helpers that already resolve the object via to_param.
Pass display_id so the link follows the same convention everywhere.
Sort WP lists by project identifier, not project_id, in semantic mode
The semantic-mode "ID" sort grouped projects by project_id (insertion
order) before the per-project sequence. Projects added after others
landed below them in the list even when their identifier sorted
alphabetically earlier. Sort by projects.identifier instead so the order
matches the visible "<project identifier>-<sequence>" column.
The projects table is already joined for every work-package list query,
so the new sort term costs no extra round-trip.
The `mentioned` and `watcher_changed` text-mailer bodies surfaced raw
journal markdown — numeric `#42` references stayed numeric in semantic
mode, and `<mention>` envelopes leaked as HTML source.
Introduces `:plain_text` as a sibling format inside the existing Plain
module. The filter chain mirrors the markdown pipeline (markdown,
sanitization, mention, pattern-matcher) and finishes with a new
`PlainTextOutputFilter` that collapses the DOM to text. The
`WorkPackages` link handler and `MentionFilter` get plain-text branches
keyed off `context[:plain_text]` so identifier resolution stays in one
place across rich and plain channels.
Closes https://community.openproject.org/wp/74762
Sort work packages by sequence_number in semantic mode
In semantic mode the visible "ID" is `<project_identifier>-<sequence_number>`,
but the list sort key was the database primary key. A work package moved
between projects keeps its primary key and receives a new sequence number
in the target project, so it could appear above natively-created rows with
higher sequence numbers — e.g. `LARGE-3` above `LARGE-1` and `LARGE-2`.
The "ID" sortable on the work-package query select now resolves at request
time. In semantic mode it returns `(work_packages.project_id, work_packages
.sequence_number)`, backed by the existing partial unique index. In classic
mode it stays `work_packages.id`. The Proc resolution lives in
`WorkPackageSelect#sortable` so every consumer (`Query::SortCriteria`,
group-by composition, the HAL/API sortBy resource) sees the resolved value.
Scope semantic-id resolution through `WorkPackage.visible` so a reference to a
work package outside the current user's visibility falls through to literal
text. Without this, semantic identifiers (guessable, project-prefixed) would
disclose existence: a hidden `#PROJ-1` would resolve and render a mention,
whereas a non-existent `#GHOST-1` would fall through — a discoverable oracle.
Spec pins the new contract; matches the in-app preload pipeline which already
uses `WorkPackage.visible`.
Drop the `to_i.to_s` round-trip on the numeric branch. `numeric_id?` already
verifies the matched string is canonical, so the round-trip is a no-op.
Tighten the alias spec to assert the rendered `data-text` carries the current
identifier, not just that the old one is absent. Trim the `render_link`
comment to the load-bearing claim (defence-in-depth escape).
In semantic mode, the PDF text-macro handler resolves `#PROJ-1`,
`##PROJ-1`, and `###PROJ-1` through `find_by_display_id` and emits a
`<mention>` whose `data-id` carries the display id straight through to
the PDF renderer. Cache misses (unknown identifier, deleted WP) fall
through to literal text. Historical aliases resolve to the current
identifier. Classic mode rejects semantic-shape input — `#PROJ-1` in
a classic-mode export stays literal.
Numeric input keeps its current path: the matcher emits the mention
from the matched id without a DB lookup.
`render_link` HTML-escapes both interpolated values. The matcher
regex (`ID_ROUTE_CONSTRAINT`) already constrains shape to
`\d+|[A-Z][A-Z0-9_]*-\d+`, so the escape is defence-in-depth: a
future caller bypassing the matcher cannot regress into HTML
attribute injection.
The fast-path filter `Links.applicable?` matches `/#[A-Z\d]/` so
semantic-only bodies reach the full regex.
Downstream `Exports::PDF::Common::Markdown#wp_mention_macro` is
updated to `find_by_display_id` in PR 23093. This commit must land
after that PR — otherwise its existing `id[/\d+/]; find_by(id:)`
extracts the trailing digits from `data-id="PROJ-7"` and resolves
to WP id 7.
Cuts comments that restate code mechanics, narrate the journey, or
pile on implementation detail. Genuine WHYs (perf rationale of the
to_i.to_s round-trip, the alias second-query reason, leading-zero
rejection, classic-mode preload skip) all stayed.
`Macros::Links` extends `ResourceLinksMatcher`, so the regex broadening
in the previous commit also matches `#PROJ-1` shapes inside markdown
that's about to be exported as PDF. Without an explicit guard, the PDF
custom handler would silently emit `<mention data-id="0">` (since
`"PROJ-1".to_i == 0`) — broken-looking output that's hard to attribute.
Tighten `WorkPackagesLinkHandler#applicable?` to reject semantic-shape
input. Override `call` so it doesn't fall through the parent's
cache-driven path (PDF rendering walks Markly nodes via a separate
pipeline that doesn't populate the per-render cache). Cite WP #74366 in
the comment as the follow-up that adds semantic-id support to the PDF
side via the Markly walk in `app/models/exports/pdf/common/macro.rb`.
New specs assert `#PROJ-1` falls through to literal text in PDF output
and never produces a `data-id="0"` mention, both alone and mixed with
numeric references.
Splat with a depth-1 flatten lets callers pass scalars, varargs, or a
pre-built array interchangeably. `Array(values)` would have been
misleading here — `Array([[1, "a"], 2])` leaves the inner array
intact and `map(&:to_s)` stringifies it as `"[1, \"a\"]"`, which
then misclassifies through the semantic-id branch. Bounded `flatten(1)`
absorbs the (scalar) and ([scalar, scalar]) shapes that production
code already uses while leaving deeper nesting alone, so the same
pathology produces no match rather than silently working.
Refs https://github.com/opf/openproject/pull/23202#discussion_r3235337043
The route constraint composes the numeric and semantic shapes from
SEMANTIC_ID_PATTERN, which itself composes the project-identifier shape
from `Projects::Identifier::SEMANTIC_FORMAT`. A future change to either
upstream pattern would shift what URLs Rails accepts without touching
routes.rb. Three anchored assertions lock the boundary in place — the
constant is used only as a route constraint, so anchored matching mirrors
the actual call site.
`semantic_id?` already strips surrounding whitespace before its round-trip
check, so `" 123 "` was classified as not-semantic. `numeric_id?` did
not strip, so the same value was also not-numeric — leaving it
"neither" and unreachable through normal routing.
Stripping in `numeric_id?` and expressing the String branch as
`!semantic_id?(value)` removes the duplicated round-trip logic and gives
the routing predicates a single source of truth: any String classified
not-semantic is necessarily numeric. Integer/nil/other types are
unaffected — they fall on their own branches.
Routing-table spec gains a `" 123 "` row to lock the symmetry in.
The `value == value.to_i.to_s` round-trip check that filters leading-
zero ID forms ("0123") was duplicated across the WP link handler, the
PDF export macro, and the cost-query filter.
A new `WorkPackage::SemanticIdentifier.numeric_id?(value)` predicate
captures the canonical-numeric check at one site. It pairs with
`semantic_id?` as the WP-finder shape gate; the two answer different
questions (shape vs routing) and so are kept independent rather than
expressed as one another's negation.
The cost-query filter switches to the predicate in this slice; the
text-formatting and PDF callers convert in a follow-up.
The autocomplete dropdown in CKEditor renders each result with
`item.name = wp.to_s`, and `to_s` hardcoded `##{id}` — so a semantic-mode
search for `#KSTP-2` resolved correctly but the dropdown row showed
`Task #10244: Subject` instead of `Task KSTP-2: Subject`.
Switch to `formatted_id`, which already produces the user-facing form in
both modes (`PROJ-7` semantic, `#42` classic). The other caller — the
delete-dialog component — benefits from the same mode awareness.