diff --git a/README.md b/README.md
index b9185a4..fddbe3c 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/assets/javascripts/discourse/components/hledger-account-cell.gjs b/assets/javascripts/discourse/components/hledger-account-cell.gjs
index ef4b4c9..c27b086 100644
--- a/assets/javascripts/discourse/components/hledger-account-cell.gjs
+++ b/assets/javascripts/discourse/components/hledger-account-cell.gjs
@@ -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 =
|
}}
title={{@row.account}}
>
- {{@row.label}}
+ {{#if @row.mention}}{{else}}{{@row.label}}{{/if}}
|
;
diff --git a/assets/javascripts/discourse/components/hledger-account-path.gjs b/assets/javascripts/discourse/components/hledger-account-path.gjs
new file mode 100644
index 0000000..698590c
--- /dev/null
+++ b/assets/javascripts/discourse/components/hledger-account-path.gjs
@@ -0,0 +1,12 @@
+import HledgerMention from "./hledger-mention";
+
+const HledgerAccountPath =
+ {{#each @segments as |segment index|}}{{#if index}}:{{/if}}{{#if
+ segment.username
+ }}{{else}}{{segment.text}}{{/if}}{{/each}}
+;
+
+export default HledgerAccountPath;
diff --git a/assets/javascripts/discourse/components/hledger-dashboard.gjs b/assets/javascripts/discourse/components/hledger-dashboard.gjs
index 65e9bbe..d68d0f2 100644
--- a/assets/javascripts/discourse/components/hledger-dashboard.gjs
+++ b/assets/javascripts/discourse/components/hledger-dashboard.gjs
@@ -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 {
| {{posting.account}} |
+ title={{posting.account}}
+ >
+ {{#if posting.account_segments}}
+
+ {{else}}
+ {{posting.account}}
+ {{/if}}
+
{{amountText
posting.amounts
}} |
diff --git a/assets/javascripts/discourse/components/hledger-mention.gjs b/assets/javascripts/discourse/components/hledger-mention.gjs
new file mode 100644
index 0000000..47a4eaa
--- /dev/null
+++ b/assets/javascripts/discourse/components/hledger-mention.gjs
@@ -0,0 +1,11 @@
+import { userPath } from "discourse/lib/url";
+
+const HledgerMention =
+ {{@text}}
+;
+
+export default HledgerMention;
diff --git a/lib/hledger/mentions.rb b/lib/hledger/mentions.rb
new file mode 100644
index 0000000..4135f9f
--- /dev/null
+++ b/lib/hledger/mentions.rb
@@ -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
diff --git a/lib/hledger/report_builder.rb b/lib/hledger/report_builder.rb
index 5784206..338d5a0 100644
--- a/lib/hledger/report_builder.rb
+++ b/lib/hledger/report_builder.rb
@@ -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
diff --git a/spec/lib/hledger/integration_spec.rb b/spec/lib/hledger/integration_spec.rb
index b5865c2..55ae5ea 100644
--- a/spec/lib/hledger/integration_spec.rb
+++ b/spec/lib/hledger/integration_spec.rb
@@ -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,
diff --git a/spec/lib/hledger/mentions_spec.rb b/spec/lib/hledger/mentions_spec.rb
new file mode 100644
index 0000000..dbbbaa1
--- /dev/null
+++ b/spec/lib/hledger/mentions_spec.rb
@@ -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
diff --git a/test/javascripts/acceptance/hledger-test.js b/test/javascripts/acceptance/hledger-test.js
index ca663d1..300dfe3 100644
--- a/test/javascripts/acceptance/hledger-test.js
+++ b/test/javascripts/acceptance/hledger-test.js
@@ -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");
});
});