From 7872a1a0eb76bca7b973731c285d8935bb588822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 27 Sep 2026 13:50:17 +0200 Subject: [PATCH] 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. --- README.md | 5 + .../components/hledger-account-cell.gjs | 6 +- .../components/hledger-account-path.gjs | 12 +++ .../components/hledger-dashboard.gjs | 12 ++- .../discourse/components/hledger-mention.gjs | 11 +++ lib/hledger/mentions.rb | 94 +++++++++++++++++++ lib/hledger/report_builder.rb | 27 +++--- spec/lib/hledger/integration_spec.rb | 13 +++ spec/lib/hledger/mentions_spec.rb | 82 ++++++++++++++++ test/javascripts/acceptance/hledger-test.js | 43 +++++++++ 10 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 assets/javascripts/discourse/components/hledger-account-path.gjs create mode 100644 assets/javascripts/discourse/components/hledger-mention.gjs create mode 100644 lib/hledger/mentions.rb create mode 100644 spec/lib/hledger/mentions_spec.rb 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 = ; 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 = ; + +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 = ; + +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"); }); });