15 Commits
30 changed files with 1885 additions and 25 deletions
+33
View File
@@ -7,6 +7,39 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
## [Unveröffentlicht]
## [0.7.0] - 2026-10-12
### Hinzugefügt
- `column -d Spalte ...` (und `project -d`) löscht Spalten: alle anderen bleiben in ihrer Reihenfolge. Fehler bei unbekannter Spalte oder wenn alle gelöscht würden. Dazu Abschnitt in `man/column.man`, Beispiel in `testall.cmds` und Tests.
- Kantonstarife 2026 für `tax`: `demo/taxCH_AI2026`, `taxCH_AR2026`, `taxCH_SG2026`, `taxCH_GR2026` (Präfix `taxCH_` für die Schweiz; einfache Staatssteuer für Alleinstehende, vor Steuerfuss, Gemeinde und Kirche), erzeugt mit `tools/gen_tax_tables.rb`. Mit Tests, Beispiel in `testall.cmds` und Erklärung zum Steuerfuss im README.
### Behoben
- `rdb man dashline` (und `headline`, `backup`, `dbase`, `accounting_ter`) zeigte als Überschrift eine Makrozeile (`dashline -- .PP`). Themen-Seiten ohne Beschreibung nehmen jetzt den Titel aus der `.TH`-Zeile.
- `splittable -0` und mehr als 676 Stücke endeten mit einem Ruby-Abbruch (im zweiten Fall nach 676 geschriebenen Dateien). Jetzt eine Fehlermeldung, und es wird vorher geprüft, damit nichts halb geschrieben wird.
## [0.6.0] - 2026-10-11
### Hinzugefügt
- Steuertabellen 2026 für `tax`: `demo/taxDE2026`, `taxAT2026`, `taxCH2026` (nur Bundessteuer), `taxLI2026` (nur Landessteuer), erzeugt mit `tools/gen_tax_tables.rb`, mit Tests.
### Geändert
- `record`: 0 und 1 sind die erste Zeile, negative Zahlen zählen vom Ende (`-1` letzte, `-2` vorletzte ...). Außerhalb der Tabelle kommt eine leere Tabelle, Text und Dezimalzahlen sind ein Fehler. (Vorher lieferten 0, -1 und Text ungewollt Zeilen vom Ende.) `testall.cmds` und `man/record.man` zeigen es.
- README und `man/row.man` erklären, warum `select` in bash/zsh/ksh einen Syntaxfehler gibt und wie man es trotzdem aufruft.
### Behoben
- `rdb man` zeigte `\\select` in den Man-Pages als `\elect`: ein doppelter Backslash wird jetzt zuerst geschützt.
## [0.5.0] - 2026-10-11
### Hinzugefügt
- `pick`: Tabelle auf dem Terminal durchblättern, filtern und eine oder mehrere Zeilen (`-m`) auswählen; die Auswahl geht als Tabelle auf die Standardausgabe. `-k` nimmt die Tasten aus einem String. Dazu `man/pick.man`, Beispiele in `testall.cmds` und Tests (auch mit Pseudo-Terminal).
## [0.4.0] - 2026-10-11
### Hinzugefügt
- `mask`: Eingabemaske mit eigener Oberfläche (Felder mit Breite, Schaltflächen, Rahmen `-b`, `-s` zeigt das Bild ohne Terminal). Schreibt die Eingabe als Tabelle auf die Standardausgabe. Die Vorlage kommt wie bei `screen` von der Standardeingabe (`mask < entry`). Dazu `man/mask.man`, `demo/maskform` und Tests (auch mit Pseudo-Terminal).
- `demo/entry`: beschriftete Eingabemaske für `screen` (README erklärt den Unterschied zu `form`).
## [0.3.0] - 2026-10-11
### Hinzugefügt
+89 -1
View File
@@ -40,6 +40,8 @@ Dasselbe gilt für **Listen** (ein Datensatz als `Spalte<Tab>Wert`-Zeilen, die
Datei beginnt mit einer Leerzeile). `tabletolist` und `listtotable` wandeln um,
die meisten Befehle lesen beides.
`column -d Spalte ...` löscht Spalten (alle anderen bleiben), `column Spalte ...` wählt sie aus.
`row`, `compute` und `validate` übergeben ihren Ausdruck an awk, nachdem sie
Spaltennamen durch `$N` ersetzt haben. Es gilt also awk-Syntax.
@@ -77,6 +79,88 @@ Eine Zeile mit `##` erscheint als Überschrift in der Ausgabe, eine mit `#` ist
ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt der Test
`test_testall_mentions_every_command` fehl.
## Vorlagen: `form` und `entry`
- `demo/form` ist eine Vorlage für `report` und `letter`: sie setzt Spaltenwerte in einen Text ein.
Sie hat keine Beschriftungen und nennt `Name` zweimal.
- `demo/entry` ist eine Eingabemaske für `screen`: jedes Feld kommt einmal vor und hat eine
Beschriftung. `screen < entry > maske.sh` erzeugt das Skript. Starten:
`CLEAR=$(clear.rdb) sh maske.sh > tabelle 2>&1`. Die Tabelle geht auf stderr (wie im Original),
daher das `2>&1`. Mit `form` würde die Maske leer und doppelt aussehen.
## Eingabemasken: `mask`
`mask` ist die Variante von `screen` mit eigener Oberfläche (nur Ruby, kein ncurses). Es zeichnet die
Vorlage (von der Standardeingabe, wie `screen`; ein Dateiname geht auch) selbst und schreibt die Eingabe als Tabelle auf die Standardausgabe, also pipefähig:
```
mask -b < demo/maskform Rahmen (-b), Felder, Schaltflächen
mask < demo/maskform > neu Tabelle in eine Datei
mask < demo/maskform | append -h kunden oder direkt in eine Tabelle
mask -s < demo/maskform nur das Bild und die Feldpositionen, ohne Terminal
```
In der Vorlage: `<Spalte>` oder `<Spalte:20>` ist ein Feld (mit Breite), `[ Speichern ]` eine Schaltfläche,
`[ Abbrechen ]` (oder Cancel, Abort, Quit) bricht ab, `<!date!>` fügt Befehlsausgabe ein. Kommt eine Spalte mehrfach
vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Status 1, keine Ausgabe).
## Zeilen auswählen: `pick`
`pick` zeigt eine Tabelle auf dem Terminal, man scrollt, filtert und wählt eine oder mehrere Zeilen. Die Tabelle
kommt von der Standardeingabe, die gewählten Zeilen gehen als Tabelle auf die Standardausgabe, also mitten in der Pipe:
```
pick < inventory eine Zeile wählen
pick -m < inventory | total Amount Value mehrere (Leertaste markiert, a alle)
column Item Description < inventory | pick -m | jointable - owners
pick -m -k 'down space down enter' < inventory Tasten aus einem String (Skripte, Tests)
```
Pfeile, Bild auf/ab, Pos1/Ende scrollen; `/` filtert (Enter behält den Filter, Esc löscht ihn); Enter wählt;
Esc bricht ab (Status 1, keine Ausgabe). Ohne Markierung gilt die Zeile unter dem Cursor.
## Steuertabellen 2026 für `tax`
`demo/` enthält vier Tarife für das Steuerjahr 2026 im Format von `tax` (income, tax, percent), jeweils für
Alleinstehende (Grundtarif). Erzeugt mit `ruby tools/gen_tax_tables.rb`.
| Datei | Land | Quelle | Genauigkeit |
|---|---|---|---|
| `taxDE2026` | Deutschland, Einkommensteuer | § 32a EStG (Grundfreibetrag 12 348 €) | Kurvenstücke durch Geraden genähert, Abweichung unter 0,15 € (die Formel rundet ab, `tax` auf) |
| `taxAT2026` | Österreich, Einkommensteuer | § 33 EStG (0 % bis 13 539 €, dann 20/30/40/48/50/55 %) | exakt, vor Absetzbeträgen |
| `taxCH2026` | Schweiz, **nur direkte Bundessteuer** | Art. 36 DBG, ESTV-Rundschreiben 2-215-D-2025 | Stufen exakt, das Gesetz rechnet in 100-Franken-Schritten |
| `taxLI2026` | Liechtenstein, **nur Landessteuer** | Art. 19 SteG (LR 640.0, Fassung 01.07.2026) | exakt |
| `taxCH_AI2026` | Kanton Appenzell I.Rh., einfache Staatssteuer | Art. 38 StG (ESTV-Kantonsblatt) | exakt |
| `taxCH_AR2026` | Kanton Appenzell A.Rh., einfache Staatssteuer (Alleinstehendentarif) | Art. 39 StG, Beträge ab 2024 | exakt |
| `taxCH_SG2026` | Kanton St. Gallen, einfache Staatssteuer | Art. 50 StG, Beträge ab 2026 | exakt |
| `taxCH_GR2026` | Kanton Graubünden, einfache Kantonssteuer | Art. 39 StG, indexierte Beträge ab 2025 | exakt |
```
tax 60000 < demo/taxDE2026 14233
tax 60000 < demo/taxAT2026 15447
tax 60000 < demo/taxCH2026 671
tax 60000 < demo/taxLI2026 1396 (mal 1 + Gemeindezuschlag, 150 % bis 250 %)
```
Der Betrag ist die Steuer auf das **zu versteuernde** Einkommen. Abzüge, Sozialversicherung, Absetzbeträge,
Kirchensteuer und Soli sind nicht enthalten. Für die Schweiz kommen Kanton und Gemeinde dazu (sie sind meist
höher als der Bund), für Liechtenstein der Gemeindezuschlag: gesamt = Landessteuer × (1 + Zuschlag/100).
Ehepaar- und Alleinerziehendentarife fehlen. Das ist keine Steuerberatung.
**Kantone (AI, AR, SG, GR):** Die Dateien tragen den Präfix `taxCH_` (Schweiz) vor dem Kantonskürzel; `taxCH2026` ist die Bundessteuer. Die Tabellen geben die *einfache Steuer* wieder. Sie wird mit dem Steuerfuss multipliziert,
und Gemeinde und Kirche kommen dazu. Der Kanton setzt den Steuerfuss pro Jahr fest:
| Kanton | Steuerfuss Kanton 2026 | Quelle |
|---|---|---|
| AI | 96 % | Kantonsblatt AI (Grossratsbeschluss 1.12.2025) |
| SG | 105 % | Budgetbeschluss des Kantonsrats (Websuche, nicht im Kantonsblatt) |
| GR | 92 % | Grosser Rat, Budget 2026 (Websuche) |
| AR | nicht ermittelt | AR rechnet in Steuereinheiten, die Zahl für 2026 habe ich nicht gefunden |
Beispiel AI bei 60 000: `tax 60000 < demo/taxCH_AI2026` = 3770, mal 0,96 = 3619 (Kantonssteuer, ohne Bezirk, Gemeinde und Kirche).
Oberhalb einer Grenze gilt bei allen vier Kantonen ein fester Satz auf das *ganze* Einkommen (AI ab 200 000 mit 8 %,
AR ab 260 800 mit 2,6 %, SG ab 264 500 mit 8,5 %, GR ab 780 440 mit 11 %); in der Tabelle ist das die letzte Zeile.
## Befehle
| Gruppe | Befehle |
@@ -91,7 +175,7 @@ ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt d
| Text | `cap` `uppercase` `lowercase` `substitute` `trimblank` `padstring` `length` `filesize` `ascii` `chr` `word` `prompt` `not` `path` |
| Umwandeln | `tabletolist` `listtotable` `listtosh` `tabletofix` `fixtotable` `tabletostruct` `tabletotbl` `tabletofact` `tabletorule` `tabletom4` `tabletosed` `translate` `uniondict` |
| Datum, Namen | `julian` `gregorian` `computedate` `todaysdate` `soundex` `like` `hashkey` |
| Berichte | `report` `reportwriter` `screen` `label` `letter` |
| Berichte | `report` `reportwriter` `screen` `mask` `pick` `label` `letter` |
| Terminal, Hilfe | `termput` `tset` `clear.rdb` `cursor` `menu` `rdb` `commands` `helpme` `whatis` `whatwill` `howmany` `man` |
| Betrieb | `testall` `testsearch` `timesearch` `rmcore` `cpdir.rdb` |
| Buchhaltung (/act) | `act` `chartdup` `foot` `adjust` `balance` `close` `consolidate` `getjournal` `fillform` `calculate` `tax` `cashflow` `calcpay` `w2` `bom` `explode` `sale` `invoice` `cstate` `update.inv` |
@@ -135,6 +219,10 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell.
`row`, `compute`, `column`, `sorttable`, `jointable`, `rename`, `validate` und
`rdb` haben dazu den Abschnitt „Case of Column Names“; er ist die einzige
Ergänzung gegenüber den Original-Seiten.
- `select` ist in bash, zsh und ksh ein reserviertes Wort. `select 'Cost > 5' < inventory` endet dort
mit `syntax error near unexpected token '<'`, weil die Shell das Schlüsselwort vor dem `PATH` auswertet.
Es gibt dasselbe unter dem Namen `row`. Außerdem gehen `\select ...`, `command select ...` und
`rdb select ...`.
- `jointable` braucht keine vorsortierten Tabellen.
- `ve` ist ein zeilenorientierter Editor, kein Bildschirmeditor.
- `soundex` nutzt den Standard-Algorithmus. Die Beispielwerte im Handbuch
Symlink
+1
View File
@@ -0,0 +1 @@
/home/wkh/Development/Coherent/rdb/bin/rdb
Symlink
+1
View File
@@ -0,0 +1 @@
/home/wkh/Development/Coherent/rdb/bin/rdb
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry Form
Name : <Name> Company: <Company>
Street: <Street>
City : <City> State : <State> ZIP: <ZIP>
Phone : <Phone>
[Speichern] [Abbrechen]
+24
View File
@@ -0,0 +1,24 @@
: paint crt screen
exec 1>/dev/tty
cat <<SCREEN
${CLEAR} Mail List Entry Form
Name : Company:
Street:
City : State : ZIP:
Phone :
SCREEN
: read user input
cursor 3 9 ; read Name;
cursor 3 37 ; read Company;
cursor 4 9 ; read Street;
cursor 5 9 ; read City;
cursor 5 37 ; read State;
cursor 5 56 ; read ZIP;
cursor 6 9 ; read Phone;
: output table head
exec 1>&2
echo "Name Company Street City State ZIP Phone"
echo "---- ------- ------ ---- ----- --- -----"
: append row
echo "$Name $Company $Street $City $State $ZIP $Phone"
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry
Name : <Name:24>
Company: <Company:24>
Street : <Street:24>
City : <City:16> State: <State:10> ZIP: <ZIP:9>
Phone : <Phone:16>
[ Save ] [ Cancel ]
+9
View File
@@ -0,0 +1,9 @@
income tax percent
------ --- -------
0 0.00 0
13539 0.00 20
21992 1690.60 30
36458 6030.40 40
70365 19593.20 48
104859 36150.32 50
1000000 483720.82 55
+14
View File
@@ -0,0 +1,14 @@
income tax percent
------ --- -------
0 0.00 0
15200 0.00 0.77
33200 138.60 0.88
43500 229.20 2.64
58000 612.00 2.97
76200 1152.50 5.94
82100 1502.95 6.6
108900 3271.75 8.8
141500 6140.55 11
185100 10936.55 13.2
793900 91298.15 11.85
794000 91310.00 11.5
+16
View File
@@ -0,0 +1,16 @@
income tax percent
------ --- -------
0 0.00 0
3000 0.00 1
6000 30.00 2
9000 90.00 3
12000 180.00 4
15000 300.00 5
18000 450.00 6
22000 690.00 7
26000 970.00 7.5
30000 1270.00 8
40000 2070.00 8.5
74000 4960.00 9
140000 10900.00 8.5
200000 16000.00 8
+14
View File
@@ -0,0 +1,14 @@
income tax percent
------ --- -------
0 0.00 0
8300 0.00 0.6
9900 9.60 1
11500 25.60 1.5
15700 88.60 1.8
27200 295.60 2.2
41800 616.80 2.4
54300 916.80 2.6
74100 1431.60 2.7
88700 1825.80 2.8
125200 2847.80 2.9
260800 6780.80 2.6
+21
View File
@@ -0,0 +1,21 @@
income tax percent
------ --- -------
0 0.00 0
16895 0.00 2.5
17985 27.25 4
19075 70.85 5
20165 125.35 6
21255 190.75 6.5
22345 261.60 7
24525 414.20 8
31065 937.40 8.5
35425 1308.00 9
39785 1700.40 9.5
44145 2114.60 10.3
65945 4360.00 10.6
87745 6670.80 10.7
109545 9003.40 11.2
218545 21211.40 11.3
327545 33528.40 11.4
436545 45954.40 11.6
780440 85848.40 11
+9
View File
@@ -0,0 +1,9 @@
income tax percent
------ --- -------
0 0.00 0
11600 0.00 4
15800 168.00 6
33800 1248.00 8
60300 3368.00 9.2
98400 6873.20 9.4
264500 22482.50 8.5
+235
View File
@@ -0,0 +1,235 @@
income tax percent
------ --- -------
0 0.00 0
12348 0.00 14.228628
12598 35.57 14.685882
12848 72.29 15.143138
13098 110.14 15.600392
13348 149.15 16.057648
13598 189.29 16.514902
13848 230.58 16.972158
14098 273.01 17.429413
14348 316.58 17.886668
14598 361.30 18.343922
14848 407.16 18.801178
15098 454.16 19.258432
15348 502.31 19.715688
15598 551.60 20.172942
15848 602.03 20.630198
16098 653.60 21.087453
16348 706.32 21.544707
16598 760.18 22.001963
16848 815.19 22.459217
17098 871.34 22.916473
17348 928.63 23.373727
17598 987.06 23.786172
17799 1034.87 24.012466
18049 1094.90 24.099825
18299 1155.15 24.186375
18549 1215.62 24.272925
18799 1276.30 24.359475
19049 1337.20 24.446025
19299 1398.31 24.532575
19549 1459.65 24.619125
19799 1521.19 24.705675
20049 1582.96 24.792225
20299 1644.94 24.878775
20549 1707.14 24.965325
20799 1769.55 25.051875
21049 1832.18 25.138425
21299 1895.02 25.224975
21549 1958.09 25.311525
21799 2021.37 25.398075
22049 2084.86 25.484625
22299 2148.57 25.571175
22549 2212.50 25.657725
22799 2276.64 25.744275
23049 2341.01 25.830825
23299 2405.58 25.917375
23549 2470.38 26.003925
23799 2535.39 26.090475
24049 2600.61 26.177025
24299 2666.05 26.263575
24549 2731.71 26.350125
24799 2797.59 26.436675
25049 2863.68 26.523225
25299 2929.99 26.609775
25549 2996.51 26.696325
25799 3063.25 26.782875
26049 3130.21 26.869425
26299 3197.38 26.955975
26549 3264.77 27.042525
26799 3332.38 27.129075
27049 3400.20 27.215625
27299 3468.24 27.302175
27549 3536.50 27.388725
27799 3604.97 27.475275
28049 3673.66 27.561825
28299 3742.56 27.648375
28549 3811.68 27.734925
28799 3881.02 27.821475
29049 3950.57 27.908025
29299 4020.34 27.994575
29549 4090.33 28.081125
29799 4160.53 28.167675
30049 4230.95 28.254225
30299 4301.59 28.340775
30549 4372.44 28.427325
30799 4443.51 28.513875
31049 4514.79 28.600425
31299 4586.29 28.686975
31549 4658.01 28.773525
31799 4729.95 28.860075
32049 4802.10 28.946625
32299 4874.46 29.033175
32549 4947.05 29.119725
32799 5019.84 29.206275
33049 5092.86 29.292825
33299 5166.09 29.379375
33549 5239.54 29.465925
33799 5313.21 29.552475
34049 5387.09 29.639025
34299 5461.18 29.725575
34549 5535.50 29.812125
34799 5610.03 29.898675
35049 5684.78 29.985225
35299 5759.74 30.071775
35549 5834.92 30.158325
35799 5910.31 30.244875
36049 5985.93 30.331425
36299 6061.75 30.417975
36549 6137.80 30.504525
36799 6214.06 30.591075
37049 6290.54 30.677625
37299 6367.23 30.764175
37549 6444.14 30.850725
37799 6521.27 30.937275
38049 6598.61 31.023825
38299 6676.17 31.110375
38549 6753.95 31.196925
38799 6831.94 31.283475
39049 6910.15 31.370025
39299 6988.57 31.456575
39549 7067.22 31.543125
39799 7146.07 31.629675
40049 7225.15 31.716225
40299 7304.44 31.802775
40549 7383.95 31.889325
40799 7463.67 31.975875
41049 7543.61 32.062425
41299 7623.76 32.148975
41549 7704.14 32.235525
41799 7784.73 32.322075
42049 7865.53 32.408625
42299 7946.55 32.495175
42549 8027.79 32.581725
42799 8109.24 32.668275
43049 8190.92 32.754825
43299 8272.80 32.841375
43549 8354.91 32.927925
43799 8437.23 33.014475
44049 8519.76 33.101025
44299 8602.51 33.187575
44549 8685.48 33.274125
44799 8768.67 33.360675
45049 8852.07 33.447225
45299 8935.69 33.533775
45549 9019.52 33.620325
45799 9103.57 33.706875
46049 9187.84 33.793425
46299 9272.32 33.879975
46549 9357.02 33.966525
46799 9441.94 34.053075
47049 9527.07 34.139625
47299 9612.42 34.226175
47549 9697.99 34.312725
47799 9783.77 34.399275
48049 9869.77 34.485825
48299 9955.98 34.572375
48549 10042.41 34.658925
48799 10129.06 34.745475
49049 10215.92 34.832025
49299 10303.00 34.918575
49549 10390.30 35.005125
49799 10477.81 35.091675
50049 10565.54 35.178225
50299 10653.49 35.264775
50549 10741.65 35.351325
50799 10830.03 35.437875
51049 10918.62 35.524425
51299 11007.43 35.610975
51549 11096.46 35.697525
51799 11185.71 35.784075
52049 11275.17 35.870625
52299 11364.84 35.957175
52549 11454.74 36.043725
52799 11544.84 36.130275
53049 11635.17 36.216825
53299 11725.71 36.303375
53549 11816.47 36.389925
53799 11907.45 36.476475
54049 11998.64 36.563025
54299 12090.04 36.649575
54549 12181.67 36.736125
54799 12273.51 36.822675
55049 12365.57 36.909225
55299 12457.84 36.995775
55549 12550.33 37.082325
55799 12643.03 37.168875
56049 12735.96 37.255425
56299 12829.09 37.341975
56549 12922.45 37.428525
56799 13016.02 37.515075
57049 13109.81 37.601625
57299 13203.81 37.688175
57549 13298.03 37.774725
57799 13392.47 37.861275
58049 13487.12 37.947825
58299 13581.99 38.034375
58549 13677.08 38.120925
58799 13772.38 38.207475
59049 13867.90 38.294025
59299 13963.63 38.380575
59549 14059.59 38.467125
59799 14155.75 38.553675
60049 14252.14 38.640225
60299 14348.74 38.726775
60549 14445.56 38.813325
60799 14542.59 38.899875
61049 14639.84 38.986425
61299 14737.30 39.072975
61549 14834.99 39.159525
61799 14932.89 39.246075
62049 15031.00 39.332625
62299 15129.33 39.419175
62549 15227.88 39.505725
62799 15326.64 39.592275
63049 15425.63 39.678825
63299 15524.82 39.765375
63549 15624.24 39.851925
63799 15723.87 39.938475
64049 15823.71 40.025025
64299 15923.77 40.111575
64549 16024.05 40.198125
64799 16124.55 40.284675
65049 16225.26 40.371225
65299 16326.19 40.457775
65549 16427.33 40.544325
65799 16528.69 40.630875
66049 16630.27 40.717425
66299 16732.06 40.803975
66549 16834.07 40.890525
66799 16936.30 40.977075
67049 17038.74 41.063625
67299 17141.40 41.150175
67549 17244.28 41.236725
67799 17347.37 41.323275
68049 17450.68 41.409825
68299 17554.20 41.496375
68549 17657.94 41.582925
68799 17761.90 41.669475
69049 17866.07 41.756025
69299 17970.46 41.842575
69549 18075.07 41.963277
69879 18213.55 42.000014
277826 105551.32 45
+11
View File
@@ -0,0 +1,11 @@
income tax percent
------ --- -------
0 0.00 0
15900 0.00 1
21100 52.00 3
42300 688.00 4
74000 1956.00 5
105700 3541.00 6
137400 5443.00 6.5
169200 7510.00 7
211400 10464.00 8
+44 -3
View File
@@ -12,6 +12,9 @@ rdb select 'Description ~ /gloves/' < inventory
## column (project): pick columns in any order; a name that does not exist becomes a new empty column
column Description Cost < inventory
project Item Notes < owners
## column -d: keep all columns except the named ones (a deletion)
column -d Cost Value < inventory
subtotal Date Debit Credit < journal | column -d Account Description
## sorttable: -n numeric, -r reverse, -f fold case
sorttable -nr Cost < inventory
## compute: calculate columns from other columns (the new column must exist, so make it with addcol)
@@ -23,8 +26,14 @@ rename Owner Person < owners
## number / Number: number the rows (number: rows only, Number: rows and the head)
number < owners
Number < owners
## record: print one record (the 3rd row)
## record: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
record 3 < inventory
record 0 < owners
record -1 < owners
record -2 < owners
## beyond the end: an empty table; not a number: an error
record 9 < owners
record abc < owners
## rmblank: drop empty rows; headon / headoff: add or strip the head
column Item Cost < inventory | rmblank
headoff < colors
@@ -182,8 +191,16 @@ report form < maillist
## label prints mailing labels, letter prints form letters
label < maillist
letter form < maillist | head -5
## screen turns a form into a shell script that asks for the fields
screen < form | head -6
## screen turns a form into a shell script that asks for the fields. Use a form with labels
## and every column once (entry); the report template `form` would give a blank, doubled screen.
## Run the script with: CLEAR=$(clear.rdb) sh script > table 2>&1 (the table goes to stderr)
cat entry
screen < entry
## mask draws the form itself (fields, buttons, frame) and writes the row as a table on stdout.
## It needs a terminal, so here only the picture of the form and its fields (-s) is shown.
## Try it: mask -b < maskform mask < maskform | append -h t
cat maskform
mask -s -b < maskform
## reportwriter: a sample report with page headings (the date is cut out)
reportwriter -3 Prices < inventory | grep -v 'Report Date'
## tabletom4 / tabletosed / tabletostruct were shown above
@@ -215,6 +232,17 @@ act
cashflow < cash
## tax: look up the tax for an income in a tax table
tax 20000 < X83
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
tax 60000 < taxDE2026
tax 60000 < taxAT2026
tax 60000 < taxCH2026
tax 60000 < taxLI2026
## Swiss cantons (einfache Staatssteuer, single; still times the cantonal Steuerfuss, plus Gemeinde): AI AR SG GR
tax 60000 < taxCH_AI2026
tax 60000 < taxCH_AR2026
tax 60000 < taxCH_SG2026
tax 60000 < taxCH_GR2026
## explode: parts list -> all subparts with counts (part, amount)
explode 10001 2 parttable
## chartdup: look for duplicate account numbers and short names in chart
@@ -257,3 +285,16 @@ bom ; cat bom
## update.inv: change the stock of one item, with record locking (INV names the table)
cp inventory inv ; printf '7\n9\n\n' | INV=inv update.inv | tail -n +2
grep '^7' inv
## ===== Choosing rows on the terminal =====
## pick shows a table, lets you scroll, filter (/) and mark rows (Space, -m), and writes the
## chosen rows as a table, so it can sit in a pipe. It needs a terminal; -k feeds the keys instead.
## Try it: pick -m < inventory | total Amount Value
## mark the 2nd and 5th row (down, space, down, down, space) and finish with enter
pick -m -k 'down space down down space enter' < inventory
## the same chosen rows go on into total
pick -m -k 'down space down down space enter' < inventory | total Amount Value
## filter with / (type, enter), then choose the row that is left
pick -k '/clamps enter enter' < inventory
## Esc cancels: nothing on stdout, exit status 1
pick -k esc < inventory ; echo "status $?"
+154 -8
View File
@@ -28,6 +28,23 @@ Item Notes
1
3
6
## column -d: keep all columns except the named ones (a deletion)
$ column -d Cost Value < inventory
Item Amount Description
---- ------ -----------
1 3 rubber gloves
2 100 test tubes
3 5 clamps
4 23 plates
5 99 cleaning cloth
6 89 bunsen burners
7 5 scales
$ subtotal Date Debit Credit < journal | column -d Account Description
Date Debit Credit
---- ----- ------
861222 25000 25000
861223 10000 10000
861224 30000 30000
## sorttable: -n numeric, -r reverse, -f fold case
$ sorttable -nr Cost < inventory
Item Amount Cost Value Description
@@ -86,11 +103,29 @@ Number Item Owner
1 1 ann
2 3 bob
3 6 cy
## record: print one record (the 3rd row)
## record: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
$ record 3 < inventory
Item Amount Cost Value Description
---- ------ ---- ----- -----------
3 5 8.00 40.00 clamps
$ record 0 < owners
Item Owner
---- -----
1 ann
$ record -1 < owners
Item Owner
---- -----
6 cy
$ record -2 < owners
Item Owner
---- -----
3 bob
## beyond the end: an empty table; not a number: an error
$ record 9 < owners
Item Owner
---- -----
$ record abc < owners
record: 'abc' is not a record number (1, 2, 3 ... or -1 for the last)
## rmblank: drop empty rows; headon / headoff: add or strip the head
$ column Item Cost < inventory | rmblank
Item Cost
@@ -735,14 +770,82 @@ McDonalds
123 Mac Attack
Memphis, TENN 30000
## screen turns a form into a shell script that asks for the fields
$ screen < form | head -6
## screen turns a form into a shell script that asks for the fields. Use a form with labels
## and every column once (entry); the report template `form` would give a blank, doubled screen.
## Run the script with: CLEAR=$(clear.rdb) sh script > table 2>&1 (the table goes to stderr)
$ cat entry
Mail List Entry Form
Name : <Name> Company: <Company>
Street: <Street>
City : <City> State : <State> ZIP: <ZIP>
Phone : <Phone>
[Speichern] [Abbrechen]
$ screen < entry
: paint crt screen
exec 1>/dev/tty
cat <<SCREEN
${CLEAR}
${CLEAR} Mail List Entry Form
Name : Company:
Street:
City : State : ZIP:
Phone :
[Speichern] [Abbrechen]
SCREEN
: read user input
cursor 3 9 ; read Name;
cursor 3 37 ; read Company;
cursor 4 9 ; read Street;
cursor 5 9 ; read City;
cursor 5 37 ; read State;
cursor 5 56 ; read ZIP;
cursor 6 9 ; read Phone;
: output table head
exec 1>&2
echo "Name Company Street City State ZIP Phone"
echo "---- ------- ------ ---- ----- --- -----"
: append row
echo "$Name $Company $Street $City $State $ZIP $Phone"
## mask draws the form itself (fields, buttons, frame) and writes the row as a table on stdout.
## It needs a terminal, so here only the picture of the form and its fields (-s) is shown.
## Try it: mask -b < maskform mask < maskform | append -h t
$ cat maskform
Mail List Entry
Name : <Name:24>
Company: <Company:24>
Street : <Street:24>
City : <City:16> State: <State:10> ZIP: <ZIP:9>
Phone : <Phone:16>
[ Save ] [ Cancel ]
$ mask -s -b < maskform
┌──────────────────────────────────────────────────────────────┐
│ Mail List Entry │
│ │
│ Name : ________________________ │
│ Company: ________________________ │
│ Street : ________________________ │
│ City : ________________ State: __________ ZIP: _________ │
│ Phone : ________________ │
│ │
│ [ Save ] [ Cancel ] │
└──────────────────────────────────────────────────────────────┘
Fields:
Name row 4 col 12 width 24
Company row 5 col 12 width 24
Street row 6 col 12 width 24
City row 7 col 12 width 16
State row 7 col 37 width 10
ZIP row 7 col 54 width 9
Phone row 8 col 12 width 16
[Save] row 10 col 6
[Cancel] row 10 col 17 (cancels)
## reportwriter: a sample report with page headings (the date is cut out)
$ reportwriter -3 Prices < inventory | grep -v 'Report Date'
Prices
@@ -777,9 +880,9 @@ Item Amount Cost Value Description
## tabletom4 / tabletosed / tabletostruct were shown above
## ===== Help and the terminal =====
$ rdb --version
rdb 0.2.0
rdb 0.7.0
$ howmany
134
136
$ whatis row
row - make a new table where rows match logical condition
row row 'column condition column-or-value ..' < table
@@ -867,6 +970,25 @@ Date Amount Balance Description
## tax: look up the tax for an income in a tax table
$ tax 20000 < X83
3369
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
$ tax 60000 < taxDE2026
14233
$ tax 60000 < taxAT2026
15447
$ tax 60000 < taxCH2026
671
$ tax 60000 < taxLI2026
1396
## Swiss cantons (einfache Staatssteuer, single; still times the cantonal Steuerfuss, plus Gemeinde): AI AR SG GR
$ tax 60000 < taxCH_AI2026
3770
$ tax 60000 < taxCH_AR2026
1065
$ tax 60000 < taxCH_SG2026
3344
$ tax 60000 < taxCH_GR2026
3748
## explode: parts list -> all subparts with counts (part, amount)
$ explode 10001 2 parttable
Part Amount
@@ -1039,3 +1161,27 @@ Item Number: (or hit Return key to exit.) Item=7 Amount=5 Cost=175.00 Value=8
Amount Amount: Item Number: (or hit Return key to exit.)
$ grep '^7' inv
7 9 175.00 1575 scales
## ===== Choosing rows on the terminal =====
## pick shows a table, lets you scroll, filter (/) and mark rows (Space, -m), and writes the
## chosen rows as a table, so it can sit in a pipe. It needs a terminal; -k feeds the keys instead.
## Try it: pick -m < inventory | total Amount Value
## mark the 2nd and 5th row (down, space, down, down, space) and finish with enter
$ pick -m -k 'down space down down space enter' < inventory
Item Amount Cost Value Description
---- ------ ---- ----- -----------
2 100 0.50 50.00 test tubes
5 99 2.45 242.55 cleaning cloth
## the same chosen rows go on into total
$ pick -m -k 'down space down down space enter' < inventory | total Amount Value
Item Amount Cost Value Description
---- ------ ---- ----- -----------
199 292.55
## filter with / (type, enter), then choose the row that is left
$ pick -k '/clamps enter enter' < inventory
Item Amount Cost Value Description
---- ------ ---- ----- -----------
3 6 8.00 48.00 clamps
## Esc cancels: nothing on stdout, exit status 1
$ pick -k esc < inventory ; echo "status $?"
pick: nothing chosen
status 1
+275
View File
@@ -0,0 +1,275 @@
# frozen_string_literal: true
require 'io/console'
require 'open3'
# mask -- a full-screen input form. It reads the same kind of form as `screen`
# (text with <Column> fields) but draws it itself and writes the row typed in as
# a table on the standard output, so it works in a pipe: mask < entry | append t
#
# Besides <Column> the form may contain
# <Column:20> a field 20 characters wide (otherwise as wide as the <...>)
# [ Save ] a button; Cancel, Abort, Quit or Abbrechen cancel, any other accepts
# <!command!> replaced by the first line of the command's output
# Frames are plain text in the form, or drawn around everything with -b.
module Rdb
module Mask
Slot = Struct.new(:row, :col, :width, :name)
Button = Struct.new(:row, :col, :text, :label)
CANCEL = /\A(cancel|abort|quit|abbrechen)\z/i
TOKEN = /<!(.*?)!>|<([^<>!:\n]+?)(?::(\d+))?>|\[ *([^\[\]<>\n]*[^\[\]<>\s]) *\]/
class Form
attr_reader :lines, :slots, :buttons
def self.parse(text, frame: false)
lines = []
slots = []
buttons = []
text.each_line(chomp: true).with_index do |line, row|
out = +''
pos = 0
while (m = TOKEN.match(line, pos))
out << line[pos...m.begin(0)]
if m[1]
out << Open3.capture2('sh', '-c', m[1]).first.lines.first.to_s.chomp
elsif m[2]
width = m[3] ? m[3].to_i : m[0].size
slots << Slot.new(row, out.size, width, m[2].strip)
out << ' ' * width
else
buttons << Button.new(row, out.size, m[0], m[4].strip)
out << m[0]
end
pos = m.end(0)
end
lines << (out << line[pos..].to_s)
end
new(lines, slots, buttons).then { |f| frame ? f.framed : f }
end
def initialize(lines, slots, buttons)
@lines = lines
@slots = slots
@buttons = buttons
end
def names
@slots.map(&:name).uniq
end
# Fields (first slot of each column) and buttons in reading order.
def items
fields = @slots.uniq(&:name).map { |s| [:field, s] }
(fields + @buttons.map { |b| [:button, b] }).sort_by { |_, o| [o.row, o.col] }
end
def framed
w = @lines.map(&:size).max.to_i
lines = ["┌#{'─' * (w + 2)}┐"] + @lines.map { |l| "│ #{l.ljust(w)} │" } + ["└#{'─' * (w + 2)}┘"]
self.class.new(lines,
@slots.map { |s| Slot.new(s.row + 1, s.col + 2, s.width, s.name) },
@buttons.map { |b| Button.new(b.row + 1, b.col + 2, b.text, b.label) })
end
end
# The state of one input session: values, which item has the focus, and the keys.
class Session
attr_reader :values, :focus, :pos
def initialize(form)
@form = form
@items = form.items
@values = form.names.to_h { |n| [n, +''] }
@focus = 0
reset_pos
end
def kind = @items[@focus]&.first
def current = @items[@focus]&.last
def field? = kind == :field
# Feed one key; returns :accept or :cancel when the session ends.
def feed(key)
case key
when "\t", "\e[B", "\e[6~" then move(1)
when "\e[Z", "\e[A", "\e[5~" then move(-1)
when "\r", "\n" then return enter
when "\x03", "\e", "\x04" then return :cancel
when "\e[C" then @pos = [@pos + 1, text.size].min if field?
when "\e[D" then @pos = [@pos - 1, 0].max if field?
when "\e[H", "\e[1~", "\eOH", "\x01" then @pos = 0
when "\e[F", "\e[4~", "\eOF", "\x05" then @pos = text.size if field?
when "\x7f", "\b" then backspace
when "\e[3~" then delete
when "\x15" then clear_field
when ' '
return press unless field?
insert(key)
else insert(key) if key.size == 1 && key.ord >= 32
end
nil
end
# Plain-text picture of the form (no escapes), for `mask -s` and the tests.
def plain
grid = @form.lines.map(&:dup)
@form.slots.each { |s| grid[s.row][s.col, s.width] = '_' * s.width }
grid.join("\n") + "\n"
end
# Escape sequences that paint the whole screen.
def render
out = +"\e[?25l\e[H\e[2J"
@form.lines.each_with_index { |l, r| out << "\e[#{r + 1};1H#{l}" }
cursor = nil
@form.slots.each do |s|
focused = field? && current.name == s.name
start = focused ? view_start(s.width) : 0
shown = @values[s.name][start, s.width].to_s.ljust(s.width)
out << "\e[#{s.row + 1};#{s.col + 1}H\e[#{focused ? 7 : 4}m#{shown}\e[0m"
cursor = [s.row + 1, s.col + 1 + @pos - start] if focused && current.equal?(s)
end
@form.buttons.each do |b|
focused = kind == :button && current.equal?(b)
out << "\e[#{b.row + 1};#{b.col + 1}H\e[#{focused ? 7 : 1}m#{b.text}\e[0m"
cursor = [b.row + 1, b.col + 1] if focused
end
out << "\e[#{cursor[0]};#{cursor[1]}H" if cursor
out << "\e[?25h" if field?
out
end
private
def text = field? ? @values[current.name] : +''
def reset_pos
@pos = field? ? text.size : 0
end
def move(delta)
@focus = (@focus + delta) % [@items.size, 1].max
reset_pos
end
def enter
return press unless field?
last = @focus == @items.size - 1
return :accept if last && @form.buttons.empty?
move(1)
nil
end
def press
current.label.match?(CANCEL) ? :cancel : :accept
end
def insert(key)
return unless field?
text.insert(@pos, key)
@pos += 1
end
def backspace
return unless field? && @pos.positive?
text.slice!(@pos - 1)
@pos -= 1
end
def delete
text.slice!(@pos) if field?
end
def clear_field
return unless field?
text.clear
@pos = 0
end
def view_start(width)
[@pos - width + 1, 0].max
end
end
def self.read_key(io)
c = io.getch
return c unless c == "\e" && io.wait_readable(0.05)
seq = +"\e#{io.getch}"
if seq == "\e["
loop do
c = io.getch
seq << c
break if c.ord.between?(0x40, 0x7e)
end
elsif seq == "\eO"
seq << io.getch
end
seq
end
# Run the form on a terminal; returns the values, or nil when cancelled.
def self.run(form, tty)
session = Session.new(form)
tty.raw do
tty.write "\e[?1049h"
begin
loop do
tty.write session.render
key = begin
read_key(tty)
rescue EOFError
"\x03"
end
result = session.feed(key)
return (result == :accept ? session.values : nil) if result
end
ensure
tty.write "\e[?25h\e[?1049l"
end
end
end
end
end
Rdb.register('mask', 'Full-screen input form that writes a table row', 'mask [-b] [-s] [form] < form > table') do |args|
frame = !args.delete('-b').nil?
show = !args.delete('-s').nil?
die 'usage: mask [-b] [-s] [form] < form > table' if args.size > 1
file = args.first
file.nil? || file == '-' || File.exist?(file) or die("#{file}: no such form")
text = file && file != '-' ? File.read(file) : $stdin.read
form = Rdb::Mask::Form.parse(text, frame: frame)
die "the form has no <field>" if form.names.empty?
session = Rdb::Mask::Session.new(form)
if show
print session.plain
puts '', 'Fields:'
form.slots.each { |s| puts " #{s.name}\trow #{s.row + 1} col #{s.col + 1} width #{s.width}" }
form.buttons.each { |b| puts " [#{b.label}]\trow #{b.row + 1} col #{b.col + 1}#{b.label.match?(Rdb::Mask::CANCEL) ? ' (cancels)' : ''}" }
next
end
tty = begin
File.open('/dev/tty', 'r+')
rescue SystemCallError
die 'no terminal available (mask draws on /dev/tty)'
end
values = Rdb::Mask.run(form, tty)
tty.close
if values.nil?
warn 'mask: cancelled'
exit 1
end
Rdb::Table.new(form.names, [form.names.map { |n| values[n].tr("\t\r\n", ' ') }]).write
end
+218
View File
@@ -0,0 +1,218 @@
# frozen_string_literal: true
require 'io/console'
# pick -- look at a table on the terminal, scroll through it and choose one or
# several rows. The table comes in on the standard input, the chosen rows go
# out on the standard output as a table, so it sits in the middle of a pipe:
# column Item Description < inventory | pick -m | total Item
# The keys are read from /dev/tty (or, for scripts and tests, from -k).
module Rdb
module Pick
KEYS = {
'up' => "\e[A", 'down' => "\e[B", 'pgup' => "\e[5~", 'pgdn' => "\e[6~",
'home' => "\e[H", 'end' => "\e[F", 'enter' => "\r", 'space' => ' ',
'esc' => "\e", 'backspace' => "\x7f"
}.freeze
# The state of one session: which rows are shown, where the cursor is,
# which rows are marked. Knows nothing about the terminal.
class Session
attr_reader :head, :rows, :cursor, :top, :filter, :marked
def initialize(head, rows, multi: false)
@head = head
@rows = rows
@multi = multi
@marked = []
@filter = +''
@typing = false
@cursor = 0
@top = 0
@height = 20
@width = 80
refilter
end
def resize(height, width)
@height = [height, 1].max
@width = [width, 20].max
scroll
end
def typing? = @typing
# Indices (into rows) of the rows that pass the filter.
def visible = @visible
def current = @visible[@cursor]
# Rows to hand on: the marked ones, else the row under the cursor.
def selected
idx = @marked.empty? ? [current].compact : @marked.sort
idx.map { |i| @rows[i] }
end
# Returns :accept or :cancel when the session is over.
def feed(key)
@typing ? type(key) : browse(key)
end
def render
widths = @head.each_index.map { |i| ([@head[i]] + @rows.map { |r| r[i].to_s }).map(&:size).max }
line = ->(cells) { cells.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(' ').rstrip }
out = +"\e[?25l\e[H"
out << "\e[K \e[1m#{fit(line.(@head))}\e[0m\r\n"
out << "\e[K #{fit(widths.map { |w| '-' * w }.join(' '))}\r\n"
@height.times do |n|
pos = @top + n
i = @visible[pos]
out << "\e[K"
if i
text = "#{@marked.include?(i) ? '*' : ' '} #{fit(line.(@rows[i]))}"
out << (pos == @cursor ? "\e[7m#{text}\e[0m" : text)
end
out << "\r\n"
end
out << "\e[K#{status}"
out
end
private
def fit(text) = text[0, @width - 2]
def status
keys = @typing ? 'type to filter Enter keep Esc clear' : "Enter ok#{' Space mark a all' if @multi} / filter Esc cancel"
filter = @filter.empty? && !@typing ? '' : " /#{@filter}#{'_' if @typing}"
"\e[7m #{@visible.empty? ? 0 : @cursor + 1}/#{@visible.size} of #{@rows.size}" \
"#{" marked #{@marked.size}" if @multi}#{filter} #{keys} \e[0m"
end
def browse(key)
case key
when "\e[A" then move(-1)
when "\e[B" then move(1)
when "\e[5~" then move(-@height)
when "\e[6~" then move(@height)
when "\e[H", "\e[1~", "\eOH" then move(-@visible.size)
when "\e[F", "\e[4~", "\eOF" then move(@visible.size)
when "\r", "\n" then return selected.empty? ? :cancel : :accept
when ' ' then toggle
when 'a' then mark_all if @multi
when '/' then @typing = true
when "\x03", "\e", 'q' then return :cancel
end
nil
end
def type(key)
case key
when "\r", "\n" then @typing = false
when "\e" then @filter.clear; @typing = false; refilter
when "\x03" then return :cancel
when "\x7f", "\b" then @filter.chop!; refilter
else
if key.size == 1 && key.ord >= 32
@filter << key
refilter
end
end
nil
end
def refilter
needle = @filter.downcase
@visible = @rows.each_index.select { |i| needle.empty? || @rows[i].join("\t").downcase.include?(needle) }
@cursor = [@cursor, [@visible.size - 1, 0].max].min
scroll
end
def move(delta)
@cursor = (@cursor + delta).clamp(0, [@visible.size - 1, 0].max)
scroll
end
def scroll
@top = @cursor if @cursor < @top
@top = @cursor - @height + 1 if @cursor >= @top + @height
@top = 0 if @top.negative?
end
def toggle
return unless @multi && current
@marked.include?(current) ? @marked.delete(current) : @marked << current
move(1)
end
def mark_all
all = @visible.all? { |i| @marked.include?(i) }
all ? @marked -= @visible : @marked |= @visible
end
end
def self.run(session, tty)
tty.raw do
tty.write "\e[?1049h"
begin
loop do
rows, cols = tty.winsize
session.resize((rows.zero? ? 24 : rows) - 3, cols.zero? ? 80 : cols)
tty.write session.render
key = begin
Rdb::Mask.read_key(tty)
rescue EOFError
"\x03"
end
result = session.feed(key)
return result if result
end
ensure
tty.write "\e[?25h\e[?1049l"
end
end
end
# Words of a -k script: a name from KEYS, or else every character is one key.
def self.script(words)
words.split.flat_map { |w| KEYS.key?(w) ? [KEYS[w]] : w.chars }
end
end
end
Rdb.register('pick', 'Scroll through a table and choose one or more rows', 'pick [-m] [-k keys] < table > chosenrows') do |args|
multi = !args.delete('-m').nil?
keys = nil
if (i = args.index('-k'))
keys = args[i + 1] or die 'usage: pick [-m] [-k keys] < table'
args.slice!(i, 2)
end
die 'usage: pick [-m] [-k "down space enter"] < table > chosenrows' unless args.empty?
t = table_in
die 'pick: the table is empty' if t.rows.empty?
session = Rdb::Pick::Session.new(t.head, t.rows, multi: multi)
result = nil
if keys
session.resize(21, 80)
Rdb::Pick.script(keys).each do |k|
result = session.feed(k)
break if result
end
else
tty = begin
File.open('/dev/tty', 'r+')
rescue SystemCallError
die 'no terminal available (pick takes its keys from /dev/tty; see -k)'
end
result = Rdb::Pick.run(session, tty)
tty.close
end
unless result == :accept
warn 'pick: nothing chosen'
exit 1
end
Rdb::Table.new(t.head, session.selected).write
end
+24 -7
View File
@@ -11,9 +11,20 @@ Rdb.register('row', 'Make a new table where rows match logical condition',
end
Rdb.alias_for('select', 'row')
Rdb.register('column', 'Display columns of a table in any order', 'column [ Column ... ] < tableorlist') do |args|
Rdb.register('column', 'Display columns of a table in any order', 'column [ -d ] [ Column ... ] < tableorlist') do |args|
drop = args.first == '-d'
args.shift if drop
t = table_in
next t.write if args.empty?
next t.write if args.empty? && !drop
die 'usage: column -d Column ... < tableorlist' if drop && args.empty?
if drop # keep every column except the named ones
gone = args.map { |a| t.index_of(a) }
keep = t.head.each_index.reject { |i| gone.include?(i) }
die 'column -d would delete every column' if keep.empty?
next t.with(head: keep.map { |i| t.head[i] }, rows: t.rows.map { |r| keep.map { |i| r[i] } }).write
end
idx = args.map { |a| Rdb.column_index(t.head, a) }
head = args.each_with_index.map { |a, n| idx[n] ? t.head[idx[n]] : a }
@@ -72,8 +83,12 @@ end
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
die 'usage: record number < table' unless args.size == 1
die "'#{args[0]}' is not a record number (1, 2, 3 ... or -1 for the last)" unless args[0].match?(/\A[+-]?\d+\z/)
# 0 and 1 are the first record; -1 is the last, -2 the one before it ...
t = table_in
row = t.rows[args[0].to_i - 1]
n = args[0].to_i
row = n.negative? ? (t.rows[n] if -n <= t.rows.size) : t.rows[[n, 1].max - 1]
t.with(rows: row ? [row] : []).write
end
@@ -160,12 +175,14 @@ end
Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args|
n = 50
n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/)
die 'usage: splittable [-n] table' unless args.size == 1
die 'usage: splittable [-n] table (n is at least 1)' unless args.size == 1 && n.positive?
t = open_table(args[0])
chunks = t.rows.each_slice(n).to_a
die "#{args[0]}: #{t.rows.size} rows in pieces of #{n} need #{chunks.size} files, but there are only 676 names (aa to zz)" if chunks.size > 676
suffix = ('aa'..'zz').each
t.rows.each_slice(n) do |chunk|
File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s)
end
chunks.each { |chunk| File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s) }
end
# ---- aggregates ------------------------------------------------------------
+8 -4
View File
@@ -81,9 +81,10 @@ end
# ---- manual pages ---------------------------------------------------------------
def troff_clean(str)
str.gsub(/\\f[BIRP]/, '').gsub('\\e', '\\').gsub('\\(em', ' -- ').gsub('\\(aa', "'")
# a literal backslash (\\ or \e) is parked in \0 so that later rules cannot see it
str.gsub('\\\\', "\0").gsub(/\\f[BIRP]/, '').gsub('\\e', "\0").gsub('\\(em', ' -- ').gsub('\\(aa', "'")
.gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ')
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub('\\\\', '\\')
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub("\0", '\\')
end
def render_man(src)
@@ -147,8 +148,11 @@ Rdb.register('man', 'Display the manual page for a command', 'man [-g] command')
args.each do |a|
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
File.exist?(f) or die("no manual page for '#{a}'")
desc = File.read(f)[/^\.PC\s+"?(.*?)"?\s*$/, 1]
puts "#{a}#{desc ? " -- #{troff_clean(desc)}" : ''}", '=' * 60
src = File.read(f)
desc = src[/^\.PC[ \t]+"?(.*?)"?[ \t]*$/, 1]
# topic pages (dash line, head line ...) have an empty .PC; their title is in .TH
title = desc.to_s.empty? && src =~ /^\.TH\s+(?:"([^"]*)"|(\S+))/ ? ($1 || $2) : nil
puts "#{title || a}#{desc.to_s.empty? ? '' : " -- #{troff_clean(desc)}"}", '=' * 60
puts render_man(File.read(f)), ''
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Rdb
VERSION = '0.3.0'
VERSION = '0.7.0'
end
+18
View File
@@ -129,6 +129,24 @@ Names that are also
reserved words or are followed by
.B (
are never taken for columns.
.SH "Deleting Columns"
.I "(Ruby re-creation only.)"
.DM
column -d Column ... < tableorlist
.DE
.PP
With the option
.BR -d ,
.B column
keeps every column
.I except
those named.
The order of the other columns stays as it is.
It is an error to name a column that does not exist,
or to delete every column.
.DM
subtotal Date Debit Credit < journal | column -d Account Description
.DE
.SH "See Also"
.B
project,
+116
View File
@@ -0,0 +1,116 @@
.TH mask "" "" "/rdb Command"
.PC "Full-screen input form that writes a table row"
\fBmask \fB[ -b ] [ -s ] [ \fIform\fB ] < \fIform \fB> \fItable\fR
.PP
.II "mask^input form"
.II "form^full-screen input"
.I "(Ruby re-creation only; the original /rdb has no such command.)"
.PP
The command
.B mask
draws
.I form
on the terminal, lets the user fill in its fields, and writes the result as
a one-row table on the standard output.
Unlike
.BR screen ,
it does not write a shell script: it runs the form itself.
The screen is drawn on
.BR /dev/tty ,
so the table can be redirected or piped:
.DM
mask < maskform > newrow
mask < maskform | append -h customer
.DE
.PP
.I form
is ordinary text.
Everything is shown as it stands, except:
.IP "\fB<Column>\fR" 1.2i
An input field for column
.IR Column .
It is as wide as the text between the angle brackets, including the brackets.
Blanks around the name are ignored, so \fB< City >\fR is column \fBCity\fR.
.IP "\fB<Column:20>\fR"
The same, but 20 characters wide.
Longer input scrolls inside the field.
.IP "\fB[ Save ]\fR"
A button.
A button whose label is
.BR Cancel ,
.BR Abort ,
.B Quit
or
.B Abbrechen
cancels; any other button accepts.
.IP "\fB<!command!>\fR"
Replaced by the first line the shell
.I command
writes.
.PP
If a column occurs more than once in the form, it is one field,
and every place shows what was typed.
The table has one column per field, in the order of first appearance.
Frames are made of characters in the form, for example \fB+---+\fR or
box-drawing characters.
.SH Keys
.nf
Tab, Down, Enter next field or button
Shift-Tab, Up previous field or button
Left, Right, Home, End move inside a field
Backspace, Delete erase; Ctrl-U clears the field
Enter, Space on a button: press it
Enter on the last field of a form without buttons: accept
Esc, Ctrl-C cancel
.fi
.PP
When the user cancels,
.B mask
writes nothing and exits with status 1.
.SH Options
.IP \fB-b\fR 0.5i
Draw a frame around the whole form.
.IP \fB-s\fR
Show the form as a plain picture (fields as underscores) with the position of
each field and button, and exit.
This needs no terminal and is useful to check a form.
.PP
Like
.BR screen ,
.B mask
reads the form from the standard input.
A file name argument is also accepted, and
.B -
means the standard input.
.SH Example
Form
.BR maskform :
.DM
Mail List Entry
Name : <Name:24>
City : <City:16> State: <State:10>
[ Save ] [ Cancel ]
.DE
.PP
.DM
mask -b < maskform > newrow
.DE
.PP
After typing ``Ann'' and ``Rome'', pressing Tab to the buttons and Enter on
.BR Save ,
.B newrow
contains:
.DM
Name City State
---- ---- -----
Ann Rome
.DE
.SH "See Also"
.B
cursor,
report,
screen
.R
.SO rdb
+83
View File
@@ -0,0 +1,83 @@
.TH pick "" "" "/rdb Command"
.PC "Scroll through a table and choose one or more rows"
\fBpick \fB[ -m ] [ -k \fIkeys\fB ] < \fItable \fB> \fIchosenrows\fR
.PP
.II "pick^choose rows"
.II "table^scroll"
.I "(Ruby re-creation only; the original /rdb has no such command.)"
.PP
The command
.B pick
shows
.I table
on the terminal, lets the user scroll through it, and writes the rows
that were chosen as a table on the standard output.
It therefore fits into the middle of a pipe:
.DM
column Item Description < inventory | pick -m | jointable - owners
pick -m < inventory | total Amount Value
.DE
.PP
The table is read from the standard input;
the keys are read from the terminal (\fB/dev/tty\fR).
A list is read like a table, and the output is always a table.
The head and the dash line stay at the top while the rows scroll.
.SH Keys
.nf
Up, Down move the cursor one row
PgUp, PgDn move a page
Home, End first or last row
Enter choose and finish
Space mark the row, move down (with -m)
a mark all shown rows; again, unmark them (with -m)
/ filter: type text, shown rows must contain it
(Enter keeps the filter, Esc clears it)
Esc, q, Ctrl-C cancel
.fi
.PP
.B pick
writes the marked rows in table order.
If no row is marked, it writes the row under the cursor.
Marks stay when a filter hides the row.
If the user cancels, or the filter leaves no row to choose,
.B pick
writes nothing and exits with status 1.
.SH Options
.IP \fB-m\fR 0.5i
Allow several rows: Space marks, \fBa\fR marks all.
Without it, Enter chooses the row under the cursor.
.IP "\fB-k\fR \fIkeys\fR"
Take the keys from this string instead of the terminal.
Words are key names:
.BR up ,
.BR down ,
.BR pgup ,
.BR pgdn ,
.BR home ,
.BR end ,
.BR enter ,
.BR space ,
.BR esc ,
.BR backspace ;
any other word is typed character by character.
This is for scripts, demonstrations and tests.
.SH Example
.DM
pick -m -k 'down space down down space enter' < inventory
.DE
.PP
marks the second and the fifth row and prints:
.DM
Item Amount Cost Value Description
---- ------ ---- ------ --------------
2 100 0.50 50.00 test tubes
5 99 2.45 242.55 cleaning cloth
.DE
.SH "See Also"
.B
display,
mask,
row,
search
.R
.SO rdb
+11
View File
@@ -59,6 +59,17 @@ you see output in the following format:
.DM
Item 1 is rubber gloves
.DE
.SH "Record Numbers"
.I "(Ruby re-creation only.)"
Records are counted from 1.
The numbers 0 and 1 both mean the first record.
A negative number counts from the end:
.B \-1
is the last record,
.B \-2
the one before it, and so on.
A number beyond either end gives a table without records.
Anything that is not a whole number is an error.
.SH "See Also"
.B
row,
+26
View File
@@ -191,6 +191,32 @@ Names that are also
reserved words or are followed by
.B (
are never taken for columns.
.SH "Using select in a Shell"
.I "(Ruby re-creation only.)"
.B select
is another name for
.BR row ,
but in
.BR bash ,
.B zsh
and
.B ksh
``select'' is a reserved word of the shell.
The shell recognizes it before it looks for a program, so
.DM
select 'Cost > 5' < inventory
.DE
.PP
stops with a syntax error at the ``<''.
Use the name
.B row
instead, or protect the word from the shell:
.DM
row 'Cost > 5' < inventory
\\select 'Cost > 5' < inventory
command select 'Cost > 5' < inventory
rdb select 'Cost > 5' < inventory
.DE
.SH "See Also"
.B
compute,
+2
View File
@@ -1,5 +1,7 @@
[tools]
"cargo:scryer-prolog" = "0.10"
ruby = "3.4"
rust = "1.99"
[env]
_.path = ["{{config_root}}/bin"]
+316 -1
View File
@@ -1,8 +1,69 @@
# frozen_string_literal: true
require_relative 'test_helper'
require 'pty'
require_relative '../lib/rdb'
class SelectionTest < RdbCase
def test_column_d_deletes_columns
out = rdb('column', '-d', 'Cost', 'Value', file: 'inventory')
assert_equal %w[Item Amount Description], out.lines.first.chomp.split("\t")
assert_equal %w[1 3 rubber\ gloves], data(out).first
assert_equal %w[Date Debit Credit], rdb('column', '-d', 'Account', 'Description', stdin: rdb('subtotal', 'Date', 'Debit', 'Credit', file: 'journal')).lines.first.chomp.split("\t")
end
def test_column_d_errors
src = File.read(File.join(@dir, 'owners'))
[%w[-d], %w[-d Nope], %w[-d Item Owner]].each do |args|
out, err, st = run3('column', *args, stdin: src)
refute st.success?, "column #{args.join(' ')} should fail"
assert_equal '', out
refute_empty err
end
end
def test_column_d_works_on_lists_and_with_icase
list = rdb('tabletolist', file: 'owners')
assert_includes rdb('column', '-d', 'Item', stdin: list), "Owner\tann"
out, _e, st = run3('column', '-d', 'COST', stdin: File.read(File.join(@dir, 'inventory')), env: { 'RDB_ICASE' => '1' })
assert st.success?
refute_includes out.lines.first, 'Cost'
end
def test_splittable_pieces_and_errors
out, = run3('splittable', '-4', 'inventory')
assert_equal 4, data(File.read(File.join(@dir, 'inventoryaa'))).size
assert_equal 3, data(File.read(File.join(@dir, 'inventoryab'))).size
assert_includes File.read(File.join(@dir, 'inventoryab')), "Item\tAmount"
_o, err, st = run3('splittable', '-0', 'inventory')
refute st.success?
assert_match(/usage/, err)
write('big', "N\n-\n#{(1..700).to_a.join("\n")}\n")
_o, err, st = run3('splittable', '-1', 'big')
refute st.success?
assert_match(/only 676 names/, err)
assert_empty Dir[File.join(@dir, 'bigaa')] # nothing is written when it cannot all be written
end
def test_record_zero_and_one_are_the_first_and_negatives_count_from_the_end
{ '0' => '1', '1' => '1', '2' => '3', '3' => '6', '-1' => '6', '-2' => '3', '-3' => '1', '+2' => '3' }.each do |n, item|
assert_equal [[item]], data(rdb('record', n, file: 'owners')).map { |r| r.first(1) }, "record #{n}"
end
end
def test_record_beyond_either_end_is_an_empty_table
%w[4 9 -4 -9].each { |n| assert_equal [], data(rdb('record', n, file: 'owners')), "record #{n}" }
end
def test_record_rejects_junk
%w[abc 2.5 1e3].each do |n|
out, err, st = run3('record', n, stdin: File.read(File.join(@dir, 'owners')))
refute st.success?, "record #{n} should fail"
assert_equal '', out
assert_match(/not a record number/, err)
end
end
def test_row_numeric_condition
out = rdb('row', 'Cost > 5', file: 'inventory')
assert_equal %w[3 6 7], data(out).map(&:first)
@@ -427,7 +488,7 @@ end
class HelpTest < RdbCase
def test_whatis_whatwill_commands
assert_includes rdb('whatis', 'column'), " column - display columns of a table in any order\n column column [ Column ... ] < tableorlist\n"
assert_includes rdb('whatis', 'column'), " column - display columns of a table in any order\n column column [ -d ] [ Column ... ] < tableorlist\n"
assert_includes rdb('whatwill', 'total'), 'subtotal'
assert_operator data(rdb('commands')).size, :>, 120
assert_operator rdb('howmany').to_i, :>, 120
@@ -445,6 +506,12 @@ class HelpTest < RdbCase
assert_equal "\e[3;8H", rdb('cursor', '3', '8')
end
def test_man_topic_pages_get_their_title
assert_equal 'dash line', rdb('man', 'dashline').lines.first.chomp
assert_equal 'head line', rdb('man', 'headline').lines.first.chomp
assert_match(/\Arow -- Make a new table/, rdb('man', 'row'))
end
def test_testall_mentions_every_command
script = File.read(File.join(DEMO, 'testall.cmds'))
names = data(rdb('commands')).map(&:first)
@@ -523,3 +590,251 @@ class ActTest < RdbCase
assert_equal [%w[b]], data(out)
end
end
class MaskTest < RdbCase
def form(text, frame: false) = Rdb::Mask::Form.parse(text, frame: frame)
def type(session, *keys)
keys.each { |k| k.start_with?("\e") || k.size == 1 ? session.feed(k) : k.each_char { |c| session.feed(c) } }
end
def test_parse_fields_buttons_and_width
f = form("Name: <Name:10> < City >\n[ Save ] [Cancel]\n")
assert_equal %w[Name City], f.names
assert_equal [0, 6, 10], f.slots[0].to_a.first(3)
assert_equal 9, f.slots[1].width # the width of "< City >"
assert_equal %w[Save Cancel], f.buttons.map(&:label)
assert_equal ["Name: #{' ' * 10} #{' ' * 9}", '[ Save ] [Cancel]'], f.lines
end
def test_command_in_form_and_frame
f = form("<!echo hi!> <A>\n", frame: true)
assert_equal '│ hi │', f.lines[1]
assert_equal [1, 5], f.slots.first.to_a.first(2)
end
def test_typing_and_navigation
s = Rdb::Mask::Session.new(form("<A:5> <B:5> [ OK ] [ Cancel ]\n"))
type(s, 'one', "\t", 'two', "\x7f", "\r")
assert_equal({ 'A' => 'one', 'B' => 'tw' }, s.values)
assert_equal :button, s.kind
assert_equal :accept, s.feed("\r")
end
def test_cancel_by_button_and_escape
s = Rdb::Mask::Session.new(form("<A:5> [ Save ] [ Abbrechen ]\n"))
s.feed("\e[Z")
assert_equal :cancel, s.feed(' ')
assert_equal :cancel, Rdb::Mask::Session.new(form("<A>\n")).feed("\e")
end
def test_enter_on_last_field_accepts_without_buttons
s = Rdb::Mask::Session.new(form("<A:3> <B:3>\n"))
assert_nil s.feed("\r")
assert_equal :accept, s.feed("\r")
end
def test_same_column_twice_is_one_field
s = Rdb::Mask::Session.new(form("<A:3> <A:3>\n"))
type(s, 'xy')
assert_equal({ 'A' => 'xy' }, s.values)
assert_equal %w[A], form("<A:3> <A:3>\n").names
end
def test_show_prints_picture_without_terminal
write('f', "Name: <Name:6>\n[ Save ] [ Cancel ]\n")
out = rdb('mask', '-s', file: 'f')
assert_includes out, "Name: ______\n[ Save ] [ Cancel ]"
assert_includes out, " Name\trow 1 col 7 width 6"
assert_includes out, " [Cancel]\trow 2 col 10 (cancels)"
assert_equal out, rdb('mask', '-s', 'f') # a file name works too
end
# Drive mask on a pseudo terminal; its table goes to a file, like in a pipe.
def run_on_pty(*keys)
out = File.join(@dir, 'mask.out')
result = nil
PTY.spawn({ 'PATH' => "#{BIN}:#{ENV['PATH']}" }, 'sh', '-c', "cd #{@dir} && mask < f > mask.out") do |r, w, pid|
sleep 0.5
keys.each do |k|
w.write k
sleep 0.1
r.read_nonblock(65_536) rescue nil
end
sleep 0.3
r.read_nonblock(65_536) rescue nil
_, st = Process.wait2(pid)
result = [File.exist?(out) ? File.read(out) : '', st.exitstatus]
end
result
end
def test_mask_on_a_terminal_writes_a_table
write('f', "Name: <Name:10>\nCity: <City:10>\n[ Save ] [ Cancel ]\n")
table, status = run_on_pty("Ann\r", "Rome\r", "\r")
assert_equal 0, status
assert_equal "Name\tCity\n----\t----\nAnn\tRome\n", table
end
def test_mask_cancel_writes_nothing
write('f', "Name: <Name:10>\n[ Save ] [ Cancel ]\n")
table, status = run_on_pty("Ann", "\e[Z", "\r")
assert_equal 1, status
assert_equal '', table
end
end
class PickTest < RdbCase
def session(multi: true, height: 3)
t = Rdb::Table.read(File.open(File.join(DEMO, 'inventory')))
Rdb::Pick::Session.new(t.head, t.rows, multi: multi).tap { |x| x.resize(height, 80) }
end
def keys(sess, words) = Rdb::Pick.script(words).map { |k| sess.feed(k) }.compact.last
def test_mark_and_select
s = session
assert_equal :accept, keys(s, 'down space down down space enter')
assert_equal %w[2 5], s.selected.map(&:first)
end
def test_single_mode_takes_the_cursor_row_and_ignores_space
s = session(multi: false)
assert_equal :accept, keys(s, 'down down space enter')
assert_equal %w[3], s.selected.map(&:first)
end
def test_scrolling_keeps_the_cursor_in_view
s = session(height: 3)
keys(s, 'down down down down')
assert_equal [4, 2], [s.cursor, s.top]
keys(s, 'end')
assert_equal [6, 4], [s.cursor, s.top]
keys(s, 'pgup')
assert_equal 3, s.cursor
keys(s, 'home')
assert_equal [0, 0], [s.cursor, s.top]
end
def test_filter_and_clear
s = session
keys(s, '/clam')
assert_predicate s, :typing?
keys(s, 'enter')
assert_equal [2], s.visible
keys(s, '/ esc')
assert_equal 7, s.visible.size
end
def test_marks_survive_a_filter_and_a_all_toggles
s = session
keys(s, 'space /scales enter')
assert_equal [0], s.marked
keys(s, 'a')
assert_equal [0, 6], s.marked.sort
keys(s, 'a')
assert_equal [0], s.marked
end
def test_cancel_and_empty_choice
assert_equal :cancel, keys(session, 'esc')
assert_equal :cancel, keys(session, '/zzz enter enter')
end
def test_command_with_keys_script_is_a_filter
out = rdb('pick', '-m', '-k', 'down space down down space enter', file: 'inventory')
assert_equal %w[2 5], data(out).map(&:first)
assert_equal '292.55', data(rdb('total', 'Value', stdin: out)).first[3]
end
def test_command_cancelled_exits_1_with_no_output
out, _err, st = run3('pick', '-k', 'esc', stdin: File.read(File.join(@dir, 'inventory')))
refute st.success?
assert_equal '', out
end
def test_command_reads_a_list
out = rdb('pick', '-k', 'down enter', stdin: rdb('tabletolist', file: 'owners'))
assert_equal [%w[3 bob]], data(out)
end
def run_on_pty(*key_strokes)
out = File.join(@dir, 'pick.out')
result = nil
PTY.spawn({ 'PATH' => "#{BIN}:#{ENV['PATH']}" }, 'sh', '-c', "cd #{@dir} && pick -m < inventory > pick.out") do |r, w, pid|
sleep 0.5
key_strokes.each do |k|
w.write k
sleep 0.1
r.read_nonblock(65_536) rescue nil
end
sleep 0.3
r.read_nonblock(65_536) rescue nil
_, st = Process.wait2(pid)
result = [File.exist?(out) ? File.read(out) : '', st.exitstatus]
end
result
end
def test_pick_on_a_terminal
table, status = run_on_pty("\e[B", ' ', "\e[B", ' ', "\r")
assert_equal 0, status
assert_equal %w[2 4], data(table).map(&:first)
end
def test_pick_on_a_terminal_cancel
table, status = run_on_pty("\e[B", "\e")
assert_equal 1, status
assert_equal '', table
end
end
class TaxTablesTest < RdbCase
def tax(income, table) = rdb('tax', income.to_s, file: table).to_i
def test_at_2026
assert_equal 0, tax(13_539, 'taxAT2026')
assert_equal 11_447, tax(50_000, 'taxAT2026') # 6 030.40 + 13 542 * 40 %
assert_equal 33_818, tax(100_000, 'taxAT2026')
end
def test_ch_federal_2026
assert_equal 0, tax(15_200, 'taxCH2026')
assert_equal 401, tax(50_000, 'taxCH2026')
assert_equal 2684, tax(100_000, 'taxCH2026')
assert_equal 91_310, tax(794_000, 'taxCH2026')
end
def test_li_landessteuer_2026
assert_equal 996, tax(50_000, 'taxLI2026') # 0,04 * x - 1 004
assert_equal 3256, tax(100_000, 'taxLI2026') # 0,05 * x - 1 744
assert_equal 10_464, tax(211_400, 'taxLI2026')
assert_equal 0, tax(15_855, 'taxLI2026')
end
def test_swiss_cantons_2026_einfache_staatssteuer
assert_equal 3770, tax(60_000, 'taxCH_AI2026') # 2 070 + 8,5 % of 20 000
assert_equal 16_000, tax(200_000, 'taxCH_AI2026')
assert_equal 24_000, tax(300_000, 'taxCH_AI2026') # 8 % of the whole income
assert_equal 1065, tax(60_000, 'taxCH_AR2026')
assert_equal 3344, tax(60_000, 'taxCH_SG2026') # 1 248 + 8 % + 9,2 % pieces
assert_equal 25_500, tax(300_000, 'taxCH_SG2026') # 8,5 % of the whole income
assert_equal 3748, tax(60_000, 'taxCH_GR2026')
assert_equal 0, tax(16_895, 'taxCH_GR2026')
assert_equal 20, tax(5_000, 'taxCH_AI2026') # 1 % of 2 000
%w[AR SG GR].each { |c| assert_equal 0, tax(5_000, "taxCH_#{c}2026") }
end
# § 32a EStG: the table is a straight-line approximation of the curved zones
def test_de_2026_follows_the_formula
[12_348, 14_000, 17_799, 20_000, 33_333, 50_000, 69_878, 100_000, 277_825, 300_000].each do |x|
exact = if x <= 12_348 then 0
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
elsif x <= 277_825 then (0.42 * x) - 11_135.63
else (0.45 * x) - 19_470.38
end
assert_in_delta exact.floor, tax(x, 'taxDE2026'), 1, "income #{x}"
end
end
end
+94
View File
@@ -0,0 +1,94 @@
# frozen_string_literal: true
# Writes the 2026 income tax tables in the format of `tax` (income, tax, percent)
# into demo/. Sources: § 32a EStG (DE), § 33 EStG (AT), Tarif 2026 Art. 36 DBG (CH,
# ESTV Rundschreiben 2-215-D-2025), Art. 19 SteG LR 640.0, Fassung 01.07.2026 (LI).
# ruby tools/gen_tax_tables.rb
DEMO = File.expand_path('../demo', __dir__)
def write_table(name, points)
rows = points.map { |i, t, p| [i.to_s, format('%.2f', t), format('%.6f', p).sub(/0+\z/, '').sub(/\.\z/, '')] }
File.write(File.join(DEMO, name), "income\ttax\tpercent\n------\t---\t-------\n#{rows.map { |r| r.join("\t") }.join("\n")}\n")
end
# --- Germany: single assessment, § 32a Abs. 1 (the zones 2 and 3 are curves, so
# the table approximates them with short straight pieces; error below one euro)
def de_tax(x)
if x <= 12_348 then 0.0
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
elsif x <= 277_825 then (0.42 * x) - 11_135.63
else (0.45 * x) - 19_470.38
end
end
de = [12_348] + (12_598..17_798).step(250).to_a + [17_799] + (18_049..69_549).step(250).to_a + [69_879, 277_826]
pts = de.each_with_index.map do |i, n|
nxt = de[n + 1]
slope = nxt ? (de_tax(nxt) - de_tax(i)) / (nxt - i) * 100 : 45.0
[i, de_tax(i), slope]
end
write_table('taxDE2026', [[0, 0.0, 0.0]] + pts)
# --- Austria: § 33 EStG, the tariff before the Absetzbeträge
at = [[13_539, 20], [21_992, 30], [36_458, 40], [70_365, 48], [104_859, 50], [1_000_000, 55]]
acc = 0.0
prev = [0, 0]
pts = [[0, 0.0, 0.0]]
at.each do |limit, rate|
acc += (limit - prev[0]) * prev[1] / 100.0
pts << [limit, acc, rate]
prev = [limit, rate]
end
write_table('taxAT2026', pts)
# --- Switzerland: direct federal tax, Art. 36 DBG, single persons (without canton and municipality)
ch = [[0, 0, 0], [15_200, 0, 0.77], [33_200, 138.60, 0.88], [43_500, 229.20, 2.64], [58_000, 612.00, 2.97],
[76_200, 1152.50, 5.94], [82_100, 1502.95, 6.60], [108_900, 3271.75, 8.80], [141_500, 6140.55, 11.00],
[185_100, 10_936.55, 13.20], [793_900, 91_298.15, 11.85], [794_000, 91_310.00, 11.50]]
write_table('taxCH2026', ch)
# --- Liechtenstein: Landessteuer, Art. 19 Abs. 1 Bst. a SteG; the municipal surcharge
# (150 % to 250 % of it) comes on top: total = Landessteuer * (1 + surcharge / 100)
li_rates = [[15_900, 1], [21_100, 3], [42_300, 4], [74_000, 5], [105_700, 6], [137_400, 6.5], [169_200, 7], [211_400, 8]]
acc = 0.0
prev = [15_855, 0]
pts = [[0, 0.0, 0.0]]
li_rates.each do |limit, rate|
acc += (limit - prev[0]) * prev[1] / 100.0 if prev[1].positive?
pts << [limit, acc, rate]
prev = [limit, rate]
end
write_table('taxLI2026', pts)
# --- Swiss cantons: the "einfache Staatssteuer" for single persons, before the Steuerfuss and
# without municipal and church tax. Source: ESTV Kantonsblätter (Steuerinformationen 2026).
# Each tariff is a list of [width, rate] pieces; above the last piece one flat rate applies to
# the whole income (the cantons call this "für das ganze Einkommen").
def staircase(pieces, flat_rate, expect_limit)
acc = 0.0
start = 0
pts = []
pieces.each do |width, rate|
pts << [start, acc, rate]
acc += width * rate / 100.0
start += width
end
raise "limit #{start} != #{expect_limit}" unless start == expect_limit
pts << [start, start * flat_rate / 100.0, flat_rate]
end
# AI: Art. 38 StG
write_table('taxCH_AI2026', staircase([[3_000, 0], [3_000, 1], [3_000, 2], [3_000, 3], [3_000, 4], [3_000, 5], [4_000, 6],
[4_000, 7], [4_000, 7.5], [10_000, 8], [34_000, 8.5], [66_000, 9], [60_000, 8.5]], 8, 200_000))
# AR: Art. 39 Abs. 1 lit. b StG (Alleinstehendentarif, Beträge ab Steuerperiode 2024); multiplied by the Steuereinheiten
write_table('taxCH_AR2026', staircase([[8_300, 0], [1_600, 0.6], [1_600, 1.0], [4_200, 1.5], [11_500, 1.8], [14_600, 2.2],
[12_500, 2.4], [19_800, 2.6], [14_600, 2.7], [36_500, 2.8], [135_600, 2.9]], 2.6, 260_800))
# SG: Art. 50 StG (Beträge ab Steuerperiode 2026)
write_table('taxCH_SG2026', staircase([[11_600, 0], [4_200, 4], [18_000, 6], [26_500, 8], [38_100, 9.2], [166_100, 9.4]], 8.5, 264_500))
# GR: Art. 39 Abs. 1 StG (indexierte Beträge ab Steuerperiode 2025)
write_table('taxCH_GR2026', staircase([[16_895, 0], [1_090, 2.5], [1_090, 4], [1_090, 5], [1_090, 6], [1_090, 6.5], [2_180, 7],
[6_540, 8], [4_360, 8.5], [4_360, 9], [4_360, 9.5], [21_800, 10.3], [21_800, 10.6],
[21_800, 10.7], [109_000, 11.2], [109_000, 11.3], [109_000, 11.4], [343_895, 11.6]], 11.0, 780_440))