rustfava 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. rustfava/__init__.py +30 -0
  2. rustfava/_ctx_globals_class.py +55 -0
  3. rustfava/api_models.py +36 -0
  4. rustfava/application.py +534 -0
  5. rustfava/beans/__init__.py +6 -0
  6. rustfava/beans/abc.py +327 -0
  7. rustfava/beans/account.py +79 -0
  8. rustfava/beans/create.py +377 -0
  9. rustfava/beans/flags.py +20 -0
  10. rustfava/beans/funcs.py +38 -0
  11. rustfava/beans/helpers.py +52 -0
  12. rustfava/beans/ingest.py +75 -0
  13. rustfava/beans/load.py +31 -0
  14. rustfava/beans/prices.py +151 -0
  15. rustfava/beans/protocols.py +82 -0
  16. rustfava/beans/str.py +454 -0
  17. rustfava/beans/types.py +63 -0
  18. rustfava/cli.py +187 -0
  19. rustfava/context.py +13 -0
  20. rustfava/core/__init__.py +729 -0
  21. rustfava/core/accounts.py +161 -0
  22. rustfava/core/attributes.py +145 -0
  23. rustfava/core/budgets.py +207 -0
  24. rustfava/core/charts.py +301 -0
  25. rustfava/core/commodities.py +37 -0
  26. rustfava/core/conversion.py +229 -0
  27. rustfava/core/documents.py +87 -0
  28. rustfava/core/extensions.py +132 -0
  29. rustfava/core/fava_options.py +255 -0
  30. rustfava/core/file.py +542 -0
  31. rustfava/core/filters.py +484 -0
  32. rustfava/core/group_entries.py +97 -0
  33. rustfava/core/ingest.py +509 -0
  34. rustfava/core/inventory.py +167 -0
  35. rustfava/core/misc.py +105 -0
  36. rustfava/core/module_base.py +18 -0
  37. rustfava/core/number.py +106 -0
  38. rustfava/core/query.py +180 -0
  39. rustfava/core/query_shell.py +301 -0
  40. rustfava/core/tree.py +265 -0
  41. rustfava/core/watcher.py +219 -0
  42. rustfava/ext/__init__.py +232 -0
  43. rustfava/ext/auto_commit.py +61 -0
  44. rustfava/ext/portfolio_list/PortfolioList.js +34 -0
  45. rustfava/ext/portfolio_list/__init__.py +29 -0
  46. rustfava/ext/portfolio_list/templates/PortfolioList.html +15 -0
  47. rustfava/ext/rustfava_ext_test/RustfavaExtTest.js +42 -0
  48. rustfava/ext/rustfava_ext_test/__init__.py +207 -0
  49. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTest.html +45 -0
  50. rustfava/ext/rustfava_ext_test/templates/RustfavaExtTestInclude.html +1 -0
  51. rustfava/help/__init__.py +15 -0
  52. rustfava/help/_index.md +29 -0
  53. rustfava/help/beancount_syntax.md +156 -0
  54. rustfava/help/budgets.md +31 -0
  55. rustfava/help/conversion.md +29 -0
  56. rustfava/help/extensions.md +111 -0
  57. rustfava/help/features.md +179 -0
  58. rustfava/help/filters.md +103 -0
  59. rustfava/help/import.md +27 -0
  60. rustfava/help/options.md +289 -0
  61. rustfava/helpers.py +30 -0
  62. rustfava/internal_api.py +221 -0
  63. rustfava/json_api.py +952 -0
  64. rustfava/plugins/__init__.py +3 -0
  65. rustfava/plugins/link_documents.py +107 -0
  66. rustfava/plugins/tag_discovered_documents.py +44 -0
  67. rustfava/py.typed +0 -0
  68. rustfava/rustledger/__init__.py +31 -0
  69. rustfava/rustledger/constants.py +76 -0
  70. rustfava/rustledger/engine.py +485 -0
  71. rustfava/rustledger/loader.py +273 -0
  72. rustfava/rustledger/options.py +202 -0
  73. rustfava/rustledger/query.py +331 -0
  74. rustfava/rustledger/types.py +830 -0
  75. rustfava/serialisation.py +220 -0
  76. rustfava/static/app.css +2988 -0
  77. rustfava/static/app.css.map +7 -0
  78. rustfava/static/app.js +12854 -0
  79. rustfava/static/app.js.map +7 -0
  80. rustfava/static/beancount-JFV44ZVZ.css +5 -0
  81. rustfava/static/beancount-JFV44ZVZ.css.map +7 -0
  82. rustfava/static/beancount-VTTKRGSK.js +4642 -0
  83. rustfava/static/beancount-VTTKRGSK.js.map +7 -0
  84. rustfava/static/bql-MGFRUMBP.js +333 -0
  85. rustfava/static/bql-MGFRUMBP.js.map +7 -0
  86. rustfava/static/chunk-E7ZF4ASL.js +23061 -0
  87. rustfava/static/chunk-E7ZF4ASL.js.map +7 -0
  88. rustfava/static/chunk-V24TLQHT.js +12673 -0
  89. rustfava/static/chunk-V24TLQHT.js.map +7 -0
  90. rustfava/static/favicon.ico +0 -0
  91. rustfava/static/fira-mono-cyrillic-400-normal-BLAGXRCE.woff2 +0 -0
  92. rustfava/static/fira-mono-cyrillic-500-normal-EN7JUAAW.woff2 +0 -0
  93. rustfava/static/fira-mono-cyrillic-ext-400-normal-EX7VARTS.woff2 +0 -0
  94. rustfava/static/fira-mono-cyrillic-ext-500-normal-ZDPTUPRR.woff2 +0 -0
  95. rustfava/static/fira-mono-greek-400-normal-COGHKMOA.woff2 +0 -0
  96. rustfava/static/fira-mono-greek-500-normal-4EN2PKZT.woff2 +0 -0
  97. rustfava/static/fira-mono-greek-ext-400-normal-DYEQIJH7.woff2 +0 -0
  98. rustfava/static/fira-mono-greek-ext-500-normal-SG73CVKQ.woff2 +0 -0
  99. rustfava/static/fira-mono-latin-400-normal-NA3VLV7E.woff2 +0 -0
  100. rustfava/static/fira-mono-latin-500-normal-YC77GFWD.woff2 +0 -0
  101. rustfava/static/fira-mono-latin-ext-400-normal-DIKTZ5PW.woff2 +0 -0
  102. rustfava/static/fira-mono-latin-ext-500-normal-ZWY4UO4V.woff2 +0 -0
  103. rustfava/static/fira-mono-symbols2-400-normal-UITXT77Q.woff2 +0 -0
  104. rustfava/static/fira-mono-symbols2-500-normal-VWPC2EFN.woff2 +0 -0
  105. rustfava/static/fira-sans-cyrillic-400-normal-KLQMBCA6.woff2 +0 -0
  106. rustfava/static/fira-sans-cyrillic-500-normal-NFG7UD6J.woff2 +0 -0
  107. rustfava/static/fira-sans-cyrillic-ext-400-normal-GWO44OPC.woff2 +0 -0
  108. rustfava/static/fira-sans-cyrillic-ext-500-normal-SP47E5SC.woff2 +0 -0
  109. rustfava/static/fira-sans-greek-400-normal-UMQBTLC3.woff2 +0 -0
  110. rustfava/static/fira-sans-greek-500-normal-4ZKHN4FQ.woff2 +0 -0
  111. rustfava/static/fira-sans-greek-ext-400-normal-O2DVJAJZ.woff2 +0 -0
  112. rustfava/static/fira-sans-greek-ext-500-normal-SK6GNWGO.woff2 +0 -0
  113. rustfava/static/fira-sans-latin-400-normal-OYYTPMAV.woff2 +0 -0
  114. rustfava/static/fira-sans-latin-500-normal-SMQPZW5A.woff2 +0 -0
  115. rustfava/static/fira-sans-latin-ext-400-normal-OAUP3WK5.woff2 +0 -0
  116. rustfava/static/fira-sans-latin-ext-500-normal-LY3YDR5Y.woff2 +0 -0
  117. rustfava/static/fira-sans-vietnamese-400-normal-OBMQ72MR.woff2 +0 -0
  118. rustfava/static/fira-sans-vietnamese-500-normal-Y4NZR5EU.woff2 +0 -0
  119. rustfava/static/source-code-pro-cyrillic-400-normal-TO22V6M3.woff2 +0 -0
  120. rustfava/static/source-code-pro-cyrillic-500-normal-OGBWWWYW.woff2 +0 -0
  121. rustfava/static/source-code-pro-cyrillic-ext-400-normal-XH44UCIA.woff2 +0 -0
  122. rustfava/static/source-code-pro-cyrillic-ext-500-normal-3Z6MMVM6.woff2 +0 -0
  123. rustfava/static/source-code-pro-greek-400-normal-OUXXUQWK.woff2 +0 -0
  124. rustfava/static/source-code-pro-greek-500-normal-JA2Z5UXO.woff2 +0 -0
  125. rustfava/static/source-code-pro-greek-ext-400-normal-WCDKMX7U.woff2 +0 -0
  126. rustfava/static/source-code-pro-greek-ext-500-normal-ZHVI4VKW.woff2 +0 -0
  127. rustfava/static/source-code-pro-latin-400-normal-QOGTXED5.woff2 +0 -0
  128. rustfava/static/source-code-pro-latin-500-normal-X57QEOLQ.woff2 +0 -0
  129. rustfava/static/source-code-pro-latin-ext-400-normal-QXC74NBF.woff2 +0 -0
  130. rustfava/static/source-code-pro-latin-ext-500-normal-QGOY7MTT.woff2 +0 -0
  131. rustfava/static/source-code-pro-vietnamese-400-normal-NPDCDTBA.woff2 +0 -0
  132. rustfava/static/source-code-pro-vietnamese-500-normal-M6PJKTR5.woff2 +0 -0
  133. rustfava/static/tree-sitter-beancount-MLXFQBZ5.wasm +0 -0
  134. rustfava/static/web-tree-sitter-RNOQ6E74.wasm +0 -0
  135. rustfava/template_filters.py +64 -0
  136. rustfava/templates/_journal_table.html +156 -0
  137. rustfava/templates/_layout.html +26 -0
  138. rustfava/templates/_query_table.html +88 -0
  139. rustfava/templates/beancount_file +18 -0
  140. rustfava/templates/help.html +23 -0
  141. rustfava/templates/macros/_account_macros.html +5 -0
  142. rustfava/templates/macros/_commodity_macros.html +13 -0
  143. rustfava/translations/bg/LC_MESSAGES/messages.mo +0 -0
  144. rustfava/translations/bg/LC_MESSAGES/messages.po +618 -0
  145. rustfava/translations/ca/LC_MESSAGES/messages.mo +0 -0
  146. rustfava/translations/ca/LC_MESSAGES/messages.po +618 -0
  147. rustfava/translations/de/LC_MESSAGES/messages.mo +0 -0
  148. rustfava/translations/de/LC_MESSAGES/messages.po +618 -0
  149. rustfava/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. rustfava/translations/es/LC_MESSAGES/messages.po +619 -0
  151. rustfava/translations/fa/LC_MESSAGES/messages.mo +0 -0
  152. rustfava/translations/fa/LC_MESSAGES/messages.po +618 -0
  153. rustfava/translations/fr/LC_MESSAGES/messages.mo +0 -0
  154. rustfava/translations/fr/LC_MESSAGES/messages.po +618 -0
  155. rustfava/translations/ja/LC_MESSAGES/messages.mo +0 -0
  156. rustfava/translations/ja/LC_MESSAGES/messages.po +618 -0
  157. rustfava/translations/nl/LC_MESSAGES/messages.mo +0 -0
  158. rustfava/translations/nl/LC_MESSAGES/messages.po +617 -0
  159. rustfava/translations/pt/LC_MESSAGES/messages.mo +0 -0
  160. rustfava/translations/pt/LC_MESSAGES/messages.po +617 -0
  161. rustfava/translations/pt_BR/LC_MESSAGES/messages.mo +0 -0
  162. rustfava/translations/pt_BR/LC_MESSAGES/messages.po +618 -0
  163. rustfava/translations/ru/LC_MESSAGES/messages.mo +0 -0
  164. rustfava/translations/ru/LC_MESSAGES/messages.po +617 -0
  165. rustfava/translations/sk/LC_MESSAGES/messages.mo +0 -0
  166. rustfava/translations/sk/LC_MESSAGES/messages.po +623 -0
  167. rustfava/translations/sv/LC_MESSAGES/messages.mo +0 -0
  168. rustfava/translations/sv/LC_MESSAGES/messages.po +618 -0
  169. rustfava/translations/uk/LC_MESSAGES/messages.mo +0 -0
  170. rustfava/translations/uk/LC_MESSAGES/messages.po +618 -0
  171. rustfava/translations/zh/LC_MESSAGES/messages.mo +0 -0
  172. rustfava/translations/zh/LC_MESSAGES/messages.po +618 -0
  173. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.mo +0 -0
  174. rustfava/translations/zh_Hant_TW/LC_MESSAGES/messages.po +618 -0
  175. rustfava/util/__init__.py +157 -0
  176. rustfava/util/date.py +576 -0
  177. rustfava/util/excel.py +118 -0
  178. rustfava/util/ranking.py +79 -0
  179. rustfava/util/sets.py +18 -0
  180. rustfava/util/unreachable.py +20 -0
  181. rustfava-0.1.0.dist-info/METADATA +102 -0
  182. rustfava-0.1.0.dist-info/RECORD +187 -0
  183. rustfava-0.1.0.dist-info/WHEEL +5 -0
  184. rustfava-0.1.0.dist-info/entry_points.txt +2 -0
  185. rustfava-0.1.0.dist-info/licenses/AUTHORS +11 -0
  186. rustfava-0.1.0.dist-info/licenses/LICENSE +21 -0
  187. rustfava-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,331 @@
