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:
10 files changed
+291
-14
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user