mevebu 1.10.0 → 2.0.0

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **188 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **200 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
6
6
 
7
7
  A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
8
8
 
@@ -10,10 +10,10 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
10
10
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-188-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-200-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
15
15
 
16
- [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Host it yourself](#host-it-yourself) · [Development](#development) · [FAQ](#faq)
16
+ [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Credit](#credit) · [Development](#development) · [FAQ](#faq)
17
17
 
18
18
  </div>
19
19
 
@@ -23,11 +23,11 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
23
23
 
24
24
  ## What is mevebu?
25
25
 
26
- mevebu is a collection of **188 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
26
+ mevebu is a collection of **200 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
27
 
28
28
  Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
29
 
30
- You can run it on your own computer with one command, or copy the folder to any static host and use it from anywhere.
30
+ Install it once with one command and it runs on your machine, with no server behind it and no account to create.
31
31
 
32
32
  ## Why you will like it
33
33
 
@@ -37,7 +37,6 @@ You can run it on your own computer with one command, or copy the folder to any
37
37
  - **One tool, one page.** Every tool has its own clean page you can bookmark.
38
38
  - **Instant search.** Press `Ctrl+K` and find any tool by name, keyword, or what it does.
39
39
  - **Works offline.** Once installed, it runs fully on your machine.
40
- - **Easy to host.** It is a plain static site. Drop it on any file host.
41
40
  - **Free and open source.** MIT licensed.
42
41
 
43
42
  ## Quick start
@@ -65,14 +64,14 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
65
64
 
66
65
  ## What's inside
67
66
 
68
- mevebu has **188 tools in 20 categories**. Here is the full breakdown:
67
+ mevebu has **200 tools in 20 categories**. Here is the full breakdown:
69
68
 
70
69
  | Category | Tools |
71
70
  | ------------------ | ----: |
72
- | Random | 3 |
71
+ | Data Generator | 5 |
73
72
  | Passwords and keys | 10 |
74
73
  | Developer | 21 |
75
- | Convert | 5 |
74
+ | Convert | 15 |
76
75
  | Text and data | 14 |
77
76
  | Daily use | 8 |
78
77
  | Math | 17 |
@@ -89,7 +88,7 @@ mevebu has **188 tools in 20 categories**. Here is the full breakdown:
89
88
  | GitHub | 8 |
90
89
  | Units | 8 |
91
90
  | QR codes | 2 |
92
- | **Total** | **188** |
91
+ | **Total** | **200** |
93
92
 
94
93
  The counts come straight from the tool registry, so this table and the app never drift apart.
95
94
 
@@ -126,21 +125,18 @@ Do not just take our word for it. Open the Network tab in your browser dev tools
126
125
  | Your data uploaded | Never | Sometimes |
127
126
  | Works offline | Yes | Rarely |
128
127
  | Open source | Yes (MIT) | Rarely |
129
- | Self-hostable | Yes | Rarely |
130
128
 
131
- ## Host it yourself
129
+ ## Credit
132
130
 
133
- Prefer not to use the CLI? mevebu is a plain static site, so you can host it anywhere files can be served: a static site host, a CDN, an object store, or even a USB drive.
131
+ mevebu is built and maintained by the [mevebu](https://github.com/mevebu) organization, created by [Ilyas Mukhlisin](https://www.instagram.com/ilyas.mukhlisin). Two repositories live there: **Mevebu**, this toolbox, and **Waitsec**, a set of guardrails for coding agents.
134
132
 
135
- 1. Copy the `mevebu/` folder from this repo (or from the npm package).
136
- 2. Upload it to your host.
137
- 3. Open `index.html`.
133
+ The code is MIT licensed. If you learn from it, borrow a helper, or ship something inspired by it, keep the copyright notice and say where it came from. A link back to [github.com/mevebu/mevebu](https://github.com/mevebu/mevebu) is enough.
138
134
 
139
- Routes are hash routes, like `#/security/password-generator`, so you do not need any server rewrites. The CSS is one committed stylesheet built by Tailwind, and there is no extra build step. Your host never has to run code.
135
+ Bug reports and tool ideas are welcome in the issues. The best way to support the project is to use it, share it, and tell us what is missing.
140
136
 
141
137
  ## Development
142
138
 
143
- Want to hack on mevebu or add your own tool? Here is how to get started:
139
+ Contributions are welcome. Here is how to run the project locally while you work on it:
144
140
 
145
141
  ```bash
146
142
  git clone https://github.com/mevebu/mevebu
@@ -180,7 +176,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
180
176
  ## FAQ
181
177
 
182
178
  **What is mevebu?**
183
- mevebu is a free, open source collection of 188 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
179
+ mevebu is a free, open source collection of 200 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
184
180
 
185
181
  **Is mevebu really free?**
186
182
  Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
@@ -191,8 +187,8 @@ No. Your input is processed in your browser tab and never sent to a server.
191
187
  **Can I use mevebu offline?**
192
188
  Yes. After you install it with npm, it runs on your own machine without needing the internet.
193
189
 
194
- **Can I host mevebu on my own site?**
195
- Yes. Copy the `mevebu/` folder to any static host. No server side setup is needed.
190
+ **Can I reuse the code?**
191
+ Yes. It is MIT licensed, so keep the copyright notice and the license text, and credit [mevebu](https://github.com/mevebu/mevebu) where you use it.
196
192
 
197
193
  **How do I find a tool quickly?**
198
194
  Press `Ctrl+K` and search by tool name, keyword, or what the tool does.
@@ -1013,7 +1013,7 @@ function aboutPage() {
1013
1013
  block("Running it",
1014
1014
  el("p", {}, "Install it once from npm. The command starts a local server and opens your browser at the address it prints."),
1015
1015
  el("pre", { class: "m-0 overflow-x-auto rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3 font-mono text-sm" }, "npm install -g mevebu\nmevebu"),
1016
- el("p", {}, "Serve the ", el("code", { class: CODE }, "mevebu/"), " folder from any static host. Routes are hash routes, so no rewrite rules are needed.")),
1016
+ el("p", {}, "The command serves the tools that ship with the package, so there is nothing to configure and no server to run.")),
1017
1017
 
1018
1018
  block("Adding a tool",
1019
1019
  el("p", {}, "Add one object to ", el("code", { class: CODE }, "mevebu/libs/js/registry.js"), " and one file in ", el("code", { class: CODE }, "mevebu/libs/js/tools/"), ". The home page, the index, the categories, search and the route all come from that entry.")),
@@ -218,6 +218,9 @@
218
218
  .inset-0 {
219
219
  inset: 0px;
220
220
  }
221
+ .start-1 {
222
+ inset-inline-start: calc(var(--spacing) * 1);
223
+ }
221
224
  .end-1 {
222
225
  inset-inline-end: calc(var(--spacing) * 1);
223
226
  }
@@ -290,6 +293,9 @@
290
293
  .ms-1\.5 {
291
294
  margin-inline-start: calc(var(--spacing) * 1.5);
292
295
  }
296
+ .ms-2 {
297
+ margin-inline-start: calc(var(--spacing) * 2);
298
+ }
293
299
  .ms-auto {
294
300
  margin-inline-start: auto;
295
301
  }
@@ -401,6 +407,10 @@
401
407
  .aspect-\[4\/3\] {
402
408
  aspect-ratio: 4/3;
403
409
  }
410
+ .size-1\.5 {
411
+ width: calc(var(--spacing) * 1.5);
412
+ height: calc(var(--spacing) * 1.5);
413
+ }
404
414
  .size-3 {
405
415
  width: calc(var(--spacing) * 3);
406
416
  height: calc(var(--spacing) * 3);
@@ -421,6 +431,10 @@
421
431
  width: calc(var(--spacing) * 6);
422
432
  height: calc(var(--spacing) * 6);
423
433
  }
434
+ .size-7 {
435
+ width: calc(var(--spacing) * 7);
436
+ height: calc(var(--spacing) * 7);
437
+ }
424
438
  .size-8 {
425
439
  width: calc(var(--spacing) * 8);
426
440
  height: calc(var(--spacing) * 8);
@@ -433,6 +447,10 @@
433
447
  width: calc(var(--spacing) * 11);
434
448
  height: calc(var(--spacing) * 11);
435
449
  }
450
+ .size-12 {
451
+ width: calc(var(--spacing) * 12);
452
+ height: calc(var(--spacing) * 12);
453
+ }
436
454
  .size-14 {
437
455
  width: calc(var(--spacing) * 14);
438
456
  height: calc(var(--spacing) * 14);
@@ -441,10 +459,6 @@
441
459
  width: calc(var(--spacing) * 16);
442
460
  height: calc(var(--spacing) * 16);
443
461
  }
444
- .size-32 {
445
- width: calc(var(--spacing) * 32);
446
- height: calc(var(--spacing) * 32);
447
- }
448
462
  .size-\[15px\] {
449
463
  width: 15px;
450
464
  height: 15px;
@@ -495,6 +509,9 @@
495
509
  .h-14 {
496
510
  height: calc(var(--spacing) * 14);
497
511
  }
512
+ .h-24 {
513
+ height: calc(var(--spacing) * 24);
514
+ }
498
515
  .h-\[22px\] {
499
516
  height: 22px;
500
517
  }
@@ -525,6 +542,9 @@
525
542
  .max-h-80 {
526
543
  max-height: calc(var(--spacing) * 80);
527
544
  }
545
+ .max-h-\[320px\] {
546
+ max-height: 320px;
547
+ }
528
548
  .max-h-\[340px\] {
529
549
  max-height: 340px;
530
550
  }
@@ -588,9 +608,6 @@
588
608
  .w-\[min\(300px\,100\%\)\] {
589
609
  width: min(300px, 100%);
590
610
  }
591
- .w-\[min\(320px\,100\%\)\] {
592
- width: min(320px, 100%);
593
- }
594
611
  .w-\[min\(420px\,100\%\)\] {
595
612
  width: min(420px, 100%);
596
613
  }
@@ -603,6 +620,9 @@
603
620
  .w-\[min\(640px\,100\%\)\] {
604
621
  width: min(640px, 100%);
605
622
  }
623
+ .w-auto {
624
+ width: auto;
625
+ }
606
626
  .w-full {
607
627
  width: 100%;
608
628
  }
@@ -633,15 +653,15 @@
633
653
  .max-w-\[64ch\] {
634
654
  max-width: 64ch;
635
655
  }
636
- .max-w-\[340px\] {
637
- max-width: 340px;
638
- }
639
656
  .max-w-\[560px\] {
640
657
  max-width: 560px;
641
658
  }
642
659
  .max-w-\[var\(--bench\)\] {
643
660
  max-width: var(--bench);
644
661
  }
662
+ .max-w-full {
663
+ max-width: 100%;
664
+ }
645
665
  .min-w-0 {
646
666
  min-width: 0px;
647
667
  }
@@ -657,6 +677,9 @@
657
677
  .shrink {
658
678
  flex-shrink: 1;
659
679
  }
680
+ .grow {
681
+ flex-grow: 1;
682
+ }
660
683
  .border-collapse {
661
684
  border-collapse: collapse;
662
685
  }
@@ -675,6 +698,15 @@
675
698
  .scale-\[0\.97\] {
676
699
  scale: 0.97;
677
700
  }
701
+ .-rotate-90 {
702
+ rotate: calc(90deg * -1);
703
+ }
704
+ .rotate-90 {
705
+ rotate: 90deg;
706
+ }
707
+ .rotate-180 {
708
+ rotate: 180deg;
709
+ }
678
710
  .transform {
679
711
  transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
680
712
  }
@@ -702,15 +734,33 @@
702
734
  .\[grid-template-columns\:minmax\(0\,1fr\)_auto\] {
703
735
  grid-template-columns: minmax(0,1fr) auto;
704
736
  }
737
+ .\[grid-template-columns\:minmax\(90px\,1fr\)_70px_52px_64px_minmax\(70px\,\.9fr\)_minmax\(70px\,\.9fr\)_minmax\(90px\,1\.1fr\)\] {
738
+ grid-template-columns: minmax(90px,1fr) 70px 52px 64px minmax(70px,.9fr) minmax(70px,.9fr) minmax(90px,1.1fr);
739
+ }
705
740
  .\[grid-template-columns\:minmax\(90px\,34\%\)_minmax\(0\,1fr\)\] {
706
741
  grid-template-columns: minmax(90px,34%) minmax(0,1fr);
707
742
  }
743
+ .\[grid-template-columns\:minmax\(110px\,150px\)_minmax\(0\,1fr\)\] {
744
+ grid-template-columns: minmax(110px,150px) minmax(0,1fr);
745
+ }
746
+ .\[grid-template-columns\:repeat\(96\,minmax\(0\,1fr\)\)\] {
747
+ grid-template-columns: repeat(96,minmax(0,1fr));
748
+ }
708
749
  .\[grid-template-columns\:repeat\(auto-fill\,minmax\(92px\,1fr\)\)\] {
709
750
  grid-template-columns: repeat(auto-fill,minmax(92px,1fr));
710
751
  }
752
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(150px\,1fr\)\)\] {
753
+ grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
754
+ }
711
755
  .\[grid-template-columns\:repeat\(auto-fill\,minmax\(160px\,1fr\)\)\] {
712
756
  grid-template-columns: repeat(auto-fill,minmax(160px,1fr));
713
757
  }
758
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(170px\,1fr\)\)\] {
759
+ grid-template-columns: repeat(auto-fill,minmax(170px,1fr));
760
+ }
761
+ .\[grid-template-columns\:repeat\(auto-fill\,minmax\(220px\,1fr\)\)\] {
762
+ grid-template-columns: repeat(auto-fill,minmax(220px,1fr));
763
+ }
714
764
  .\[grid-template-columns\:repeat\(auto-fit\,minmax\(96px\,1fr\)\)\] {
715
765
  grid-template-columns: repeat(auto-fit,minmax(96px,1fr));
716
766
  }
@@ -750,6 +800,9 @@
750
800
  .items-center {
751
801
  align-items: center;
752
802
  }
803
+ .items-end {
804
+ align-items: flex-end;
805
+ }
753
806
  .items-start {
754
807
  align-items: flex-start;
755
808
  }
@@ -828,6 +881,9 @@
828
881
  .gap-y-3 {
829
882
  row-gap: calc(var(--spacing) * 3);
830
883
  }
884
+ .self-start {
885
+ align-self: flex-start;
886
+ }
831
887
  .truncate {
832
888
  overflow: hidden;
833
889
  text-overflow: ellipsis;
@@ -851,6 +907,9 @@
851
907
  .rounded {
852
908
  border-radius: 0.25rem;
853
909
  }
910
+ .rounded-\[1px\] {
911
+ border-radius: 1px;
912
+ }
854
913
  .rounded-\[3px\] {
855
914
  border-radius: 3px;
856
915
  }
@@ -973,6 +1032,12 @@
973
1032
  .bg-jade-soft {
974
1033
  background-color: var(--jade-soft);
975
1034
  }
1035
+ .bg-jade\/70 {
1036
+ background-color: var(--jade);
1037
+ @supports (color: color-mix(in lab, red, red)) {
1038
+ background-color: color-mix(in oklab, var(--jade) 70%, transparent);
1039
+ }
1040
+ }
976
1041
  .bg-orbit {
977
1042
  background-color: var(--orbit);
978
1043
  }
@@ -997,9 +1062,15 @@
997
1062
  .bg-\[image\:var\(--caret\)\] {
998
1063
  background-image: var(--caret);
999
1064
  }
1065
+ .bg-\[repeating-conic-gradient\(\#0000_0\%_25\%\,var\(--deck-2\)_0\%_50\%\)\] {
1066
+ background-image: repeating-conic-gradient(#0000 0% 25%,var(--deck-2) 0% 50%);
1067
+ }
1000
1068
  .bg-\[length\:14px\] {
1001
1069
  background-size: 14px;
1002
1070
  }
1071
+ .bg-\[length\:16px_16px\] {
1072
+ background-size: 16px 16px;
1073
+ }
1003
1074
  .bg-\[position\:right_12px_center\] {
1004
1075
  background-position: right 12px center;
1005
1076
  }
@@ -1009,6 +1080,9 @@
1009
1080
  .object-contain {
1010
1081
  object-fit: contain;
1011
1082
  }
1083
+ .object-cover {
1084
+ object-fit: cover;
1085
+ }
1012
1086
  .p-0 {
1013
1087
  padding: 0px;
1014
1088
  }
@@ -1027,6 +1101,9 @@
1027
1101
  .p-2\.5 {
1028
1102
  padding: calc(var(--spacing) * 2.5);
1029
1103
  }
1104
+ .p-3 {
1105
+ padding: calc(var(--spacing) * 3);
1106
+ }
1030
1107
  .p-3\.5 {
1031
1108
  padding: calc(var(--spacing) * 3.5);
1032
1109
  }
@@ -1060,6 +1137,9 @@
1060
1137
  .px-1 {
1061
1138
  padding-inline: var(--spacing);
1062
1139
  }
1140
+ .px-1\.5 {
1141
+ padding-inline: calc(var(--spacing) * 1.5);
1142
+ }
1063
1143
  .px-2 {
1064
1144
  padding-inline: calc(var(--spacing) * 2);
1065
1145
  }
@@ -1096,9 +1176,15 @@
1096
1176
  .px-\[var\(--margin\)\] {
1097
1177
  padding-inline: var(--margin);
1098
1178
  }
1179
+ .py-0\.5 {
1180
+ padding-block: calc(var(--spacing) * 0.5);
1181
+ }
1099
1182
  .py-1 {
1100
1183
  padding-block: var(--spacing);
1101
1184
  }
1185
+ .py-1\.5 {
1186
+ padding-block: calc(var(--spacing) * 1.5);
1187
+ }
1102
1188
  .py-2 {
1103
1189
  padding-block: calc(var(--spacing) * 2);
1104
1190
  }
@@ -1244,10 +1330,6 @@
1244
1330
  font-size: var(--text-base);
1245
1331
  line-height: var(--tw-leading, var(--text-base--line-height));
1246
1332
  }
1247
- .text-display {
1248
- font-size: var(--text-display);
1249
- line-height: var(--tw-leading, var(--text-display--line-height));
1250
- }
1251
1333
  .text-hero {
1252
1334
  font-size: var(--text-hero);
1253
1335
  line-height: var(--tw-leading, var(--text-hero--line-height));
@@ -1516,6 +1598,11 @@
1516
1598
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
1517
1599
  transition-duration: var(--tw-duration, var(--default-transition-duration));
1518
1600
  }
1601
+ .transition-\[width\] {
1602
+ transition-property: width;
1603
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
1604
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
1605
+ }
1519
1606
  .transition-colors {
1520
1607
  transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
1521
1608
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1704,9 +1791,15 @@
1704
1791
  .disabled\:cursor-not-allowed:disabled {
1705
1792
  cursor: not-allowed;
1706
1793
  }
1794
+ .disabled\:opacity-40:disabled {
1795
+ opacity: 40%;
1796
+ }
1707
1797
  .disabled\:opacity-45:disabled {
1708
1798
  opacity: 45%;
1709
1799
  }
1800
+ .disabled\:opacity-50:disabled {
1801
+ opacity: 50%;
1802
+ }
1710
1803
  .has-checked\:border-jade:has(:checked) {
1711
1804
  border-color: var(--jade);
1712
1805
  }
@@ -1795,9 +1888,6 @@
1795
1888
  .data-\[state\=open\]\:opacity-100[data-state="open"] {
1796
1889
  opacity: 100%;
1797
1890
  }
1798
- .data-\[toss\]\:animate-toss[data-toss] {
1799
- animation: var(--animate-toss);
1800
- }
1801
1891
  @media (width < 60rem) {
1802
1892
  .max-\[60rem\]\:hidden {
1803
1893
  display: none;
@@ -1833,6 +1923,9 @@
1833
1923
  }
1834
1924
  }
1835
1925
  @media (width >= 40rem) {
1926
+ .sm\:col-span-2 {
1927
+ grid-column: span 2 / span 2;
1928
+ }
1836
1929
  .sm\:grid-cols-2 {
1837
1930
  grid-template-columns: repeat(2, minmax(0, 1fr));
1838
1931
  }
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "1.10.0";
753
+ export const VERSION = "2.0.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -0,0 +1,87 @@
1
+ // CSV reading and writing shared by the data tools. Quoted fields, doubled
2
+ // quotes, CRLF, and a delimiter guess, so five tools do not each carry a copy.
3
+
4
+ export function parseDelimited(text, delimiter = ",") {
5
+ const rows = [];
6
+ let row = [];
7
+ let field = "";
8
+ let quoted = false;
9
+ for (let i = 0; i < text.length; i++) {
10
+ const char = text[i];
11
+ if (quoted) {
12
+ if (char === '"') {
13
+ if (text[i + 1] === '"') {
14
+ field += '"';
15
+ i++;
16
+ } else quoted = false;
17
+ } else field += char;
18
+ } else if (char === '"') {
19
+ quoted = true;
20
+ } else if (char === delimiter) {
21
+ row.push(field);
22
+ field = "";
23
+ } else if (char === "\n") {
24
+ row.push(field);
25
+ rows.push(row);
26
+ row = [];
27
+ field = "";
28
+ } else if (char !== "\r") {
29
+ field += char;
30
+ }
31
+ }
32
+ if (field !== "" || row.length) {
33
+ row.push(field);
34
+ rows.push(row);
35
+ }
36
+ // A trailing blank line is an artefact of the file, not a row.
37
+ if (rows.length && rows[rows.length - 1].every((cell) => cell === "")) rows.pop();
38
+ return rows;
39
+ }
40
+
41
+ export function detectDelimiter(text) {
42
+ const candidates = [",", ";", "\t", "|"];
43
+ const first = text.split(/\r?\n/)[0] ?? "";
44
+ const counts = { ",": 0, ";": 0, "\t": 0, "|": 0 };
45
+ let quoted = false;
46
+ for (let i = 0; i < first.length; i++) {
47
+ const char = first[i];
48
+ if (char === '"') quoted = !quoted;
49
+ else if (!quoted && char in counts) counts[char]++;
50
+ }
51
+ let best = ",";
52
+ let bestCount = 0;
53
+ for (const candidate of candidates) {
54
+ if (counts[candidate] > bestCount) {
55
+ bestCount = counts[candidate];
56
+ best = candidate;
57
+ }
58
+ }
59
+ return best;
60
+ }
61
+
62
+ const needsQuote = (value) => /[",\n\r]/.test(value) || /^\s|\s$/.test(value);
63
+
64
+ /** Rows of strings to CSV text. Every field that needs quoting gets it. */
65
+ export function toCsv(rows) {
66
+ return rows
67
+ .map((row) =>
68
+ row
69
+ .map((cell) => {
70
+ const text = cell === null || cell === undefined ? "" : String(cell);
71
+ return needsQuote(text) ? `"${text.replace(/"/g, '""')}"` : text;
72
+ })
73
+ .join(",")
74
+ )
75
+ .join("\n");
76
+ }
77
+
78
+ /** The header row, with duplicates and blanks made usable as keys. */
79
+ export function headerKeys(header) {
80
+ const seen = new Map();
81
+ return header.map((cell, index) => {
82
+ const base = (cell || `column_${index + 1}`).trim() || `column_${index + 1}`;
83
+ const count = seen.get(base) ?? 0;
84
+ seen.set(base, count + 1);
85
+ return count ? `${base}_${count + 1}` : base;
86
+ });
87
+ }
@@ -0,0 +1,125 @@
1
+ // File plumbing shared by the converter tools: a drop target, a size label, a
2
+ // name without its extension, and an image decoder. Kept here so ten tools do
3
+ // not each grow their own copy.
4
+ import { el, icon } from "./core.js";
5
+
6
+ export function formatBytes(bytes) {
7
+ if (!bytes) return "0 B";
8
+ if (bytes < 1024) return `${bytes} B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
10
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
11
+ }
12
+
13
+ export function baseName(name) {
14
+ return (name || "file").replace(/\.[^.]+$/, "") || "file";
15
+ }
16
+
17
+ export function extension(name) {
18
+ const match = /\.([^.]+)$/.exec(name || "");
19
+ return match ? match[1].toLowerCase() : "";
20
+ }
21
+
22
+ /** A dashed drop zone. Calls `onFiles` with a FileList or an array of files. */
23
+ export function dropzone({ label, hint = "or choose a file", accept = "", multiple = false, iconName = "image-up" }) {
24
+ const picker = el("input", { type: "file", accept, multiple, class: "hidden" });
25
+ const node = el(
26
+ "div",
27
+ {
28
+ class:
29
+ "grid cursor-pointer justify-items-center gap-1.5 rounded-r2 border border-dashed border-orbit-hard bg-deck-2 px-4 py-7 text-center transition-[border-color,background-color] duration-150 hover:border-jade focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-jade data-[drag]:border-jade data-[drag]:bg-jade-soft",
30
+ role: "button",
31
+ tabindex: "0",
32
+ "aria-label": label,
33
+ },
34
+ icon(iconName, "size-6 text-ink-3"),
35
+ el("p", { class: "text-sm font-semibold" }, label),
36
+ el("p", { class: "text-2xs text-ink-3" }, hint),
37
+ picker
38
+ );
39
+
40
+ let handler = () => {};
41
+ let depth = 0;
42
+
43
+ node.addEventListener("click", () => picker.click());
44
+ node.addEventListener("keydown", (event) => {
45
+ if (event.key === "Enter" || event.key === " ") {
46
+ event.preventDefault();
47
+ picker.click();
48
+ }
49
+ });
50
+ picker.addEventListener("change", () => {
51
+ if (picker.files?.length) handler(picker.files);
52
+ picker.value = "";
53
+ });
54
+ node.addEventListener("dragenter", (event) => {
55
+ event.preventDefault();
56
+ depth++;
57
+ node.dataset.drag = "";
58
+ });
59
+ node.addEventListener("dragover", (event) => event.preventDefault());
60
+ node.addEventListener("dragleave", () => {
61
+ depth = Math.max(0, depth - 1);
62
+ if (!depth) delete node.dataset.drag;
63
+ });
64
+ node.addEventListener("drop", (event) => {
65
+ event.preventDefault();
66
+ depth = 0;
67
+ delete node.dataset.drag;
68
+ const files = Array.from(event.dataTransfer?.files ?? []);
69
+ if (files.length) handler(files);
70
+ });
71
+
72
+ node.onFiles = (fn) => {
73
+ handler = fn;
74
+ };
75
+ node.picker = picker;
76
+ return node;
77
+ }
78
+
79
+ /** Decodes an image file, preferring createImageBitmap and falling back to <img>. */
80
+ export async function decodeImage(file) {
81
+ if (typeof createImageBitmap === "function") {
82
+ try {
83
+ return await createImageBitmap(file, { imageOrientation: "from-image" });
84
+ } catch {
85
+ // SVG and a few odd files only decode through an <img>.
86
+ }
87
+ }
88
+ const url = URL.createObjectURL(file);
89
+ try {
90
+ const image = new Image();
91
+ image.decoding = "sync";
92
+ await new Promise((resolve, reject) => {
93
+ image.onload = resolve;
94
+ image.onerror = () => reject(new Error("This file is not an image the browser can read."));
95
+ image.src = url;
96
+ });
97
+ return image;
98
+ } finally {
99
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
100
+ }
101
+ }
102
+
103
+ export function imageSize(source) {
104
+ return {
105
+ width: source.width || source.naturalWidth || 0,
106
+ height: source.height || source.naturalHeight || 0,
107
+ };
108
+ }
109
+
110
+ export function canvasToBlob(canvas, type = "image/png", quality) {
111
+ return new Promise((resolve, reject) => {
112
+ canvas.toBlob(
113
+ (blob) => (blob ? resolve(blob) : reject(new Error("The browser could not write this file."))),
114
+ type,
115
+ quality
116
+ );
117
+ });
118
+ }
119
+
120
+ /** Sorts files by name with numbers read as numbers, so page-2 comes before page-10. */
121
+ export function naturalSort(files) {
122
+ return [...files].sort((a, b) =>
123
+ a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
124
+ );
125
+ }