From 5c6dce9fbcb8abf114369072438d9af33b10f0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 27 Sep 2026 10:18:38 +0200 Subject: [PATCH] MVP --- README.md | 72 ++++- .../hledger/examples_controller.rb | 11 - app/controllers/hledger/reports_controller.rb | 111 ++++++++ app/services/hledger/build_report.rb | 42 +++ .../javascripts/discourse-markdown/hledger.js | 62 +++++ .../discourse/api-initializers/hledger.js | 23 ++ .../components/hledger-dashboard.gjs | 247 +++++++++++++++++ assets/stylesheets/hledger.scss | 106 ++++++++ bin/setup-dev | 22 ++ config/locales/client.en.yml | 18 +- config/locales/server.en.yml | 21 ++ config/routes.rb | 5 +- config/settings.yml | 8 + lib/hledger/equity.rb | 68 +++++ lib/hledger/error.rb | 6 + lib/hledger/execution_error.rb | 6 + lib/hledger/journal.rb | 54 ++++ lib/hledger/parse_error.rb | 6 + lib/hledger/report_builder.rb | 255 ++++++++++++++++++ lib/hledger/runner.rb | 163 +++++++++++ lib/hledger/unavailable_error.rb | 6 + plugin.rb | 10 +- spec/fixtures/accounts.json | 150 +++++++++++ spec/fixtures/balance_sheet.csv | 8 + spec/fixtures/equity.json | 75 ++++++ spec/fixtures/income_statement.csv | 9 + spec/fixtures/journal.txt | 12 + spec/lib/hledger/equity_spec.rb | 37 +++ spec/lib/hledger/integration_spec.rb | 42 +++ spec/lib/hledger/journal_spec.rb | 51 ++++ spec/lib/hledger/report_builder_spec.rb | 116 ++++++++ spec/lib/hledger/runner_spec.rb | 51 ++++ spec/lib/pretty_text_spec.rb | 27 ++ spec/requests/hledger/reports_spec.rb | 134 +++++++++ test/javascripts/acceptance/hledger-test.js | 89 ++++++ 35 files changed, 2098 insertions(+), 25 deletions(-) delete mode 100644 app/controllers/hledger/examples_controller.rb create mode 100644 app/controllers/hledger/reports_controller.rb create mode 100644 app/services/hledger/build_report.rb create mode 100644 assets/javascripts/discourse-markdown/hledger.js create mode 100644 assets/javascripts/discourse/api-initializers/hledger.js create mode 100644 assets/javascripts/discourse/components/hledger-dashboard.gjs create mode 100644 assets/stylesheets/hledger.scss create mode 100755 bin/setup-dev create mode 100644 lib/hledger/equity.rb create mode 100644 lib/hledger/error.rb create mode 100644 lib/hledger/execution_error.rb create mode 100644 lib/hledger/journal.rb create mode 100644 lib/hledger/parse_error.rb create mode 100644 lib/hledger/report_builder.rb create mode 100644 lib/hledger/runner.rb create mode 100644 lib/hledger/unavailable_error.rb create mode 100644 spec/fixtures/accounts.json create mode 100644 spec/fixtures/balance_sheet.csv create mode 100644 spec/fixtures/equity.json create mode 100644 spec/fixtures/income_statement.csv create mode 100644 spec/fixtures/journal.txt create mode 100644 spec/lib/hledger/equity_spec.rb create mode 100644 spec/lib/hledger/integration_spec.rb create mode 100644 spec/lib/hledger/journal_spec.rb create mode 100644 spec/lib/hledger/report_builder_spec.rb create mode 100644 spec/lib/hledger/runner_spec.rb create mode 100644 spec/lib/pretty_text_spec.rb create mode 100644 spec/requests/hledger/reports_spec.rb create mode 100644 test/javascripts/acceptance/hledger-test.js diff --git a/README.md b/README.md index cf677ab..4d58ab4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,71 @@ -# **Hledger** Plugin +# **hledger** Plugin -**Plugin Summary** +Render [hledger](https://hledger.org) journal reports inside Discourse topics. -For more information, please see: **url to meta topic** +Put an hledger journal in a fenced `hledger` code block in a topic's first +post. The block is replaced by a dashboard with four reports, generated live +by the `hledger` command line tool: + +- Accounts and balances +- Balance sheet +- Income statement +- Equity distribution (contributed capital, not legal ownership) + +## Installation + +Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157). + +The plugin requires the `hledger` executable on the server. Install it with +your package manager (for example `apt-get install hledger`) and point the +`hledger path` site setting at it if it is not on the default `PATH`. + +### Docker development environment + +Inside the Discourse dev container, install the plugin's system dependencies +(hledger, Chromium and the Playwright browser used by the test suite) with: + +```sh +docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev +``` + +## How to use + +1. Enable the plugin under `Admin > Settings > Plugins` (`hledger enabled`). +2. Add an hledger journal to the first post of a topic: + + ```` + ```hledger + 2024-01-01 Opening balances + assets:bank:checking 1000.00 EUR + equity:alice -600.00 EUR + equity:bob -400.00 EUR + ``` + ```` + +3. The code block is replaced by the report dashboard. Reports can be filtered + by a start and end date; the end date is inclusive. + +Reports are generated on demand and cached per post revision, so editing the +journal refreshes them. + +## Security + +Journals are untrusted input. The plugin runs `hledger` with a scrubbed +environment, a private working directory, a hard timeout, resource limits and +an output cap, and it rejects `include` directives so a journal cannot read +arbitrary server files. + +## Development + +```sh +# Ruby specs (run `bin/setup-dev` first for the integration specs) +bin/rspec plugins/hledger/spec/lib plugins/hledger/spec/requests +bin/rspec plugins/hledger/spec/system + +# Frontend tests +DISCOURSE_DISABLE_BROWSER_SANDBOX=1 bin/qunit plugins/hledger/test/javascripts +``` + +## License + +MIT diff --git a/app/controllers/hledger/examples_controller.rb b/app/controllers/hledger/examples_controller.rb deleted file mode 100644 index 4a00d88..0000000 --- a/app/controllers/hledger/examples_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module ::Hledger - class ExamplesController < ::ApplicationController - requires_plugin PLUGIN_NAME - - def index - render json: { hello: "world" } - end - end -end diff --git a/app/controllers/hledger/reports_controller.rb b/app/controllers/hledger/reports_controller.rb new file mode 100644 index 0000000..bdea383 --- /dev/null +++ b/app/controllers/hledger/reports_controller.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module ::Hledger + class ReportsController < ::ApplicationController + requires_plugin PLUGIN_NAME + + RATE_LIMIT_MAX = 30 + RATE_LIMIT_SECONDS = 60 + CACHE_TTL = 5.minutes + + before_action :ensure_enabled + before_action :find_topic + before_action :enforce_rate_limit + + def show + report_type = params[:report_type].to_s + + if !Hledger::ReportBuilder.supported?(report_type) + return render_json_error(I18n.t("hledger.errors.unsupported_report"), status: 422) + end + + begin_date = parse_date(params[:begin]) + end_date = parse_date(params[:end]) + + if begin_date == :invalid || end_date == :invalid + return render_json_error(I18n.t("hledger.errors.invalid_date"), status: 422) + end + + first_post = @topic.first_post + cache_key = cache_key_for(first_post, report_type, begin_date, end_date) + + if (cached = Discourse.cache.read(cache_key)) + return render(json: cached) + end + + Hledger::BuildReport.call( + params: { + raw: first_post&.raw, + report_type: report_type, + begin_date: begin_date, + end_date: end_date, + }, + runner: Hledger::Runner.new, + ) do + on_success do |report:| + Discourse.cache.write(cache_key, report, expires_in: CACHE_TTL) + render json: report + end + on_failed_step(:extract_journal) { |step| render_journal_error(step.error) } + on_failed_contract do |contract| + render_json_error(contract.errors.full_messages, status: 422) + end + on_exceptions(Hledger::UnavailableError) do + render_json_error(I18n.t("hledger.errors.unavailable"), status: 503) + end + on_exceptions(Hledger::Error) do + render_json_error(I18n.t("hledger.errors.execution"), status: 422) + end + on_failure { render_json_error(I18n.t("hledger.errors.generic"), status: 422) } + end + end + + private + + def ensure_enabled + raise Discourse::NotFound if !SiteSetting.hledger_enabled + end + + def find_topic + @topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound if @topic.blank? + raise Discourse::NotFound if !guardian.can_see?(@topic) + end + + def enforce_rate_limit + RateLimiter.new( + current_user, + "hledger-reports-#{request.remote_ip}", + RATE_LIMIT_MAX, + RATE_LIMIT_SECONDS, + ).performed! + end + + def parse_date(value) + return nil if value.blank? + + Date.iso8601(value.to_s) + rescue Date::Error + :invalid + end + + def cache_key_for(first_post, report_type, begin_date, end_date) + [ + "hledger", + @topic.id, + first_post&.version, + report_type, + begin_date, + end_date, + SiteSetting.hledger_path, + ].join(":") + end + + def render_journal_error(error) + key = "hledger.errors.journal.#{error}" + key = "hledger.errors.journal.invalid" if !I18n.exists?(key) + + render_json_error(I18n.t(key), status: error == :none ? 404 : 422) + end + end +end diff --git a/app/services/hledger/build_report.rb b/app/services/hledger/build_report.rb new file mode 100644 index 0000000..a391786 --- /dev/null +++ b/app/services/hledger/build_report.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Hledger + class BuildReport + include Service::Base + + params do + attribute :raw, :string + attribute :report_type, :string + attribute :begin_date + attribute :end_date + + validates :report_type, + presence: true, + inclusion: { + in: Hledger::ReportBuilder::REPORT_TYPES, + } + end + + step :extract_journal + + try { step :build } + + private + + def extract_journal(params:) + result = Hledger::Journal.extract(params.raw) + fail!(result.error) unless result.ok? + context[:journal_source] = result.source + end + + def build(params:, journal_source:, runner:) + builder = Hledger::ReportBuilder.new(runner:) + context[:report] = builder.build( + params.report_type, + journal: journal_source, + begin_date: params.begin_date, + end_date: params.end_date, + ) + end + end +end diff --git a/assets/javascripts/discourse-markdown/hledger.js b/assets/javascripts/discourse-markdown/hledger.js new file mode 100644 index 0000000..48d8abe --- /dev/null +++ b/assets/javascripts/discourse-markdown/hledger.js @@ -0,0 +1,62 @@ +export function setup(helper) { + if (!helper.markdownIt) { + return; + } + + helper.allowList(["div.hledger-dashboard"]); + + helper.registerOptions((opts, siteSettings) => { + opts.features.hledger = siteSettings.hledger_enabled; + }); + + helper.registerPlugin((md) => { + if (!md.options.discourse.features.hledger) { + return; + } + + md.block.ruler.before( + "fence", + "hledger_dashboard", + (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + const opening = state.src.slice(start, max); + + if (!/^\s*`{3,}hledger\s*$/.test(opening)) { + return false; + } + + let closingLine = startLine + 1; + let closed = false; + + for (; closingLine < endLine; closingLine++) { + const pos = state.bMarks[closingLine] + state.tShift[closingLine]; + const lineMax = state.eMarks[closingLine]; + if (/^\s*`{3,}\s*$/.test(state.src.slice(pos, lineMax))) { + closed = true; + break; + } + } + + if (!closed) { + return false; + } + + if (silent) { + return true; + } + + const token = state.push("hledger_dashboard", "div", 0); + token.block = true; + token.map = [startLine, closingLine + 1]; + state.line = closingLine + 1; + + return true; + }, + { alt: ["paragraph", "reference", "blockquote", "list"] } + ); + + md.renderer.rules.hledger_dashboard = () => + `
\n`; + }); +} diff --git a/assets/javascripts/discourse/api-initializers/hledger.js b/assets/javascripts/discourse/api-initializers/hledger.js new file mode 100644 index 0000000..bfb5655 --- /dev/null +++ b/assets/javascripts/discourse/api-initializers/hledger.js @@ -0,0 +1,23 @@ +import { apiInitializer } from "discourse/lib/api"; +import HledgerDashboard from "../components/hledger-dashboard"; + +export default apiInitializer((api) => { + api.decorateCookedElement( + (element, helper) => { + const node = element.querySelector(".hledger-dashboard"); + + if (!node || !helper) { + return; + } + + const post = helper.getModel(); + + if (!post || post.post_number !== 1) { + return; + } + + helper.renderGlimmer(node, HledgerDashboard, { post }); + }, + { onlyStream: true, id: "hledger" } + ); +}); diff --git a/assets/javascripts/discourse/components/hledger-dashboard.gjs b/assets/javascripts/discourse/components/hledger-dashboard.gjs new file mode 100644 index 0000000..619b505 --- /dev/null +++ b/assets/javascripts/discourse/components/hledger-dashboard.gjs @@ -0,0 +1,247 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { eq } from "discourse/truth-helpers"; +import DButton from "discourse/ui-kit/d-button"; +import DConditionalLoadingSpinner from "discourse/ui-kit/d-conditional-loading-spinner"; +import DDatePicker from "discourse/ui-kit/d-date-picker"; +import { i18n } from "discourse-i18n"; + +const REPORT_IDS = ["accounts", "balance_sheet", "income_statement", "equity"]; + +export default class HledgerDashboard extends Component { + @tracked selectedReport = "accounts"; + @tracked report = null; + @tracked loading = false; + @tracked error = null; + @tracked beginDate = null; + @tracked endDate = null; + + constructor() { + super(...arguments); + this.loadReport(); + } + + get reports() { + return REPORT_IDS.map((id) => ({ + id, + label: i18n(`hledger.reports.${id}`), + icon: { + accounts: "list", + balance_sheet: "scale-balanced", + income_statement: "chart-line", + equity: "users", + }[id], + })); + } + + get topicId() { + return this.args.data?.post?.topic_id; + } + + get isEquity() { + return this.selectedReport === "equity"; + } + + get labels() { + return { + total: i18n("hledger.total"), + net: i18n("hledger.net"), + holder: i18n("hledger.equity.holder"), + amount: i18n("hledger.equity.amount"), + share: i18n("hledger.equity.share"), + note: i18n("hledger.equity.note"), + beginDate: i18n("hledger.filters.begin"), + endDate: i18n("hledger.filters.end"), + }; + } + + @action + select(reportId) { + if (this.selectedReport === reportId) { + return; + } + + this.selectedReport = reportId; + this.loadReport(); + } + + @action + setBeginDate(value) { + this.beginDate = value; + this.loadReport(); + } + + @action + setEndDate(value) { + this.endDate = value; + this.loadReport(); + } + + @action + async loadReport() { + const topicId = this.topicId; + + if (!topicId) { + return; + } + + this.loading = true; + this.error = null; + + const data = {}; + if (this.beginDate) { + data.begin = this.beginDate; + } + if (this.endDate) { + data.end = this.endDate; + } + + try { + this.report = await ajax( + `/hledger/topics/${topicId}/reports/${this.selectedReport}`, + { data } + ); + } catch (e) { + this.report = null; + this.error = + e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic"); + } finally { + this.loading = false; + } + } + + amountText(amounts) { + return (amounts || []).map((amount) => amount.display).join(", "); + } + + depthClass(depth) { + return `hledger-dashboard__account--depth-${Math.min(depth || 0, 5)}`; + } + + +} diff --git a/assets/stylesheets/hledger.scss b/assets/stylesheets/hledger.scss new file mode 100644 index 0000000..782dda0 --- /dev/null +++ b/assets/stylesheets/hledger.scss @@ -0,0 +1,106 @@ +.hledger-dashboard { + margin: 1em 0; + + &__inner { + padding: 0.75em 1em; + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius); + background: var(--primary-very-low); + } + + &__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.5em; + + .btn.is-active { + color: var(--secondary); + background: var(--tertiary); + } + } + + &__filters { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.75em; + } + + &__section + &__section { + margin-top: 1em; + } + + &__section-title { + margin: 0 0 0.25em; + font-size: var(--font-down-1); + color: var(--primary-medium); + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-down-1); + + th, + td { + padding: 0.25em 0.5em; + text-align: start; + border-bottom: 1px solid var(--primary-low); + } + } + + &__amount { + text-align: end; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + &__account { + padding-inline-start: 0.5em; + + &--depth-1 { + padding-inline-start: 1.5em; + } + + &--depth-2 { + padding-inline-start: 2.5em; + } + + &--depth-3 { + padding-inline-start: 3.5em; + } + + &--depth-4 { + padding-inline-start: 4.5em; + } + + &--depth-5 { + padding-inline-start: 5.5em; + } + } + + &__total { + font-weight: bold; + } + + &__net { + display: flex; + justify-content: space-between; + gap: 1em; + margin-top: 0.75em; + padding-top: 0.5em; + border-top: 2px solid var(--primary-low-mid); + font-weight: bold; + } + + &__note { + margin: 0.75em 0 0; + font-size: var(--font-down-2); + color: var(--primary-medium); + } + + &__error { + margin: 0; + } +} diff --git a/bin/setup-dev b/bin/setup-dev new file mode 100755 index 0000000..deb0f96 --- /dev/null +++ b/bin/setup-dev @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Install the system dependencies needed to develop and test the hledger plugin. +# +# Run inside the dev container as root, for example: +# docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root inside the dev container, eg: docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev" >&2 + exit 1 +fi + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y hledger chromium chromium-driver chromium-sandbox + +# System specs use Playwright, which needs its own Chromium build for the +# discourse user. +runuser -u discourse -- bash -lc "cd /src && pnpm playwright-install" + +echo "Installed:" +hledger --version | head -1 +chromium --version diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b70743a..50135b9 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -6,4 +6,20 @@ en: hledger: "Hledger" js: hledger: - placeholder: placeholder + reports: + accounts: "Accounts" + balance_sheet: "Balance sheet" + income_statement: "Income statement" + equity: "Equity" + total: "Total" + net: "Net" + filters: + begin: "From" + end: "To" + equity: + holder: "Holder" + amount: "Amount" + share: "Share" + note: "Contributed-capital distribution, not legal ownership." + errors: + generic: "Could not load the report." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 63f1c3e..6332948 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1 +1,22 @@ en: + site_settings: + hledger_enabled: "Enable the hledger plugin" + hledger_path: "Path to the hledger executable" + hledger_max_journal_bytes: "Maximum size of an hledger journal in bytes" + hledger_timeout_seconds: "Maximum number of seconds an hledger command may run" + hledger_max_output_bytes: "Maximum number of bytes captured from hledger output" + hledger: + errors: + unsupported_report: "Unsupported report type." + invalid_date: "Invalid date; expected YYYY-MM-DD." + unavailable: "hledger is not available on this server." + execution: "hledger could not generate the report." + generic: "The report could not be generated." + journal: + none: "No hledger journal was found in this topic." + ambiguous: "This topic contains more than one hledger journal." + too_large: "The hledger journal is too large." + too_many_lines: "The hledger journal has too many lines." + include_not_allowed: "The hledger journal uses an unsupported include directive." + invalid_encoding: "The hledger journal is not valid UTF-8." + invalid: "The hledger journal is invalid." diff --git a/config/routes.rb b/config/routes.rb index 5209339..f22142f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -Hledger::Engine.routes.draw do - get "/examples" => "examples#index" - # define routes here -end +Hledger::Engine.routes.draw { get "/topics/:topic_id/reports/:report_type" => "reports#show" } Discourse::Application.routes.draw { mount ::Hledger::Engine, at: "hledger" } diff --git a/config/settings.yml b/config/settings.yml index 7e7d9dc..6d51543 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -2,3 +2,11 @@ hledger: hledger_enabled: default: false client: true + hledger_path: + default: hledger + hledger_max_journal_bytes: + default: 262144 + hledger_timeout_seconds: + default: 10 + hledger_max_output_bytes: + default: 1048576 diff --git a/lib/hledger/equity.rb b/lib/hledger/equity.rb new file mode 100644 index 0000000..2c8e640 --- /dev/null +++ b/lib/hledger/equity.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "bigdecimal" + +module Hledger + module Equity + module_function + + # rows: array of [account_name, amounts] where amounts are normalized hashes + # with "commodity" and "quantity". Returns groups per commodity. + def distribute(rows) + buckets = Hash.new { |h, k| h[k] = Hash.new { |hh, hk| hh[hk] = BigDecimal(0) } } + + rows.each do |account, amounts| + holder = holder_name(account) + next if holder.blank? + + Array(amounts).each do |amount| + next if amount.blank? + + commodity = amount["commodity"].to_s + buckets[commodity][holder] += BigDecimal(amount["quantity"]).abs + end + end + + buckets + .map do |commodity, holders| + total = holders.values.reduce(BigDecimal(0), :+) + { + "commodity" => commodity, + "total" => amount_hash(commodity, total), + "holders" => + holders + .map do |holder, quantity| + { + "holder" => holder, + "amount" => amount_hash(commodity, quantity), + "percentage" => percentage(quantity, total), + } + end + .sort_by { |holder| -BigDecimal(holder["amount"]["quantity"]) }, + } + end + .sort_by { |group| group["commodity"] } + end + + def holder_name(account) + parts = account.to_s.split(":") + return nil unless parts.first == "equity" + + parts[1] + end + + def percentage(quantity, total) + return "0.00" if total.zero? + + format("%.2f", (quantity / total) * 100) + end + + def amount_hash(commodity, quantity) + { + "commodity" => commodity, + "quantity" => quantity.to_s("F"), + "display" => [quantity.to_s("F"), commodity].reject(&:blank?).join(" "), + } + end + end +end diff --git a/lib/hledger/error.rb b/lib/hledger/error.rb new file mode 100644 index 0000000..8a9075e --- /dev/null +++ b/lib/hledger/error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Hledger + class Error < StandardError + end +end diff --git a/lib/hledger/execution_error.rb b/lib/hledger/execution_error.rb new file mode 100644 index 0000000..73ca541 --- /dev/null +++ b/lib/hledger/execution_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Hledger + class ExecutionError < Error + end +end diff --git a/lib/hledger/journal.rb b/lib/hledger/journal.rb new file mode 100644 index 0000000..1d154bc --- /dev/null +++ b/lib/hledger/journal.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Hledger + # Extracts the single fenced `hledger` journal from a post's raw markdown and + # rejects input that could make hledger read files or resources we do not + # control. + class Journal + FENCE = /^[ \t]*`{3,}hledger[ \t]*\r?\n(?.*?)^[ \t]*`{3,}[ \t]*$/m + INCLUDE = /^[ \t]*!?include\b/i + MAX_LINES = 5_000 + + Result = + Struct.new(:source, :error, keyword_init: true) do + def ok? + error.nil? + end + + def none? + error == :none + end + end + + def self.extract(raw) + new(raw).extract + end + + def initialize(raw) + @raw = raw.to_s + end + + def extract + matches = @raw.scan(FENCE).flatten + return Result.new(source: nil, error: :none) if matches.empty? + return Result.new(source: nil, error: :ambiguous) if matches.length > 1 + + source = matches.first + validation_error = validate(source) + return Result.new(source: nil, error: validation_error) if validation_error + + Result.new(source: source, error: nil) + end + + private + + def validate(source) + return :too_large if source.bytesize > SiteSetting.hledger_max_journal_bytes + return :too_many_lines if source.lines.size > MAX_LINES + return :invalid_encoding unless source.valid_encoding? + return :include_not_allowed if source.match?(INCLUDE) + + nil + end + end +end diff --git a/lib/hledger/parse_error.rb b/lib/hledger/parse_error.rb new file mode 100644 index 0000000..4839047 --- /dev/null +++ b/lib/hledger/parse_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Hledger + class ParseError < Error + end +end diff --git a/lib/hledger/report_builder.rb b/lib/hledger/report_builder.rb new file mode 100644 index 0000000..7e93a9b --- /dev/null +++ b/lib/hledger/report_builder.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require "bigdecimal" +require "csv" +require "json" + +module Hledger + # Runs an hledger report and normalizes its output into the JSON shape the + # client renders. + class ReportBuilder + REPORT_TYPES = %w[accounts balance_sheet income_statement equity].freeze + COMPOUND_COMMANDS = { "balance_sheet" => "bs", "income_statement" => "is" }.freeze + + def self.supported?(type) + REPORT_TYPES.include?(type.to_s) + end + + def initialize(runner: Runner.new) + @runner = runner + end + + 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) + else + raise Error, "unsupported report type: #{report_type}" + end + end + + private + + def build_accounts(journal, begin_date, end_date) + result = run(["bal", "-O", "json", *balance_flags(begin_date, end_date)], journal) + rows, total = parse_json(result.stdout) + + sections = [ + { + "title" => nil, + "rows" => Array(rows).map { |row| account_row(row) }, + "total" => amounts(Array(total)), + }, + ] + + envelope("accounts", begin_date, end_date, "sections" => sections, "net" => nil) + end + + def build_compound(report_type, journal, begin_date, end_date) + flags = + ( + if report_type == "balance_sheet" + balance_flags(begin_date, end_date) + else + period_flags(begin_date, end_date) + end + ) + result = run([COMPOUND_COMMANDS.fetch(report_type), "-O", "csv", *flags], journal) + sections, net = parse_compound_csv(result.stdout) + + envelope(report_type, begin_date, end_date, "sections" => sections, "net" => net) + end + + def build_equity(journal, begin_date, end_date) + result = run(["bal", "-O", "json", *balance_flags(begin_date, end_date), "^equity:"], journal) + rows, = parse_json(result.stdout) + holdings = Array(rows).map { |row| [row[1].to_s, amounts(Array(row[3]))] } + + envelope("equity", begin_date, end_date, "groups" => Equity.distribute(holdings)) + end + + def envelope(report_type, begin_date, end_date, **extra) + { + "report_type" => report_type, + "period" => { + "begin" => begin_date&.iso8601, + "end" => end_date&.iso8601, + }, + "meta" => { + "hledger_version" => @runner.version, + }, + }.merge(extra) + end + + def run(argv, journal) + result = @runner.execute([*base_flags, *argv], stdin_data: journal) + raise ExecutionError, result.stderr if result.timed_out + raise ExecutionError, result.stderr unless result.success? + + result + end + + def base_flags + flags = ["-f-"] + flags << "-n" if no_conf_supported? + flags + end + + def no_conf_supported? + Gem::Version.new(@runner.version) >= Gem::Version.new("1.40") + rescue ArgumentError + false + end + + def parse_json(text) + JSON.parse(text) + rescue JSON::ParserError => e + raise ParseError, e.message + end + + def account_row(row) + display, full, _depth, row_amounts = row + account = (full || display).to_s + + { + "account" => account, + "depth" => depth_of(account), + "amounts" => amounts(Array(row_amounts)), + } + end + + def parse_compound_csv(text) + rows = CSV.parse(text) + sections = [] + current = nil + net = nil + + rows.each_with_index do |row, index| + next if index < 2 + + label = row[0].to_s + raw_amount = row[1].to_s + next if label.blank? + + case label + when "Total:" + current["total"] = [parse_amount_string(raw_amount)] if current + when "Net:" + net = [parse_amount_string(raw_amount)] + else + if raw_amount.blank? + current = { "title" => label, "rows" => [], "total" => nil } + sections << current + elsif current + current["rows"] << { + "account" => label, + "depth" => depth_of(label), + "amounts" => [parse_amount_string(raw_amount)], + } + end + end + end + + [sections, net] + end + + def amounts(list) + Array(list).map { |amount| amount_to_h(amount) } + end + + def amount_to_h(amount) + quantity = quantity_of(amount["aquantity"]) + style = amount["astyle"] || {} + commodity = amount["acommodity"].to_s + + { + "commodity" => commodity, + "quantity" => quantity.to_s("F"), + "display" => display_amount(quantity, commodity, style), + "side" => style["ascommodityside"] || "R", + "spaced" => style["ascommodityspaced"] ? true : false, + "precision" => style["asprecision"], + } + end + + def parse_amount_string(raw) + text = raw.to_s.strip + return nil if text.empty? + + if (match = text.match(/\A(-?\d+(?:[.,]\d+)?)\s*(.*)\z/)) + number = match[1] + commodity = match[2].strip + side = "R" + elsif (match = text.match(/\A(.*?)\s*(-?\d+(?:[.,]\d+)?)\z/)) + commodity = match[1].strip + number = match[2] + side = "L" + else + return nil + end + + quantity = BigDecimal(number.tr(",", ".")) + + { + "commodity" => commodity, + "quantity" => quantity.to_s("F"), + "display" => text, + "side" => side, + "spaced" => commodity.present? && text.include?(" "), + "precision" => number.match?(/[.,]/) ? number.split(/[.,]/).last.length : 0, + } + end + + def quantity_of(aquantity) + return BigDecimal(0) if aquantity.blank? + + mantissa = aquantity["decimalMantissa"].to_i + places = aquantity["decimalPlaces"].to_i + BigDecimal(mantissa) / (10**places) + end + + def display_amount(quantity, commodity, style) + precision = style["asprecision"] + if precision.nil? + rendered = quantity.to_s("F") + precision = rendered.include?(".") ? rendered.split(".").last.length : 0 + end + mark = style["asdecimalmark"].presence || "." + + number = format("%.#{precision}f", quantity) + number = number.tr(".", mark) if mark != "." + + return number if commodity.blank? + + spaced = style["ascommodityspaced"] ? " " : "" + if style["ascommodityside"] == "L" + "#{commodity}#{spaced}#{number}" + else + "#{number}#{spaced}#{commodity}" + end + end + + def depth_of(account) + account.to_s.count(":") + end + + def balance_flags(begin_date, end_date) + flags = [] + flags += ["-e", (end_date + 1).iso8601] if end_date + flags + end + + def period_flags(begin_date, end_date) + flags = [] + flags += ["-b", begin_date.iso8601] if begin_date + flags += ["-e", (end_date + 1).iso8601] if end_date + flags + end + end +end diff --git a/lib/hledger/runner.rb b/lib/hledger/runner.rb new file mode 100644 index 0000000..2f8be52 --- /dev/null +++ b/lib/hledger/runner.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "open3" +require "tmpdir" +require "fileutils" + +module Hledger + # Runs the hledger binary in a locked-down child process: scrubbed + # environment, private working directory, process group, CPU/file limits and + # a hard wall-clock timeout. + class Runner + Result = + Struct.new(:success, :stdout, :stderr, :exit_status, :timed_out, keyword_init: true) do + def success? + success + end + end + + READ_CHUNK = 16_384 + + def initialize(path: nil, timeout: nil, max_output: nil) + @path = (path || SiteSetting.hledger_path).to_s + @timeout = (timeout || SiteSetting.hledger_timeout_seconds).to_i + @max_output = (max_output || SiteSetting.hledger_max_output_bytes).to_i + end + + def available? + version.present? + end + + def version + return @version if defined?(@version) + + @version = detect_version + end + + def execute(argv, stdin_data: nil) + binary = resolve_binary + return failure_result if binary.nil? + + run(binary, argv, stdin_data) + end + + private + + def run(binary, argv, stdin_data) + tmpdir = Dir.mktmpdir("hledger-") + env = { + "PATH" => ENV["PATH"].to_s, + "HOME" => tmpdir, + "TMPDIR" => tmpdir, + "XDG_CACHE_HOME" => tmpdir, + "XDG_CONFIG_HOME" => tmpdir, + "NO_COLOR" => "1", + "LANG" => "C.UTF-8", + "LC_ALL" => "C.UTF-8", + } + spawn_opts = { + pgroup: true, + unsetenv_others: true, + chdir: tmpdir, + rlimit_cpu: @timeout + 5, + rlimit_nofile: 64, + } + + status = nil + timed_out = false + stdout = +"" + stderr = +"" + + Open3.popen3(env, binary, *argv, **spawn_opts) do |stdin, out, err, wait_thr| + writer = Thread.new { write_stdin(stdin, stdin_data) } + out_reader = Thread.new { read_capped(out) } + err_reader = Thread.new { read_capped(err) } + + if wait_thr.join(@timeout) + status = wait_thr.value + else + timed_out = true + kill_group(wait_thr.pid) + wait_thr.join(2) + end + + writer.join(1) + stdout = out_reader.value + stderr = err_reader.value + end + + Result.new( + success: status&.success? && !timed_out, + stdout: stdout, + stderr: stderr, + exit_status: status&.exitstatus, + timed_out: timed_out, + ) + rescue SystemCallError + failure_result + ensure + FileUtils.remove_entry(tmpdir) if tmpdir && Dir.exist?(tmpdir) + end + + def write_stdin(stdin, data) + stdin.write(data) if data + stdin.close + rescue IOError, Errno::EPIPE + end + + def read_capped(io) + buffer = +"" + while (chunk = io.read(READ_CHUNK)) + break if chunk.empty? + + remaining = @max_output - buffer.bytesize + buffer << chunk.byteslice(0, remaining) if remaining.positive? + end + buffer + rescue IOError, Errno::EPIPE + buffer + ensure + io.close + end + + def kill_group(pid) + Process.kill("KILL", -pid) + rescue Errno::ESRCH, Errno::EPERM + end + + def resolve_binary + return @binary if defined?(@binary) + + @binary = + if @path.include?(File::SEPARATOR) + File.executable?(@path) ? @path : nil + else + which(@path) + end + end + + def which(name) + return nil if name.blank? + + ENV["PATH"] + .to_s + .split(File::PATH_SEPARATOR) + .each do |dir| + candidate = File.join(dir, name) + return candidate if File.file?(candidate) && File.executable?(candidate) + end + nil + end + + def detect_version + result = execute(["--version"]) + return nil unless result.success? + + result.stdout[/\d+\.\d+(?:\.\d+)?/] + end + + def failure_result + Result.new(success: false, stdout: "", stderr: "", exit_status: nil, timed_out: false) + end + end +end diff --git a/lib/hledger/unavailable_error.rb b/lib/hledger/unavailable_error.rb new file mode 100644 index 0000000..8143e83 --- /dev/null +++ b/lib/hledger/unavailable_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Hledger + class UnavailableError < Error + end +end diff --git a/plugin.rb b/plugin.rb index d5bd998..27bc7c6 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true # name: hledger -# about: TODO +# about: Render hledger journals in topics # meta_topic_id: TODO # version: 0.0.1 -# authors: Discourse +# authors: Râu Cao # url: TODO # required_version: 2.7.0 +register_asset "stylesheets/hledger.scss" + enabled_site_setting :hledger_enabled module ::Hledger @@ -15,7 +17,3 @@ module ::Hledger end require_relative "lib/hledger/engine" - -after_initialize do - # Code which should run after Rails has finished booting -end diff --git a/spec/fixtures/accounts.json b/spec/fixtures/accounts.json new file mode 100644 index 0000000..3839f2e --- /dev/null +++ b/spec/fixtures/accounts.json @@ -0,0 +1,150 @@ +[ + [ + [ + "assets:bank:checking", + "assets:bank:checking", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": 102950, + "decimalPlaces": 2, + "floatingPoint": 1029.5 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ], + [ + "equity:alice", + "equity:alice", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -60000, + "decimalPlaces": 2, + "floatingPoint": -600 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ], + [ + "equity:bob", + "equity:bob", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -40000, + "decimalPlaces": 2, + "floatingPoint": -400 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ], + [ + "expenses:hosting:server", + "expenses:hosting:server", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": 2050, + "decimalPlaces": 2, + "floatingPoint": 20.5 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ], + [ + "revenues:donations", + "revenues:donations", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -5000, + "decimalPlaces": 2, + "floatingPoint": -50 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ] + ], + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": 0, + "decimalPlaces": 2, + "floatingPoint": 0 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] +] + diff --git a/spec/fixtures/balance_sheet.csv b/spec/fixtures/balance_sheet.csv new file mode 100644 index 0000000..5368822 --- /dev/null +++ b/spec/fixtures/balance_sheet.csv @@ -0,0 +1,8 @@ +"Balance Sheet 2024-03-15","" +"Account","2024-03-15" +"Assets","" +"assets:bank:checking","1029.50 EUR" +"Total:","1029.50 EUR" +"Liabilities","" +"Total:","0" +"Net:","1029.50 EUR" diff --git a/spec/fixtures/equity.json b/spec/fixtures/equity.json new file mode 100644 index 0000000..26ac8c3 --- /dev/null +++ b/spec/fixtures/equity.json @@ -0,0 +1,75 @@ +[ + [ + [ + "equity:alice", + "equity:alice", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -60000, + "decimalPlaces": 2, + "floatingPoint": -600 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ], + [ + "equity:bob", + "equity:bob", + 0, + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -40000, + "decimalPlaces": 2, + "floatingPoint": -400 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] + ] + ], + [ + { + "acommodity": "EUR", + "acost": null, + "acostbasis": null, + "aquantity": { + "decimalMantissa": -100000, + "decimalPlaces": 2, + "floatingPoint": -1000 + }, + "astyle": { + "ascommodityside": "R", + "ascommodityspaced": true, + "asdecimalmark": ".", + "asdigitgroups": null, + "asprecision": 2, + "asrounding": "HardRounding" + } + } + ] +] + diff --git a/spec/fixtures/income_statement.csv b/spec/fixtures/income_statement.csv new file mode 100644 index 0000000..da1eb2f --- /dev/null +++ b/spec/fixtures/income_statement.csv @@ -0,0 +1,9 @@ +"Income Statement 2024-01-01..2024-03-15","" +"Account","2024-01-01..2024-03-15" +"Revenues","" +"revenues:donations","50.00 EUR" +"Total:","50.00 EUR" +"Expenses","" +"expenses:hosting:server","20.50 EUR" +"Total:","20.50 EUR" +"Net:","29.50 EUR" diff --git a/spec/fixtures/journal.txt b/spec/fixtures/journal.txt new file mode 100644 index 0000000..f696e04 --- /dev/null +++ b/spec/fixtures/journal.txt @@ -0,0 +1,12 @@ +2024-01-01 Opening balances + assets:bank:checking 1000.00 EUR + equity:alice -600.00 EUR + equity:bob -400.00 EUR + +2024-02-10 Hosting + expenses:hosting:server 20.50 EUR + assets:bank:checking -20.50 EUR + +2024-03-15 Donation + assets:bank:checking 50.00 EUR + revenues:donations -50.00 EUR diff --git a/spec/lib/hledger/equity_spec.rb b/spec/lib/hledger/equity_spec.rb new file mode 100644 index 0000000..605f0b7 --- /dev/null +++ b/spec/lib/hledger/equity_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Hledger::Equity do + it "aggregates holders and computes percentages" do + rows = [ + ["equity:alice", [{ "commodity" => "EUR", "quantity" => "-600.0" }]], + ["equity:bob", [{ "commodity" => "EUR", "quantity" => "-400.0" }]], + ] + + group = described_class.distribute(rows).first + + expect(group["commodity"]).to eq("EUR") + expect(group["total"]["quantity"]).to eq("1000.0") + expect(group["holders"].map { |h| h["holder"] }).to eq(%w[alice bob]) + expect(group["holders"].map { |h| h["percentage"] }).to eq(%w[60.00 40.00]) + end + + it "keeps commodities separate" do + rows = [ + ["equity:alice", [{ "commodity" => "EUR", "quantity" => "100" }]], + ["equity:alice", [{ "commodity" => "USD", "quantity" => "50" }]], + ["equity:bob", [{ "commodity" => "USD", "quantity" => "50" }]], + ] + + groups = described_class.distribute(rows) + + expect(groups.map { |g| g["commodity"] }).to eq(%w[EUR USD]) + usd = groups.find { |g| g["commodity"] == "USD" } + expect(usd["holders"].map { |h| h["percentage"] }).to eq(%w[50.00 50.00]) + end + + it "ignores accounts outside of equity" do + rows = [["assets:bank", [{ "commodity" => "EUR", "quantity" => "100" }]]] + + expect(described_class.distribute(rows)).to eq([]) + end +end diff --git a/spec/lib/hledger/integration_spec.rb b/spec/lib/hledger/integration_spec.rb new file mode 100644 index 0000000..f566136 --- /dev/null +++ b/spec/lib/hledger/integration_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/DescribeClass +RSpec.describe "Hledger integration" do + before { skip "hledger is not installed" if !Hledger::Runner.new.available? } + + let(:journal) { File.read(Rails.root.join("plugins/hledger/spec/fixtures/journal.txt")) } + let(:builder) { Hledger::ReportBuilder.new } + + it "builds every supported report" do + %w[accounts balance_sheet income_statement equity].each do |type| + expect(builder.build(type, journal: journal)["report_type"]).to eq(type) + end + end + + it "treats the end date as inclusive" do + report = + builder.build( + "income_statement", + journal: journal, + begin_date: Date.new(2024, 1, 1), + end_date: Date.new(2024, 3, 15), + ) + + expect(report["net"].first["display"]).to eq("29.50 EUR") + end + + it "computes the equity distribution" do + holders = builder.build("equity", journal: journal)["groups"].first["holders"] + + expect(holders.map { |holder| [holder["holder"], holder["percentage"]] }).to eq( + [%w[alice 60.00], %w[bob 40.00]], + ) + end + + it "rejects journals with include directives" do + expect(Hledger::Journal.extract("```hledger\ninclude /etc/hostname\n```").error).to eq( + :include_not_allowed, + ) + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/lib/hledger/journal_spec.rb b/spec/lib/hledger/journal_spec.rb new file mode 100644 index 0000000..804ab39 --- /dev/null +++ b/spec/lib/hledger/journal_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe Hledger::Journal do + def raw_with(journal) + "Intro\n\n```hledger\n#{journal}\n```\n\nOutro\n" + end + + it "extracts a single fenced journal" do + result = described_class.extract(raw_with("2024-01-01 x\n a 1\n b -1")) + + expect(result).to be_ok + expect(result.source).to include("2024-01-01 x") + end + + it "returns :none when there is no journal" do + expect(described_class.extract("just text").error).to eq(:none) + end + + it "returns :ambiguous for multiple journals" do + raw = <<~RAW + ```hledger + 2024-01-01 x + a 1 + b -1 + ``` + ```hledger + 2024-01-02 y + a 1 + b -1 + ``` + RAW + + expect(described_class.extract(raw).error).to eq(:ambiguous) + end + + it "rejects include directives" do + result = described_class.extract(raw_with("include /etc/hostname")) + + expect(result.error).to eq(:include_not_allowed) + end + + it "rejects oversized journals" do + SiteSetting.hledger_max_journal_bytes = 10 + + result = described_class.extract(raw_with("2024-01-01 xxxxxxxxxxxxxxxxxxxx")) + + expect(result.error).to eq(:too_large) + ensure + SiteSetting.hledger_max_journal_bytes = 262_144 + end +end diff --git a/spec/lib/hledger/report_builder_spec.rb b/spec/lib/hledger/report_builder_spec.rb new file mode 100644 index 0000000..5e5c862 --- /dev/null +++ b/spec/lib/hledger/report_builder_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +class FakeHledgerRunner + FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures") + FILES = { + "accounts" => "accounts.json", + "balance_sheet" => "balance_sheet.csv", + "income_statement" => "income_statement.csv", + "equity" => "equity.json", + }.freeze + + attr_reader :version + + def initialize(version: "1.52.1") + @version = version + end + + def available? + version.present? + end + + def execute(argv, stdin_data: nil) + Hledger::Runner::Result.new( + success: true, + stdout: File.read(FIXTURES.join(FILES.fetch(detect(argv)))), + stderr: "", + exit_status: 0, + timed_out: false, + ) + end + + def detect(argv) + return "equity" if argv.include?("^equity:") + return "balance_sheet" if argv.include?("bs") + return "income_statement" if argv.include?("is") + + "accounts" + end +end + +RSpec.describe Hledger::ReportBuilder do + subject(:builder) { described_class.new(runner: FakeHledgerRunner.new) } + + describe "accounts" do + it "returns flat account rows with totals" do + report = builder.build("accounts", journal: "journal") + + expect(report["report_type"]).to eq("accounts") + rows = report["sections"].first["rows"] + expect(rows.map { |row| row["account"] }).to include("assets:bank:checking", "equity:alice") + checking = rows.find { |row| row["account"] == "assets:bank:checking" } + expect(checking["depth"]).to eq(2) + expect(checking["amounts"].first["display"]).to eq("1029.50 EUR") + expect(report["sections"].first["total"].first["display"]).to eq("0.00 EUR") + end + end + + describe "balance sheet" do + it "returns sections, totals and net" do + report = builder.build("balance_sheet", journal: "journal") + + expect(report["sections"].map { |s| s["title"] }).to eq(%w[Assets Liabilities]) + assets = report["sections"].first + expect(assets["total"].first["display"]).to eq("1029.50 EUR") + expect(report["net"].first["display"]).to eq("1029.50 EUR") + end + end + + describe "income statement" do + it "returns sections, totals and net" do + report = builder.build("income_statement", journal: "journal") + + expect(report["sections"].map { |s| s["title"] }).to eq(%w[Revenues Expenses]) + expect(report["net"].first["display"]).to eq("29.50 EUR") + end + end + + describe "equity" do + it "distributes contributed capital per holder" do + report = builder.build("equity", journal: "journal") + + group = report["groups"].first + expect(group["commodity"]).to eq("EUR") + expect(group["total"]["quantity"]).to eq("1000.0") + expect(group["holders"].map { |h| h["holder"] }).to eq(%w[alice bob]) + expect(group["holders"].map { |h| h["percentage"] }).to eq(%w[60.00 40.00]) + end + end + + describe "period" do + it "serializes the requested period" do + report = + builder.build( + "income_statement", + journal: "journal", + begin_date: Date.new(2024, 1, 1), + end_date: Date.new(2024, 3, 15), + ) + + expect(report["period"]).to eq("begin" => "2024-01-01", "end" => "2024-03-15") + expect(report["meta"]["hledger_version"]).to eq("1.52.1") + end + end + + it "raises for unsupported report types" do + expect { builder.build("nope", journal: "journal") }.to raise_error(Hledger::Error) + end + + it "raises when hledger is unavailable" do + unavailable = FakeHledgerRunner.new(version: nil) + + expect { + described_class.new(runner: unavailable).build("accounts", journal: "x") + }.to raise_error(Hledger::UnavailableError) + end +end diff --git a/spec/lib/hledger/runner_spec.rb b/spec/lib/hledger/runner_spec.rb new file mode 100644 index 0000000..319e85e --- /dev/null +++ b/spec/lib/hledger/runner_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe Hledger::Runner do + def runner(path:, timeout: 5, max_output: 1_000) + described_class.new(path: path, timeout: timeout, max_output: max_output) + end + + it "captures stdout and exit status" do + result = runner(path: "/bin/echo").execute(["hello"]) + + expect(result).to be_success + expect(result.stdout).to eq("hello\n") + expect(result.exit_status).to eq(0) + end + + it "reports failure for a non-zero exit" do + expect(runner(path: "/bin/false").execute([])).not_to be_success + end + + it "kills a process that exceeds the timeout" do + result = runner(path: "/bin/sleep", timeout: 1).execute(["5"]) + + expect(result.timed_out).to be(true) + expect(result).not_to be_success + end + + it "caps captured output" do + result = runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf '%0.sa' {1..100}"]) + + expect(result.stdout.bytesize).to be <= 10 + end + + it "returns a failure when the binary is missing" do + expect(runner(path: "/nonexistent/hledger").execute([])).not_to be_success + end + + it "feeds stdin to the process" do + result = runner(path: "/bin/cat").execute([], stdin_data: "journal") + + expect(result.stdout).to eq("journal") + end + + it "does not inherit the parent environment" do + ENV["HLEDGER_SECRET"] = "leak" + result = runner(path: "/usr/bin/env").execute([]) + + expect(result.stdout).not_to include("HLEDGER_SECRET") + ensure + ENV.delete("HLEDGER_SECRET") + end +end diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb new file mode 100644 index 0000000..06b00b8 --- /dev/null +++ b/spec/lib/pretty_text_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe PrettyText do + it "renders the hledger fence as a dashboard placeholder" do + SiteSetting.hledger_enabled = true + + cooked = PrettyText.cook(<<~MD) + ```hledger + 2024-01-01 x + assets:cash 1 EUR + equity:opening -1 EUR + ``` + MD + + expect(cooked).to include('class="hledger-dashboard"') + expect(cooked).not_to include("assets:cash") + end + + it "keeps the code block when the plugin is disabled" do + SiteSetting.hledger_enabled = false + + cooked = PrettyText.cook("```hledger\n2024-01-01 x\n```") + + expect(cooked).not_to include("hledger-dashboard") + expect(cooked).to include("lang-hledger") + end +end diff --git a/spec/requests/hledger/reports_spec.rb b/spec/requests/hledger/reports_spec.rb new file mode 100644 index 0000000..5359daa --- /dev/null +++ b/spec/requests/hledger/reports_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +class StubHledgerRunner + FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures") + + attr_reader :version + + def initialize(version: "1.52.1") + @version = version + end + + def execute(argv, stdin_data: nil) + file = + if argv.include?("^equity:") + "equity.json" + elsif argv.include?("bs") + "balance_sheet.csv" + elsif argv.include?("is") + "income_statement.csv" + else + "accounts.json" + end + + Hledger::Runner::Result.new( + success: true, + stdout: File.read(FIXTURES.join(file)), + stderr: "", + exit_status: 0, + timed_out: false, + ) + end +end + +RSpec.describe Hledger::ReportsController do + fab!(:post) { Fabricate(:post, raw: <<~RAW) } + Intro + + ```hledger + 2024-01-01 Opening balances + assets:bank:checking 1000.00 EUR + equity:alice -600.00 EUR + equity:bob -400.00 EUR + ``` + RAW + + before do + SiteSetting.hledger_enabled = true + allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new) + Discourse.cache.clear + end + + def get_report(report_type, params: {}) + get "/hledger/topics/#{post.topic_id}/reports/#{report_type}.json", params: params + end + + it "returns the accounts report" do + get_report("accounts") + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["report_type"]).to eq("accounts") + expect(json["sections"].first["rows"].map { |row| row["account"] }).to include( + "assets:bank:checking", + ) + end + + it "returns the balance sheet report" do + get_report("balance_sheet") + + expect(response.status).to eq(200) + expect(response.parsed_body["sections"].map { |s| s["title"] }).to eq(%w[Assets Liabilities]) + end + + it "returns the income statement report" do + get_report("income_statement") + + expect(response.status).to eq(200) + expect(response.parsed_body["net"].first["display"]).to eq("29.50 EUR") + end + + it "returns the equity distribution" do + get_report("equity") + + expect(response.status).to eq(200) + expect(response.parsed_body["groups"].first["holders"].map { |h| h["holder"] }).to eq( + %w[alice bob], + ) + end + + it "rejects unsupported report types" do + get_report("nope") + + expect(response.status).to eq(422) + end + + it "returns not found when the topic has no journal" do + post.update!(raw: "no journal here") + + get_report("accounts") + + expect(response.status).to eq(404) + end + + it "rejects invalid dates" do + get_report("accounts", params: { begin: "not-a-date" }) + + expect(response.status).to eq(422) + end + + it "returns service unavailable when hledger is missing" do + allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: nil)) + + get_report("accounts") + + expect(response.status).to eq(503) + end + + it "hides topics the user cannot see" do + private_category = Fabricate(:private_category, group: Fabricate(:group)) + hidden_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category)) + + get "/hledger/topics/#{hidden_post.topic_id}/reports/accounts.json" + + expect(response.status).to eq(404) + end + + it "is not found when the plugin is disabled" do + SiteSetting.hledger_enabled = false + + get_report("accounts") + + expect(response.status).to eq(404) + end +end diff --git a/test/javascripts/acceptance/hledger-test.js b/test/javascripts/acceptance/hledger-test.js new file mode 100644 index 0000000..8ea2ea0 --- /dev/null +++ b/test/javascripts/acceptance/hledger-test.js @@ -0,0 +1,89 @@ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { cloneJSON } from "discourse/lib/object"; +import topicFixtures from "discourse/tests/fixtures/topic"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; + +const TOPIC_ID = 28830; + +const accountsReport = { + report_type: "accounts", + period: { begin: null, end: null }, + sections: [ + { + title: null, + rows: [ + { + account: "assets:bank", + depth: 1, + amounts: [ + { commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" }, + ], + }, + ], + total: [{ commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" }], + }, + ], + net: null, + meta: { hledger_version: "1.52.1" }, +}; + +const equityReport = { + report_type: "equity", + period: { begin: null, end: null }, + groups: [ + { + commodity: "EUR", + total: { commodity: "EUR", quantity: "1000.0", display: "1000.0 EUR" }, + holders: [ + { + holder: "alice", + amount: { commodity: "EUR", quantity: "600.0", display: "600.0 EUR" }, + percentage: "60.00", + }, + { + holder: "bob", + amount: { commodity: "EUR", quantity: "400.0", display: "400.0 EUR" }, + percentage: "40.00", + }, + ], + }, + ], + meta: { hledger_version: "1.52.1" }, +}; + +acceptance("Hledger plugin", function (needs) { + needs.settings({ hledger_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/t/45.json", () => { + const topic = cloneJSON(topicFixtures["/t/28830/1.json"]); + topic.post_stream.posts[0].cooked = `
`; + return helper.response(topic); + }); + + server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () => + helper.response(accountsReport) + ); + server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () => + helper.response(equityReport) + ); + }); + + test("renders the dashboard in place of the journal", async function (assert) { + await visit("/t/-/45"); + + assert.dom(".hledger-dashboard__toolbar .btn").exists({ count: 4 }); + assert.dom(".hledger-dashboard__table").includesText("assets:bank"); + assert.dom(".hledger-dashboard__table").includesText("1000.00 EUR"); + }); + + test("switches to the equity distribution", async function (assert) { + await visit("/t/-/45"); + + await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)"); + + assert.dom(".hledger-dashboard__table").includesText("alice"); + assert.dom(".hledger-dashboard__table").includesText("60.00%"); + }); +});