Turn @usernames into Discourse user mentions/links

No notifications are sent for appearing in a journal, but profile
popovers etc. all work like mentions anywhere else in a topic.
This commit is contained in:
raucao committed 2026-09-27 13:50:17 +02:00
1 parent b82fc0fc80
commit 7872a1a0eb
10 files changed
+291 -14

No files matched your search

+5
View File
@@ -50,6 +50,11 @@ docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev
Reports are generated on demand and cached per post revision, so editing the
journal refreshes them.
Account names can reference Discourse users: a path segment written as
`@username` (for example `equity:@alice`) is rendered as a mention link, with the
same profile card as mentions in posts. Only segments matching an existing user
are linked; anything else stays plain text.
## Security
Journals are untrusted input. The plugin runs `hledger` with a scrubbed
@@ -1,5 +1,6 @@
import dConcatClass from "discourse/ui-kit/helpers/d-concat-class";
import { depthClass } from "../lib/hledger-report";
import HledgerMention from "./hledger-mention";
const HledgerAccountCell = <template>
<td
@@ -10,7 +11,10 @@ const HledgerAccountCell = <template>
}}
title={{@row.account}}
>
{{@row.label}}
{{#if @row.mention}}<HledgerMention
@username={{@row.mention.username}}
@text={{@row.mention.text}}
/>{{else}}{{@row.label}}{{/if}}
</td>
</template>;
@@ -0,0 +1,12 @@
import HledgerMention from "./hledger-mention";
const HledgerAccountPath = <template>
{{#each @segments as |segment index|}}{{#if index}}:{{/if}}{{#if
segment.username
}}<HledgerMention
@username={{segment.username}}
@text={{segment.text}}
/>{{else}}{{segment.text}}{{/if}}{{/each}}
</template>;
export default HledgerAccountPath;
@@ -10,6 +10,7 @@ import DDatePicker from "discourse/ui-kit/d-date-picker";
import { i18n } from "discourse-i18n";
import { amountText } from "../lib/hledger-report";
import HledgerAccountCell from "./hledger-account-cell";
import HledgerAccountPath from "./hledger-account-path";
const REPORT_IDS = [
"accounts",
@@ -220,7 +221,16 @@ export default class HledgerDashboard extends Component {
<tr>
<td
class="hledger-dashboard__account"
>{{posting.account}}</td>
title={{posting.account}}
>
{{#if posting.account_segments}}
<HledgerAccountPath
@segments={{posting.account_segments}}
/>
{{else}}
{{posting.account}}
{{/if}}
</td>
<td class="hledger-dashboard__amount">{{amountText
posting.amounts
}}</td>
@@ -0,0 +1,11 @@
import { userPath } from "discourse/lib/url";
const HledgerMention = <template>
<a
class="mention"
data-user-card={{@username}}
href={{userPath @username}}
>{{@text}}</a>
</template>;
export default HledgerMention;
+94
View File
@@ -0,0 +1,94 @@
# frozen_string_literal: true
module Hledger
# Resolves `@username` account segments against real users so the client can
# link them the same way mentions are linked in posts.
#
# Resolution is intentionally server-side: like PrettyText's post pipeline,
# only existing accounts become links, so unknown names stay plain text.
module Mentions
module_function
def annotate!(report, enabled: SiteSetting.enable_mentions)
return report unless enabled
resolved = resolve(collect_names(report))
return report if resolved.empty?
walk(report) do |hash|
if hash.key?("label") && hash.key?("account")
annotate_node(hash, resolved)
elsif hash.key?("account")
annotate_posting(hash, resolved)
end
end
report
end
def resolve(names)
return {} if names.blank?
lookup = ::PrettyText.lookup_mentions(names.uniq)
lookup.select { |_name, type| type == ::PrettyText::USER_TYPE }.keys.index_with(true)
end
def collect_names(report)
names = []
walk(report) do |hash|
if hash.key?("label") && hash.key?("account")
name = mention_name(hash["label"])
names << name if name
elsif hash.key?("account")
segments_of(hash["account"]).each do |segment|
name = mention_name(segment)
names << name if name
end
end
end
names
end
def annotate_node(node, resolved)
segment = node["label"].to_s
name = mention_name(segment)
return unless name && resolved[name]
node["mention"] = { "username" => name, "text" => segment }
end
def annotate_posting(posting, resolved)
posting["account_segments"] = segments_of(posting["account"]).map do |segment|
name = mention_name(segment)
if name && resolved[name]
{ "text" => segment, "username" => name }
else
{ "text" => segment }
end
end
end
def mention_name(segment)
text = segment.to_s
return if text.length < 2 || !text.start_with?("@")
text[1..].downcase
end
def segments_of(account)
account.to_s.split(":")
end
def walk(value, &block)
case value
when Hash
yield value
value.each_value { |nested| walk(nested, &block) }
when Array
value.each { |nested| walk(nested, &block) }
end
end
end
end
+15 -12
View File
@@ -21,18 +21,21 @@ module Hledger
def build(report_type, journal:, begin_date: nil, end_date: nil)
raise UnavailableError if @runner.version.blank?
case report_type.to_s
when "accounts"
build_accounts(journal, begin_date, end_date)
when "balance_sheet", "income_statement"
build_compound(report_type.to_s, journal, begin_date, end_date)
when "equity"
build_equity(journal, begin_date, end_date)
when "transactions"
build_transactions(journal, begin_date, end_date)
else
raise Error, "unsupported report type: #{report_type}"
end
report =
case report_type.to_s
when "accounts"
build_accounts(journal, begin_date, end_date)
when "balance_sheet", "income_statement"
build_compound(report_type.to_s, journal, begin_date, end_date)
when "equity"
build_equity(journal, begin_date, end_date)
when "transactions"
build_transactions(journal, begin_date, end_date)
else
raise Error, "unsupported report type: #{report_type}"
end
Mentions.annotate!(report)
end
private
+13
View File
@@ -78,6 +78,19 @@ RSpec.describe "Hledger integration" do
expect(postings.last["amounts"].first["display"]).to eq("-10.00 EUR")
end
it "links @username accounts to users" do
Fabricate(:user, username: "tony")
report = builder.build("accounts", journal: <<~JOURNAL)
2024-01-01 Capital
assets:bank 100.00 EUR
equity:members:@tony
JOURNAL
row = report["sections"].first["rows"].find { |candidate| candidate["label"] == "@tony" }
expect(row["mention"]).to eq("username" => "tony", "text" => "@tony")
end
it "rejects journals with include directives" do
expect(Hledger::Journal.extract("```hledger\ninclude /etc/hostname\n```").error).to eq(
:include_not_allowed,
+82
View File
@@ -0,0 +1,82 @@
# frozen_string_literal: true
RSpec.describe Hledger::Mentions do
fab!(:user) { Fabricate(:user, username: "tony") }
def accounts_report(*rows)
{ "report_type" => "accounts", "sections" => [{ "rows" => rows }] }
end
def row(account, label, depth)
{ "account" => account, "label" => label, "depth" => depth }
end
describe ".annotate!" do
it "annotates resolvable @username labels" do
report = accounts_report(row("equity:members:@tony", "@tony", 2))
described_class.annotate!(report)
expect(report["sections"][0]["rows"][0]["mention"]).to eq(
"username" => "tony",
"text" => "@tony",
)
end
it "leaves unresolved and non-mention labels untouched" do
report =
accounts_report(
row("equity:members:@nobody", "@nobody", 2),
row("equity:members", "Members", 1),
)
described_class.annotate!(report)
expect(report["sections"][0]["rows"][0]).not_to have_key("mention")
expect(report["sections"][0]["rows"][1]).not_to have_key("mention")
end
it "matches usernames case-insensitively while keeping the written text" do
report = accounts_report(row("equity:members:@Tony", "@Tony", 2))
described_class.annotate!(report)
expect(report["sections"][0]["rows"][0]["mention"]).to eq(
"username" => "tony",
"text" => "@Tony",
)
end
it "annotates posting account segments" do
report = {
"transactions" => [
{
"postings" => [{ "account" => "assets:bank" }, { "account" => "equity:members:@tony" }],
},
],
}
described_class.annotate!(report)
expect(report["transactions"][0]["postings"][0]["account_segments"]).to eq(
[{ "text" => "assets" }, { "text" => "bank" }],
)
expect(report["transactions"][0]["postings"][1]["account_segments"]).to eq(
[
{ "text" => "equity" },
{ "text" => "members" },
{ "text" => "@tony", "username" => "tony" },
],
)
end
it "does nothing when mentions are disabled" do
SiteSetting.enable_mentions = false
report = accounts_report(row("equity:members:@tony", "@tony", 2))
described_class.annotate!(report)
expect(report["sections"][0]["rows"][0]).not_to have_key("mention")
end
end
end
@@ -49,6 +49,23 @@ const accountsReport = {
{ commodity: "EUR", quantity: "-1000.0", display: "(1000.00 EUR)" },
],
},
{
account: "equity:members:@tony",
label: "@tony",
depth: 2,
root: false,
mention: { username: "tony", text: "@tony" },
amounts: [
{ commodity: "EUR", quantity: "-1000.0", display: "(1000.00 EUR)" },
],
},
{
account: "equity:members:@nobody",
label: "@nobody",
depth: 2,
root: false,
amounts: [{ commodity: "EUR", quantity: "0.0", display: "0.00 EUR" }],
},
],
total: [{ commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" }],
},
@@ -203,6 +220,17 @@ const transactionsReport = {
{ commodity: "EUR", quantity: "-400.0", display: "-400.00 EUR" },
],
},
{
account: "equity:members:@tony",
account_segments: [
{ text: "equity" },
{ text: "members" },
{ text: "@tony", username: "tony" },
],
amounts: [
{ commodity: "EUR", quantity: "-600.0", display: "-600.00 EUR" },
],
},
],
},
{
@@ -296,6 +324,15 @@ acceptance("Hledger plugin", function (needs) {
assert
.dom('.hledger-dashboard__account[title="assets:bank"]')
.hasText("Bank");
assert
.dom('a.mention[href="/u/tony"][data-user-card="tony"]')
.hasText("@tony", "links a mentioned account to its user");
assert
.dom('.hledger-dashboard__account[title="equity:members:@nobody"] a')
.doesNotExist("leaves an unknown mention as plain text");
assert
.dom('.hledger-dashboard__account[title="equity:members:@nobody"]')
.hasText("@nobody");
const rootAccount = document.querySelector(
".hledger-dashboard__account.--depth-0"
@@ -437,5 +474,11 @@ acceptance("Hledger plugin", function (needs) {
.dom(".hledger-dashboard__table")
.includesText("assets:bank:checking");
assert.dom(".hledger-dashboard__table").includesText("1000.00 EUR");
assert
.dom(".hledger-dashboard__table")
.includesText("equity:members:@tony", "keeps path separators");
assert
.dom('a.mention[href="/u/tony"][data-user-card="tony"]')
.hasText("@tony", "links a mentioned account in the journal");
});
});