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 +17 -21
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +110 -17
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/files.js +125 -0
- package/mevebu/libs/js/icons.js +2 -1
- package/mevebu/libs/js/pdf-engine.js +67 -0
- package/mevebu/libs/js/registry.js +253 -37
- package/mevebu/libs/js/tools/audio-converter.js +228 -0
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/image-to-pdf.js +251 -0
- package/mevebu/libs/js/tools/pdf-merge.js +158 -0
- package/mevebu/libs/js/tools/pdf-organizer.js +215 -0
- package/mevebu/libs/js/tools/pdf-split.js +198 -0
- package/mevebu/libs/js/tools/pdf-to-image.js +190 -0
- package/mevebu/libs/js/tools/pdf-to-text.js +172 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +252 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/svg-to-png.js +207 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/mevebu/libs/js/tools/text-to-pdf.js +182 -0
- package/mevebu/libs/js/tools/video-converter.js +316 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The text layer of a PDF, page by page. pdf.js exposes the text items with
|
|
2
|
+
// their positions, so lines are rebuilt from the vertical gap rather than from
|
|
3
|
+
// the order the items happen to arrive in.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, download, bench, stack, panel, button, actions, toolcard, blank,
|
|
6
|
+
notice, output, copyButton,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
9
|
+
import { readPdfjs, parsePageRange } from "../pdf-engine.js";
|
|
10
|
+
|
|
11
|
+
function joinItems(items) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
let current = null;
|
|
14
|
+
let lastY = null;
|
|
15
|
+
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
const text = item.str ?? "";
|
|
18
|
+
if (!text) continue;
|
|
19
|
+
const y = Math.round(item.transform?.[5] ?? 0);
|
|
20
|
+
if (lastY === null || Math.abs(y - lastY) > 2) {
|
|
21
|
+
if (current !== null) lines.push(current.trimEnd());
|
|
22
|
+
current = text;
|
|
23
|
+
lastY = y;
|
|
24
|
+
} else {
|
|
25
|
+
const needsSpace = current && !/\s$/.test(current) && !/^\s/.test(text);
|
|
26
|
+
current += (needsSpace ? " " : "") + text;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (current !== null) lines.push(current.trimEnd());
|
|
30
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mount(slot) {
|
|
34
|
+
let file = null;
|
|
35
|
+
let pdf = null;
|
|
36
|
+
let text = "";
|
|
37
|
+
let busy = false;
|
|
38
|
+
let token = 0;
|
|
39
|
+
|
|
40
|
+
const range = el("input", {
|
|
41
|
+
class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
42
|
+
type: "text",
|
|
43
|
+
placeholder: "all",
|
|
44
|
+
spellcheck: "false",
|
|
45
|
+
autocomplete: "off",
|
|
46
|
+
"aria-label": "Page range",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const error = notice("warn");
|
|
50
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
51
|
+
const result = output({ rows: 16 });
|
|
52
|
+
const empty = blank("file-text", "Open a PDF to read its text.");
|
|
53
|
+
|
|
54
|
+
const drop = dropzone({ label: "Drop a PDF here", accept: "application/pdf,.pdf", iconName: "file-text" });
|
|
55
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
56
|
+
const extractButton = button("Extract text", { icon: "text-search", kind: "fill" });
|
|
57
|
+
const copy = copyButton(() => text, "Copy text");
|
|
58
|
+
|
|
59
|
+
function reset() {
|
|
60
|
+
token++;
|
|
61
|
+
file = null;
|
|
62
|
+
pdf = null;
|
|
63
|
+
text = "";
|
|
64
|
+
result.write("");
|
|
65
|
+
summary.textContent = "";
|
|
66
|
+
error.write("");
|
|
67
|
+
empty.hidden = false;
|
|
68
|
+
extractButton.disabled = true;
|
|
69
|
+
clearButton.disabled = true;
|
|
70
|
+
copy.disabled = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function open(files) {
|
|
74
|
+
const picked = Array.from(files).find((entry) => entry.type === "application/pdf" || /\.pdf$/i.test(entry.name));
|
|
75
|
+
if (!picked) {
|
|
76
|
+
error.write("That file is not a PDF.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
reset();
|
|
80
|
+
const current = ++token;
|
|
81
|
+
summary.textContent = "Reading the PDF";
|
|
82
|
+
try {
|
|
83
|
+
const doc = await readPdfjs(picked);
|
|
84
|
+
if (current !== token) return;
|
|
85
|
+
file = picked;
|
|
86
|
+
pdf = doc;
|
|
87
|
+
summary.textContent = `${picked.name} · ${doc.numPages} page${doc.numPages === 1 ? "" : "s"} · ${formatBytes(picked.size)}`;
|
|
88
|
+
empty.hidden = true;
|
|
89
|
+
extractButton.disabled = false;
|
|
90
|
+
clearButton.disabled = false;
|
|
91
|
+
error.write("");
|
|
92
|
+
} catch (caught) {
|
|
93
|
+
if (current !== token) return;
|
|
94
|
+
summary.textContent = "";
|
|
95
|
+
error.write(caught?.message || "This PDF could not be read.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function extract() {
|
|
100
|
+
if (!pdf || busy) return;
|
|
101
|
+
busy = true;
|
|
102
|
+
const current = ++token;
|
|
103
|
+
extractButton.disabled = true;
|
|
104
|
+
summary.textContent = "Reading the text layer";
|
|
105
|
+
error.write("");
|
|
106
|
+
try {
|
|
107
|
+
const indexes = parsePageRange(range.value, pdf.numPages);
|
|
108
|
+
const blocks = [];
|
|
109
|
+
for (const [position, index] of indexes.entries()) {
|
|
110
|
+
if (current !== token) return;
|
|
111
|
+
const page = await pdf.getPage(index + 1);
|
|
112
|
+
const content = await page.getTextContent();
|
|
113
|
+
const body = joinItems(content.items);
|
|
114
|
+
blocks.push(`--- page ${index + 1} ---\n${body || "(no text on this page)"}`);
|
|
115
|
+
summary.textContent = `Page ${position + 1} of ${indexes.length}`;
|
|
116
|
+
}
|
|
117
|
+
text = blocks.join("\n\n");
|
|
118
|
+
result.write(text);
|
|
119
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
120
|
+
summary.textContent = `${indexes.length} page${indexes.length === 1 ? "" : "s"} · ${words} words`;
|
|
121
|
+
copy.disabled = false;
|
|
122
|
+
if (!words) error.write("No text layer here. This looks like a scan, so there is nothing to copy.");
|
|
123
|
+
else toast("Text ready");
|
|
124
|
+
} catch (caught) {
|
|
125
|
+
summary.textContent = "";
|
|
126
|
+
error.write(caught?.message || "Could not read the text.");
|
|
127
|
+
} finally {
|
|
128
|
+
if (current === token) {
|
|
129
|
+
busy = false;
|
|
130
|
+
extractButton.disabled = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
drop.onFiles(open);
|
|
136
|
+
extractButton.addEventListener("click", extract);
|
|
137
|
+
clearButton.addEventListener("click", reset);
|
|
138
|
+
reset();
|
|
139
|
+
|
|
140
|
+
const shell = toolcard({
|
|
141
|
+
body: bench(
|
|
142
|
+
stack(
|
|
143
|
+
drop,
|
|
144
|
+
panel("Pages", range, "1-3, 7, 10- . Empty means every page."),
|
|
145
|
+
error,
|
|
146
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
147
|
+
"A scanned page is an image, so it has no text to read. Run it through an OCR tool first.")
|
|
148
|
+
),
|
|
149
|
+
stack(summary, empty, result)
|
|
150
|
+
),
|
|
151
|
+
foot: actions(clearButton, copy,
|
|
152
|
+
el("button", {
|
|
153
|
+
class: "inline-flex min-h-[var(--ctl)] cursor-pointer items-center justify-center gap-2 rounded-r1 border border-orbit bg-deck px-4 text-sm font-semibold whitespace-nowrap text-ink transition-colors duration-150 hover:border-orbit-hard hover:bg-deck-2 disabled:opacity-50",
|
|
154
|
+
type: "button",
|
|
155
|
+
onclick: () => {
|
|
156
|
+
if (!text) return;
|
|
157
|
+
download(`${baseName(file?.name || "document")}.txt`, text, "text/plain");
|
|
158
|
+
},
|
|
159
|
+
}, "Download .txt"),
|
|
160
|
+
extractButton),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
slot.append(shell.node);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
unmount() {
|
|
167
|
+
token++;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default { mount };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// A rotating duty roster. Names take turns across a date range, with a fixed
|
|
2
|
+
// number of slots per day, an optional weekend skip, and pinned dates.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, textArea, numberInput, button,
|
|
5
|
+
actions, toolcard, blank, notice, checkbox, selectInput,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
|
|
8
|
+
function parseNames(text) {
|
|
9
|
+
return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parsePins(text) {
|
|
13
|
+
const pins = new Map();
|
|
14
|
+
for (const line of text.split(/\r?\n/)) {
|
|
15
|
+
const match = /^(\d{4}-\d{2}-\d{2})\s*[:|,]\s*(.+)$/.exec(line.trim());
|
|
16
|
+
if (match) pins.set(match[1], match[2].split(/\s*(?:,|\|)\s*/).map((name) => name.trim()).filter(Boolean));
|
|
17
|
+
}
|
|
18
|
+
return pins;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function iso(date) {
|
|
22
|
+
// Local date parts, not toISOString: a browser east or west of UTC would
|
|
23
|
+
// otherwise show yesterday or tomorrow in the date field.
|
|
24
|
+
const year = date.getFullYear();
|
|
25
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
26
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
27
|
+
return `${year}-${month}-${day}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mount(slot) {
|
|
31
|
+
const names = textArea({ rows: 7, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
|
|
32
|
+
const start = el("input", {
|
|
33
|
+
class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
34
|
+
type: "date",
|
|
35
|
+
value: iso(new Date()),
|
|
36
|
+
"aria-label": "Start date",
|
|
37
|
+
});
|
|
38
|
+
const end = el("input", {
|
|
39
|
+
class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
40
|
+
type: "date",
|
|
41
|
+
value: iso(new Date(Date.now() + 13 * 86400000)),
|
|
42
|
+
"aria-label": "End date",
|
|
43
|
+
});
|
|
44
|
+
const perDay = numberInput({ min: "1", max: "20", step: "1", value: "1", "aria-label": "People per day" });
|
|
45
|
+
const skipWeekend = checkbox("Skip weekends", { checked: true });
|
|
46
|
+
const pins = textArea({ rows: 3, placeholder: "2026-10-25: Ani", "aria-label": "Pinned dates" });
|
|
47
|
+
const order = selectInput(
|
|
48
|
+
[
|
|
49
|
+
{ value: "forward", label: "Forward" },
|
|
50
|
+
{ value: "reverse", label: "Reverse" },
|
|
51
|
+
],
|
|
52
|
+
{ value: "forward", "aria-label": "Rotation direction" }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const error = notice("warn");
|
|
56
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
57
|
+
const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
|
|
58
|
+
const empty = blank("calendar-check", "Add names and a date range.");
|
|
59
|
+
|
|
60
|
+
let rows = [];
|
|
61
|
+
|
|
62
|
+
function build() {
|
|
63
|
+
const list = parseNames(names.value);
|
|
64
|
+
const pinned = parsePins(pins.value);
|
|
65
|
+
error.write("");
|
|
66
|
+
rows = [];
|
|
67
|
+
if (!list.length) return;
|
|
68
|
+
|
|
69
|
+
const from = new Date(`${start.value}T00:00:00`);
|
|
70
|
+
const to = new Date(`${end.value}T00:00:00`);
|
|
71
|
+
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
|
72
|
+
error.write("Pick a start and an end date.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (to < from) {
|
|
76
|
+
error.write("The end date is before the start date.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const slots = Math.max(1, Math.min(Number(perDay.value) || 1, list.length));
|
|
81
|
+
const sequence = order.value === "reverse" ? [...list].reverse() : [...list];
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
let guard = 0;
|
|
84
|
+
|
|
85
|
+
for (let day = new Date(from); day <= to && guard < 400; day.setDate(day.getDate() + 1), guard++) {
|
|
86
|
+
const weekday = day.getDay();
|
|
87
|
+
if (skipWeekend.input.checked && (weekday === 0 || weekday === 6)) continue;
|
|
88
|
+
const key = iso(day);
|
|
89
|
+
if (pinned.has(key)) {
|
|
90
|
+
rows.push({ date: key, people: pinned.get(key), pinned: true });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const people = [];
|
|
94
|
+
for (let i = 0; i < slots; i++) {
|
|
95
|
+
people.push(sequence[cursor % sequence.length]);
|
|
96
|
+
cursor++;
|
|
97
|
+
}
|
|
98
|
+
rows.push({ date: key, people, pinned: false });
|
|
99
|
+
}
|
|
100
|
+
if (guard >= 400) error.write("That range is very long, so it stops at 400 days.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function render() {
|
|
104
|
+
empty.hidden = rows.length > 0;
|
|
105
|
+
table.hidden = rows.length === 0;
|
|
106
|
+
if (!rows.length) {
|
|
107
|
+
summary.textContent = "";
|
|
108
|
+
table.replaceChildren();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const counts = new Map();
|
|
112
|
+
for (const row of rows) for (const name of row.people) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
113
|
+
const spread = [...counts.values()];
|
|
114
|
+
const even = spread.length ? `${Math.min(...spread)} to ${Math.max(...spread)} turns each` : "";
|
|
115
|
+
summary.textContent = `${rows.length} day${rows.length === 1 ? "" : "s"} · ${counts.size} people · ${even}`;
|
|
116
|
+
|
|
117
|
+
const head = el("div", {
|
|
118
|
+
class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
|
|
119
|
+
}, el("span", {}, "Date"), el("span", {}, "On duty"));
|
|
120
|
+
|
|
121
|
+
const body = rows.map((row) =>
|
|
122
|
+
el("div", {
|
|
123
|
+
class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
|
|
124
|
+
},
|
|
125
|
+
el("span", { class: "font-mono text-2xs text-ink-3 tabular-nums" }, row.date),
|
|
126
|
+
el("span", { class: "wrap-anywhere" }, row.people.join(", "),
|
|
127
|
+
row.pinned ? el("span", { class: "ms-2 rounded-full bg-jade-soft px-1.5 py-0.5 text-2xs font-semibold text-jade" }, "pinned") : null))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
table.replaceChildren(head, ...body);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function run() {
|
|
134
|
+
build();
|
|
135
|
+
render();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
names.addEventListener("input", run);
|
|
139
|
+
start.addEventListener("change", run);
|
|
140
|
+
end.addEventListener("change", run);
|
|
141
|
+
perDay.addEventListener("input", run);
|
|
142
|
+
skipWeekend.input.addEventListener("change", run);
|
|
143
|
+
pins.addEventListener("input", run);
|
|
144
|
+
order.addEventListener("change", run);
|
|
145
|
+
|
|
146
|
+
const copyButton = button("Copy roster", {
|
|
147
|
+
icon: "copy",
|
|
148
|
+
onclick: async () => {
|
|
149
|
+
if (!rows.length) return;
|
|
150
|
+
const text = rows.map((row) => `${row.date}\t${row.people.join(", ")}`).join("\n");
|
|
151
|
+
try {
|
|
152
|
+
await navigator.clipboard.writeText(text);
|
|
153
|
+
toast("Roster copied");
|
|
154
|
+
} catch {
|
|
155
|
+
error.write("The clipboard is blocked here. Download the CSV instead.");
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const downloadButton = button("Download CSV", {
|
|
161
|
+
icon: "download",
|
|
162
|
+
kind: "fill",
|
|
163
|
+
onclick: () => {
|
|
164
|
+
if (!rows.length) return;
|
|
165
|
+
const csv = [["date", "people"], ...rows.map((row) => [row.date, row.people.join("; ")])]
|
|
166
|
+
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
|
167
|
+
.join("\n");
|
|
168
|
+
download("roster.csv", csv, "text/csv");
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
run();
|
|
173
|
+
|
|
174
|
+
const shell = toolcard({
|
|
175
|
+
body: bench(
|
|
176
|
+
stack(
|
|
177
|
+
panel("Names", names, "One per line. The rotation continues in this order."),
|
|
178
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("From", start), panel("To", end)),
|
|
179
|
+
panel("People per day", perDay),
|
|
180
|
+
panel("Direction", order),
|
|
181
|
+
skipWeekend,
|
|
182
|
+
panel("Pinned dates", pins, "One per line, like 2026-10-25: Ani."),
|
|
183
|
+
error
|
|
184
|
+
),
|
|
185
|
+
stack(summary, empty, table)
|
|
186
|
+
),
|
|
187
|
+
foot: actions(copyButton, downloadButton),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
slot.append(shell.node);
|
|
191
|
+
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default { mount };
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// A CSV with the columns and the types you describe. The same seed always
|
|
2
|
+
// produces the same file, so a fixture can be regenerated when it drifts.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, download, bench, stack, panel, textInput, selectInput,
|
|
5
|
+
numberInput, button, actions, toolcard, blank, notice,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { toCsv } from "../csv.js";
|
|
8
|
+
|
|
9
|
+
const TYPES = [
|
|
10
|
+
{ value: "text", label: "Text" },
|
|
11
|
+
{ value: "word", label: "Word" },
|
|
12
|
+
{ value: "number", label: "Number" },
|
|
13
|
+
{ value: "decimal", label: "Decimal" },
|
|
14
|
+
{ value: "boolean", label: "Boolean" },
|
|
15
|
+
{ value: "date", label: "Date" },
|
|
16
|
+
{ value: "email", label: "Email" },
|
|
17
|
+
{ value: "uuid", label: "UUID" },
|
|
18
|
+
{ value: "choice", label: "Choice list" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const WORDS = [
|
|
22
|
+
"alpha", "bravo", "cedar", "delta", "ember", "field", "grove", "harbor", "ivory", "juniper",
|
|
23
|
+
"kelp", "lumen", "meadow", "north", "orbit", "pine", "quartz", "ridge", "summit", "tide",
|
|
24
|
+
"umber", "vale", "willow", "xenon", "yarrow", "zephyr",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Mulberry32: a small deterministic generator, so a seed is worth writing down. */
|
|
28
|
+
function seeded(seed) {
|
|
29
|
+
let state = seed >>> 0;
|
|
30
|
+
return () => {
|
|
31
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
32
|
+
let t = state;
|
|
33
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
34
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
35
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pick(random, list) {
|
|
40
|
+
return list[Math.floor(random() * list.length)];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function uuid(random) {
|
|
44
|
+
const hex = "0123456789abcdef";
|
|
45
|
+
let out = "";
|
|
46
|
+
for (let i = 0; i < 32; i++) {
|
|
47
|
+
if (i === 8 || i === 12 || i === 16 || i === 20) out += "-";
|
|
48
|
+
out += i === 12 ? "4" : hex[Math.floor(random() * 16)];
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function value(type, choices, random, rowIndex) {
|
|
54
|
+
switch (type) {
|
|
55
|
+
case "word":
|
|
56
|
+
return pick(random, WORDS);
|
|
57
|
+
case "number":
|
|
58
|
+
return String(Math.floor(random() * 1000));
|
|
59
|
+
case "decimal":
|
|
60
|
+
return (random() * 100).toFixed(2);
|
|
61
|
+
case "boolean":
|
|
62
|
+
return random() > 0.5 ? "true" : "false";
|
|
63
|
+
case "date": {
|
|
64
|
+
const day = new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 700) * 86400000);
|
|
65
|
+
return day.toISOString().slice(0, 10);
|
|
66
|
+
}
|
|
67
|
+
case "email":
|
|
68
|
+
return `${pick(random, WORDS)}${rowIndex + 1}@example.com`;
|
|
69
|
+
case "uuid":
|
|
70
|
+
return uuid(random);
|
|
71
|
+
case "choice":
|
|
72
|
+
return choices.length ? pick(random, choices) : "";
|
|
73
|
+
default:
|
|
74
|
+
return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mount(slot) {
|
|
79
|
+
let columns = [
|
|
80
|
+
{ name: "id", type: "number", choices: "" },
|
|
81
|
+
{ name: "name", type: "word", choices: "" },
|
|
82
|
+
{ name: "active", type: "boolean", choices: "" },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const rows = numberInput({ min: "1", max: "10000", step: "1", value: "50", "aria-label": "Row count" });
|
|
86
|
+
const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
|
|
87
|
+
const error = notice("warn");
|
|
88
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
89
|
+
const list = el("div", { class: "grid gap-2" });
|
|
90
|
+
const preview = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
|
|
91
|
+
const empty = blank("table", "Add a column to see the preview.");
|
|
92
|
+
|
|
93
|
+
const addButton = button("Add column", { icon: "plus", kind: "quiet" });
|
|
94
|
+
const generate = button("Generate", { icon: "refresh-cw", kind: "fill" });
|
|
95
|
+
const downloadButton = button("Download CSV", { icon: "download" });
|
|
96
|
+
|
|
97
|
+
let table = [];
|
|
98
|
+
let header = [];
|
|
99
|
+
|
|
100
|
+
function renderColumns() {
|
|
101
|
+
list.replaceChildren(
|
|
102
|
+
...columns.map((column, index) =>
|
|
103
|
+
el(
|
|
104
|
+
"div",
|
|
105
|
+
{ class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:minmax(0,1fr)_auto]" },
|
|
106
|
+
el("div", { class: "grid gap-2 sm:grid-cols-2" },
|
|
107
|
+
textInput({
|
|
108
|
+
value: column.name,
|
|
109
|
+
placeholder: `column_${index + 1}`,
|
|
110
|
+
"aria-label": `Column ${index + 1} name`,
|
|
111
|
+
oninput: (event) => {
|
|
112
|
+
column.name = event.target.value;
|
|
113
|
+
run();
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
selectInput(
|
|
117
|
+
TYPES.map((type) => ({ value: type.value, label: type.label })),
|
|
118
|
+
{
|
|
119
|
+
value: column.type,
|
|
120
|
+
"aria-label": `Column ${index + 1} type`,
|
|
121
|
+
onchange: (event) => {
|
|
122
|
+
column.type = event.target.value;
|
|
123
|
+
renderColumns();
|
|
124
|
+
run();
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
column.type === "choice"
|
|
129
|
+
? textInput({
|
|
130
|
+
value: column.choices,
|
|
131
|
+
placeholder: "red, green, blue",
|
|
132
|
+
"aria-label": `Column ${index + 1} choices`,
|
|
133
|
+
class: "sm:col-span-2",
|
|
134
|
+
oninput: (event) => {
|
|
135
|
+
column.choices = event.target.value;
|
|
136
|
+
run();
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
: null),
|
|
140
|
+
el("button", {
|
|
141
|
+
class: "inline-flex size-9 cursor-pointer items-center justify-center self-start rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
|
|
142
|
+
type: "button",
|
|
143
|
+
"aria-label": `Remove column ${index + 1}`,
|
|
144
|
+
disabled: columns.length === 1,
|
|
145
|
+
onclick: () => {
|
|
146
|
+
columns.splice(index, 1);
|
|
147
|
+
renderColumns();
|
|
148
|
+
run();
|
|
149
|
+
},
|
|
150
|
+
}, icon("trash", "size-4"))
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hashSeed(text) {
|
|
157
|
+
let hash = 2166136261;
|
|
158
|
+
for (let i = 0; i < text.length; i++) {
|
|
159
|
+
hash ^= text.charCodeAt(i);
|
|
160
|
+
hash = Math.imul(hash, 16777619);
|
|
161
|
+
}
|
|
162
|
+
return hash >>> 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderPreview() {
|
|
166
|
+
empty.hidden = table.length > 0;
|
|
167
|
+
preview.hidden = table.length === 0;
|
|
168
|
+
if (!table.length) {
|
|
169
|
+
preview.replaceChildren();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const shown = table.slice(0, 8);
|
|
173
|
+
const head = el("div", {
|
|
174
|
+
class: "grid border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase",
|
|
175
|
+
style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
|
|
176
|
+
}, ...header.map((cell) => el("span", { class: "truncate" }, cell)));
|
|
177
|
+
|
|
178
|
+
const body = shown.map((row) =>
|
|
179
|
+
el("div", {
|
|
180
|
+
class: "grid border-b border-orbit px-3 py-1.5 text-sm last:border-b-0",
|
|
181
|
+
style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
|
|
182
|
+
}, ...row.map((cell) => el("span", { class: "truncate font-mono text-2xs text-ink-2" }, cell)))
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
preview.replaceChildren(head, ...body);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function run() {
|
|
189
|
+
error.write("");
|
|
190
|
+
const count = Math.max(1, Math.min(10000, Number(rows.value) || 1));
|
|
191
|
+
const names = columns.map((column, index) => column.name.trim() || `column_${index + 1}`);
|
|
192
|
+
if (new Set(names).size !== names.length) error.write("Two columns share a name, so the CSV will have duplicates.");
|
|
193
|
+
|
|
194
|
+
const random = seeded(hashSeed(seed.value || "mevebu"));
|
|
195
|
+
header = names;
|
|
196
|
+
table = Array.from({ length: count }, (_, rowIndex) =>
|
|
197
|
+
columns.map((column) =>
|
|
198
|
+
value(
|
|
199
|
+
column.type,
|
|
200
|
+
column.choices.split(",").map((choice) => choice.trim()).filter(Boolean),
|
|
201
|
+
random,
|
|
202
|
+
rowIndex
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
summary.textContent = `${count} row${count === 1 ? "" : "s"} · ${columns.length} column${columns.length === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
|
|
208
|
+
renderPreview();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
addButton.addEventListener("click", () => {
|
|
212
|
+
columns.push({ name: `column_${columns.length + 1}`, type: "text", choices: "" });
|
|
213
|
+
renderColumns();
|
|
214
|
+
run();
|
|
215
|
+
});
|
|
216
|
+
rows.addEventListener("input", run);
|
|
217
|
+
seed.addEventListener("input", run);
|
|
218
|
+
generate.addEventListener("click", () => {
|
|
219
|
+
run();
|
|
220
|
+
toast("Sample rebuilt");
|
|
221
|
+
});
|
|
222
|
+
downloadButton.addEventListener("click", () => {
|
|
223
|
+
if (!table.length) return;
|
|
224
|
+
download("sample.csv", toCsv([header, ...table]), "text/csv");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
renderColumns();
|
|
228
|
+
run();
|
|
229
|
+
|
|
230
|
+
const shell = toolcard({
|
|
231
|
+
body: bench(
|
|
232
|
+
stack(
|
|
233
|
+
panel("Rows", rows),
|
|
234
|
+
panel("Seed", seed, "The same seed always makes the same file."),
|
|
235
|
+
el("div", { class: "grid gap-1.5" },
|
|
236
|
+
el("div", { class: "flex items-center justify-between gap-2" },
|
|
237
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Columns"),
|
|
238
|
+
addButton),
|
|
239
|
+
list),
|
|
240
|
+
error
|
|
241
|
+
),
|
|
242
|
+
stack(summary, empty, preview)
|
|
243
|
+
),
|
|
244
|
+
foot: actions(generate, downloadButton),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
slot.append(shell.node);
|
|
248
|
+
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default { mount };
|