1
+ """Query adapter for rustledger - replaces beanquery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rustfava.rustledger.engine import RustledgerEngine
10
+ from rustfava.rustledger.types import RLAmount
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ from rustfava.beans.abc import Directive
18
+ from rustfava.beans.types import BeancountOptions
19
+ from rustfava.helpers import BeancountError
20
+
21
+
22
+ @dataclass
23
+ class ColumnDescription:
24
+ """Description of a query result column.
25
+
26
+ Compatible with beanquery's column description.
27
+ """
28
+
29
+ name: str
30
+ datatype: type
31
+
32
+ def __iter__(self) -> Iterator[Any]:
33
+ """Allow tuple unpacking."""
34
+ yield self.name
35
+ yield self.datatype
36
+
37
+
38
+ class RLCursor:
39
+ """Cursor for rustledger query results.
40
+
41
+ Compatible with beanquery.Cursor interface.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ columns: list[dict[str, str]],
47
+ rows: list[list[Any]],
48
+ ) -> None:
49
+ """Initialize cursor with query results."""
50
+ self._columns = columns
51
+ self._rows = rows
52
+ self._index = 0
53
+
54
+ # Build description (like DB-API cursor.description)
55
+ self.description = tuple(
56
+ ColumnDescription(
57
+ name=col["name"],
58
+ datatype=_datatype_from_string(col.get("datatype", "object")),
59
+ )
60
+ for col in columns
61
+ )
62
+
63
+ def fetchall(self) -> list[tuple[Any, ...]]:
64
+ """Fetch all remaining rows."""
65
+ rows = [
66
+ tuple(_convert_row_value(v, self._columns[i]) for i, v in enumerate(row))
67
+ for row in self._rows[self._index :]
68
+ ]
69
+ self._index = len(self._rows)
70
+ return rows
71
+
72
+ def fetchone(self) -> tuple[Any, ...] | None:
73
+ """Fetch next row."""
74
+ if self._index >= len(self._rows):
75
+ return None
76
+ row = self._rows[self._index]
77
+ self._index += 1
78
+ return tuple(
79
+ _convert_row_value(v, self._columns[i]) for i, v in enumerate(row)
80
+ )
81
+
82
+ def __iter__(self) -> Iterator[tuple[Any, ...]]:
83
+ """Iterate over rows."""
84
+ for row in self._rows[self._index :]:
85
+ yield tuple(
86
+ _convert_row_value(v, self._columns[i]) for i, v in enumerate(row)
87
+ )
88
+
89
+
90
+ # Type mapping from rustledger strings to Python types
91
+ _DATATYPE_MAP: dict[str, type] = {
92
+ "str": str,
93
+ "int": int,
94
+ "Decimal": Decimal,
95
+ "bool": bool,
96
+ "date": str, # Keep as string, Fava handles conversion
97
+ "set": frozenset,
98
+ "object": object,
99
+ "Amount": RLAmount,
100
+ "Position": object,
101
+ "Inventory": dict,
102
+ }
103
+
104
+
105
+ def _datatype_from_string(datatype: str) -> type:
106
+ """Convert datatype string to Python type."""
107
+ return _DATATYPE_MAP.get(datatype, object)
108
+
109
+
110
+ def _convert_row_value(value: Any, column: dict[str, str]) -> Any:
111
+ """Convert a row value based on column type."""
112
+ if value is None:
113
+ return None
114
+
115
+ datatype = column.get("datatype", "object")
116
+
117
+ if datatype == "Decimal" and isinstance(value, str):
118
+ return Decimal(value)
119
+ if datatype == "Amount" and isinstance(value, dict):
120
+ return RLAmount.from_json(value)
121
+ if datatype == "set" and isinstance(value, list):
122
+ return frozenset(value)
123
+ if datatype == "Inventory" and isinstance(value, dict):
124
+ # Inventory comes as {"positions": [{"units": {"number": "...", "currency": "..."}}]}
125
+ # Convert to simpler format for Fava
126
+ positions = value.get("positions", [])
127
+ result = {}
128
+ for pos in positions:
129
+ units = pos.get("units", {})
130
+ currency = units.get("currency", "")
131
+ number = units.get("number", "0")
132
+ if currency:
133
+ result[currency] = Decimal(number)
134
+ return result
135
+
136
+ return value
137
+
138
+
139
+ class RLQueryError(Exception):
140
+ """Error from rustledger query execution."""
141
+
142
+
143
+ class ParseError(RLQueryError):
144
+ """BQL parse error."""
145
+
146
+
147
+ class CompilationError(RLQueryError):
148
+ """BQL compilation error."""
149
+
150
+
151
+ def connect(
152
+ connection_string: str,
153
+ entries: Sequence[Directive],
154
+ errors: Sequence[BeancountError],
155
+ options: BeancountOptions,
156
+ ) -> RLConnection:
157
+ """Create a connection for running queries.
158
+
159
+ This matches beanquery.connect() interface.
160
+
161
+ Args:
162
+ connection_string: Ignored (beancount: prefix for compatibility)
163
+ entries: List of directives
164
+ errors: List of errors (ignored)
165
+ options: Beancount options
166
+
167
+ Returns:
168
+ A connection object for executing queries
169
+ """
170
+ return RLConnection(entries, options)
171
+
172
+
173
+ class RLConnection:
174
+ """Connection for executing BQL queries against entries.
175
+
176
+ Unlike the beancount approach that re-serializes entries,
177
+ we directly call rustledger with the original source.
178
+ This requires the source to be stored or re-read.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ entries: Sequence[Directive],
184
+ options: BeancountOptions,
185
+ ) -> None:
186
+ """Initialize connection."""
187
+ self._entries = entries
188
+ self._options = options
189
+ self._engine = RustledgerEngine.get_instance()
190
+ self._source: str | None = None
191
+
192
+ def set_source(self, source: str) -> None:
193
+ """Set the source for queries.
194
+
195
+ Since rustledger queries operate on source text (not serialized entries),
196
+ we need the original source.
197
+ """
198
+ self._source = source
199
+
200
+ def execute(self, query_string: str) -> RLCursor:
201
+ """Execute a BQL query.
202
+
203
+ Args:
204
+ query_string: BQL query string
205
+
206
+ Returns:
207
+ A cursor with query results
208
+
209
+ Raises:
210
+ ParseError: If the query cannot be parsed
211
+ CompilationError: If the query cannot be compiled
212
+ RuntimeError: If source is not set
213
+ """
214
+ if self._source is None:
215
+ # Fall back to re-serializing entries (slower but works)
216
+ self._source = _entries_to_source(self._entries)
217
+
218
+ result = self._engine.query(self._source, query_string)
219
+
220
+ errors = result.get("errors", [])
221
+ if errors:
222
+ error_msg = errors[0].get("message", "Unknown error")
223
+ if "parse" in error_msg.lower():
224
+ raise ParseError(error_msg)
225
+ raise CompilationError(error_msg)
226
+
227
+ return RLCursor(
228
+ columns=result.get("columns", []),
229
+ rows=result.get("rows", []),
230
+ )
231
+
232
+
233
+ def _entries_to_source(entries: Sequence[Directive]) -> str:
234
+ """Convert entries back to beancount source for querying.
235
+
236
+ This is a fallback when the original source isn't available.
237
+ """
238
+ lines = []
239
+ for entry in entries:
240
+ line = _directive_to_source(entry)
241
+ if line:
242
+ lines.append(line)
243
+ return "\n".join(lines)
244
+
245
+
246
+ def _directive_to_source(directive: Directive) -> str:
247
+ """Convert a directive to beancount source line."""
248
+ date = directive.date.isoformat()
249
+ dtype = type(directive).__name__.lower().removeprefix("rl")
250
+
251
+ if dtype == "open":
252
+ currencies = " ".join(getattr(directive, "currencies", []))
253
+ account = getattr(directive, "account", "")
254
+ return f'{date} open {account} {currencies}'.strip()
255
+
256
+ if dtype == "close":
257
+ account = getattr(directive, "account", "")
258
+ return f'{date} close {account}'
259
+
260
+ if dtype == "balance":
261
+ amt = getattr(directive, "amount", None)
262
+ account = getattr(directive, "account", "")
263
+ if amt:
264
+ return f'{date} balance {account} {amt.number} {amt.currency}'
265
+ return f'{date} balance {account}'
266
+
267
+ if dtype == "transaction":
268
+ flag = getattr(directive, "flag", "*")
269
+ payee = getattr(directive, "payee", None)
270
+ narration = getattr(directive, "narration", "")
271
+
272
+ if payee:
273
+ header = f'{date} {flag} "{payee}" "{narration}"'
274
+ else:
275
+ header = f'{date} {flag} "{narration}"'
276
+
277
+ posting_lines = []
278
+ for p in getattr(directive, "postings", []):
279
+ if p.units:
280
+ posting_lines.append(f' {p.account} {p.units.number} {p.units.currency}')
281
+ else:
282
+ posting_lines.append(f' {p.account}')
283
+
284
+ return header + "\n" + "\n".join(posting_lines)
285
+
286
+ if dtype == "price":
287
+ amt = getattr(directive, "amount", None)
288
+ currency = getattr(directive, "currency", "")
289
+ if amt:
290
+ return f'{date} price {currency} {amt.number} {amt.currency}'
291
+ return f'{date} price {currency}'
292
+
293
+ if dtype == "commodity":
294
+ currency = getattr(directive, "currency", "")
295
+ return f'{date} commodity {currency}'
296
+
297
+ if dtype == "event":
298
+ event_type = getattr(directive, "type", "")
299
+ desc = getattr(directive, "description", "")
300
+ return f'{date} event "{event_type}" "{desc}"'
301
+
302
+ if dtype == "note":
303
+ comment = getattr(directive, "comment", "")
304
+ account = getattr(directive, "account", "")
305
+ return f'{date} note {account} "{comment}"'
306
+
307
+ if dtype == "document":
308
+ filename = getattr(directive, "filename", "")
309
+ account = getattr(directive, "account", "")
310
+ return f'{date} document {account} "{filename}"'
311
+
312
+ if dtype == "pad":
313
+ source_account = getattr(directive, "source_account", "")
314
+ account = getattr(directive, "account", "")
315
+ return f'{date} pad {account} {source_account}'
316
+
317
+ if dtype == "query":
318
+ name = getattr(directive, "name", "")
319
+ query_string = getattr(directive, "query_string", "")
320
+ return f'{date} query "{name}" "{query_string}"'
321
+
322
+ if dtype == "custom":
323
+ # Skip fava-specific custom directives that rustledger can't parse
324
+ custom_type = getattr(directive, "type", "")
325
+ if custom_type.startswith("fava"):
326
+ return ""
327
+ values = getattr(directive, "values", [])
328
+ values_str = " ".join(f'"{v}"' for v in values)
329
+ return f'{date} custom "{custom_type}" {values_str}'
330
+
331
+ return ""