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,184 @@
|
|
|
1
|
+
// CSV to INSERT statements. Quoting follows the dialect, because Postgres and
|
|
2
|
+
// MySQL disagree about backslashes and about boolean literals.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, textInput, checkbox,
|
|
5
|
+
button, actions, toolcard, blank, notice, output,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
8
|
+
import { parseDelimited, detectDelimiter, headerKeys } from "../csv.js";
|
|
9
|
+
|
|
10
|
+
const DIALECTS = {
|
|
11
|
+
postgres: { label: "PostgreSQL", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "TRUE" : "FALSE") },
|
|
12
|
+
mysql: { label: "MySQL", quote: "'", escape: (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
13
|
+
sqlite: { label: "SQLite", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function mount(slot) {
|
|
17
|
+
let file = null;
|
|
18
|
+
let rows = [];
|
|
19
|
+
let header = [];
|
|
20
|
+
let headerTypes = [];
|
|
21
|
+
|
|
22
|
+
const tableName = textInput({ value: "my_table", "aria-label": "Table name" });
|
|
23
|
+
const schema = textInput({ placeholder: "public", "aria-label": "Schema" });
|
|
24
|
+
const dialect = selectInput(
|
|
25
|
+
Object.entries(DIALECTS).map(([value, config]) => ({ value, label: config.label })),
|
|
26
|
+
{ value: "postgres", "aria-label": "Dialect" }
|
|
27
|
+
);
|
|
28
|
+
const perStatement = selectInput(
|
|
29
|
+
[
|
|
30
|
+
{ value: "row", label: "One per row" },
|
|
31
|
+
{ value: "batch", label: "One multi row insert" },
|
|
32
|
+
],
|
|
33
|
+
{ value: "row", "aria-label": "Statement shape" }
|
|
34
|
+
);
|
|
35
|
+
const useNulls = checkbox("Empty cells become NULL", { checked: true });
|
|
36
|
+
const truncate = checkbox("Start with TRUNCATE", { checked: false });
|
|
37
|
+
|
|
38
|
+
const error = notice("warn");
|
|
39
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
40
|
+
const result = output({ rows: 14 });
|
|
41
|
+
const empty = blank("database", "Drop a CSV to build the statements.");
|
|
42
|
+
|
|
43
|
+
const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "database" });
|
|
44
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
45
|
+
const copyButton = button("Copy SQL", {
|
|
46
|
+
icon: "copy",
|
|
47
|
+
onclick: async () => {
|
|
48
|
+
if (!result.textContent) return;
|
|
49
|
+
try {
|
|
50
|
+
await navigator.clipboard.writeText(result.textContent);
|
|
51
|
+
toast("SQL copied");
|
|
52
|
+
} catch {
|
|
53
|
+
error.write("The clipboard is blocked here. Download the .sql file instead.");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const downloadButton = button("Download .sql", { icon: "download", kind: "fill" });
|
|
58
|
+
|
|
59
|
+
function quote(value, config) {
|
|
60
|
+
if (useNulls.input.checked && value === "") return "NULL";
|
|
61
|
+
return `${config.quote}${config.escape(value)}${config.quote}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function build() {
|
|
65
|
+
if (!rows.length) return "";
|
|
66
|
+
const config = DIALECTS[dialect.value] ?? DIALECTS.postgres;
|
|
67
|
+
const name = tableName.value.trim() || "my_table";
|
|
68
|
+
const prefix = schema.value.trim() ? `${schema.value.trim()}.${name}` : name;
|
|
69
|
+
const columns = header.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(", ");
|
|
70
|
+
const lines = [];
|
|
71
|
+
|
|
72
|
+
if (truncate.input.checked) lines.push(`TRUNCATE TABLE ${prefix};`);
|
|
73
|
+
|
|
74
|
+
if (perStatement.value === "batch") {
|
|
75
|
+
const values = rows
|
|
76
|
+
.map((row) => ` (${row.map((cell, index) => {
|
|
77
|
+
const columnType = headerTypes[index];
|
|
78
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
79
|
+
}).join(", ")})`)
|
|
80
|
+
.join(",\n");
|
|
81
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES\n${values};`);
|
|
82
|
+
} else {
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const values = row.map((cell, index) => {
|
|
85
|
+
const columnType = headerTypes[index];
|
|
86
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
87
|
+
}).join(", ");
|
|
88
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES (${values});`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectTypes() {
|
|
95
|
+
headerTypes = header.map((_, index) => {
|
|
96
|
+
const sample = rows.slice(0, 20).map((row) => row[index] ?? "").filter((cell) => cell !== "");
|
|
97
|
+
if (sample.length && sample.every((cell) => /^(true|false|1|0|yes|no)$/i.test(cell))) return "boolean";
|
|
98
|
+
return "text";
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function render() {
|
|
103
|
+
empty.hidden = rows.length > 0;
|
|
104
|
+
result.hidden = rows.length === 0;
|
|
105
|
+
copyButton.disabled = rows.length === 0;
|
|
106
|
+
downloadButton.disabled = rows.length === 0;
|
|
107
|
+
if (!rows.length) {
|
|
108
|
+
summary.textContent = "";
|
|
109
|
+
result.write("");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
detectTypes();
|
|
113
|
+
const sql = build();
|
|
114
|
+
result.write(sql);
|
|
115
|
+
const bytes = new TextEncoder().encode(sql).length;
|
|
116
|
+
summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${formatBytes(bytes)} of SQL`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function open(files) {
|
|
120
|
+
const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
|
|
121
|
+
if (!picked) {
|
|
122
|
+
error.write("That file is not a CSV.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const text = await picked.text();
|
|
127
|
+
const delimiter = detectDelimiter(text);
|
|
128
|
+
const parsed = parseDelimited(text, delimiter);
|
|
129
|
+
if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
|
|
130
|
+
file = picked;
|
|
131
|
+
header = headerKeys(parsed[0]);
|
|
132
|
+
rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
|
|
133
|
+
error.write("");
|
|
134
|
+
render();
|
|
135
|
+
} catch (caught) {
|
|
136
|
+
error.write(caught?.message || "This file could not be read.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
drop.onFiles(open);
|
|
141
|
+
tableName.addEventListener("input", render);
|
|
142
|
+
schema.addEventListener("input", render);
|
|
143
|
+
dialect.addEventListener("change", render);
|
|
144
|
+
perStatement.addEventListener("change", render);
|
|
145
|
+
useNulls.input.addEventListener("change", render);
|
|
146
|
+
truncate.input.addEventListener("change", render);
|
|
147
|
+
|
|
148
|
+
downloadButton.addEventListener("click", () => {
|
|
149
|
+
if (!rows.length) return;
|
|
150
|
+
download(`${baseName(file?.name || "data")}.sql`, result.textContent, "application/sql");
|
|
151
|
+
});
|
|
152
|
+
clearButton.addEventListener("click", () => {
|
|
153
|
+
file = null;
|
|
154
|
+
rows = [];
|
|
155
|
+
header = [];
|
|
156
|
+
error.write("");
|
|
157
|
+
render();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render();
|
|
161
|
+
|
|
162
|
+
const shell = toolcard({
|
|
163
|
+
body: bench(
|
|
164
|
+
stack(
|
|
165
|
+
drop,
|
|
166
|
+
panel("Table", tableName),
|
|
167
|
+
panel("Schema", schema, "Optional. Leave empty for the default."),
|
|
168
|
+
panel("Dialect", dialect),
|
|
169
|
+
panel("Statement shape", perStatement),
|
|
170
|
+
useNulls,
|
|
171
|
+
truncate,
|
|
172
|
+
error
|
|
173
|
+
),
|
|
174
|
+
stack(summary, empty, result)
|
|
175
|
+
),
|
|
176
|
+
foot: actions(clearButton, copyButton, downloadButton),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
slot.append(shell.node);
|
|
180
|
+
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default { mount };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// An SVG rendered at whatever pixel size you ask for. The vector is drawn at
|
|
2
|
+
// that size, so the PNG is sharp instead of an upscaled bitmap.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, numberInput, colorInput, checkbox,
|
|
5
|
+
button, actions, toolcard, blank, notice, chipRow,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, decodeImage, canvasToBlob, baseName, formatBytes } from "../files.js";
|
|
8
|
+
|
|
9
|
+
const PRESETS = [
|
|
10
|
+
{ value: "512", label: "512" },
|
|
11
|
+
{ value: "1024", label: "1024" },
|
|
12
|
+
{ value: "2048", label: "2048" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function mount(slot) {
|
|
16
|
+
let file = null;
|
|
17
|
+
let source = null;
|
|
18
|
+
let previewUrl = "";
|
|
19
|
+
let busy = false;
|
|
20
|
+
let token = 0;
|
|
21
|
+
let timer = null;
|
|
22
|
+
|
|
23
|
+
const width = numberInput({ min: "8", max: "8192", step: "1", value: "512", "aria-label": "Width in pixels" });
|
|
24
|
+
const height = numberInput({ min: "8", max: "8192", step: "1", value: "512", "aria-label": "Height in pixels" });
|
|
25
|
+
const lock = checkbox("Keep proportions", { checked: true });
|
|
26
|
+
const color = colorInput({ value: "#ffffff", "aria-label": "Background color" });
|
|
27
|
+
const transparent = checkbox("Transparent background", { checked: true });
|
|
28
|
+
const scale = chipRow(PRESETS.map((preset) => ({ value: preset.value, label: `${preset.label} px` })), {
|
|
29
|
+
value: "512",
|
|
30
|
+
label: "Preset size",
|
|
31
|
+
onChange: (value) => {
|
|
32
|
+
width.value = value;
|
|
33
|
+
height.value = value;
|
|
34
|
+
schedule();
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const error = notice("warn");
|
|
39
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
40
|
+
const preview = el("img", { alt: "", class: "max-h-[380px] w-auto max-w-full rounded-r1 border border-orbit bg-[repeating-conic-gradient(#0000_0%_25%,var(--deck-2)_0%_50%)] bg-[length:16px_16px] object-contain" });
|
|
41
|
+
const empty = blank("image-up", "Open an SVG to render it.");
|
|
42
|
+
const previewBox = el("div", { class: "grid place-items-center rounded-r2 border border-orbit bg-deck-2 p-4" }, preview);
|
|
43
|
+
|
|
44
|
+
const drop = dropzone({ label: "Drop an SVG here", accept: "image/svg+xml,.svg", iconName: "image-up" });
|
|
45
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
46
|
+
const saveButton = button("Download PNG", { icon: "download", kind: "fill" });
|
|
47
|
+
|
|
48
|
+
function sync() {
|
|
49
|
+
color.disabled = transparent.input.checked;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function schedule() {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
timer = setTimeout(render, 200);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function reset() {
|
|
58
|
+
token++;
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
61
|
+
previewUrl = "";
|
|
62
|
+
file = null;
|
|
63
|
+
source = null;
|
|
64
|
+
preview.removeAttribute("src");
|
|
65
|
+
summary.textContent = "";
|
|
66
|
+
error.write("");
|
|
67
|
+
empty.hidden = false;
|
|
68
|
+
previewBox.hidden = true;
|
|
69
|
+
saveButton.disabled = true;
|
|
70
|
+
clearButton.disabled = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function open(files) {
|
|
74
|
+
const picked = Array.from(files).find((entry) => entry.type === "image/svg+xml" || /\.svg$/i.test(entry.name));
|
|
75
|
+
if (!picked) {
|
|
76
|
+
error.write("That file is not an SVG.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
reset();
|
|
80
|
+
const current = ++token;
|
|
81
|
+
try {
|
|
82
|
+
const bitmap = await decodeImage(picked);
|
|
83
|
+
if (current !== token) return;
|
|
84
|
+
source = bitmap;
|
|
85
|
+
file = picked;
|
|
86
|
+
const natural = Math.round(bitmap.width || bitmap.naturalWidth || 512);
|
|
87
|
+
width.value = String(Math.min(4096, natural));
|
|
88
|
+
height.value = String(Math.min(4096, Math.round(bitmap.height || bitmap.naturalHeight || natural)));
|
|
89
|
+
empty.hidden = true;
|
|
90
|
+
previewBox.hidden = false;
|
|
91
|
+
saveButton.disabled = false;
|
|
92
|
+
clearButton.disabled = false;
|
|
93
|
+
error.write("");
|
|
94
|
+
await render();
|
|
95
|
+
} catch (caught) {
|
|
96
|
+
if (current !== token) return;
|
|
97
|
+
error.write(caught?.message || "This SVG could not be read.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function render() {
|
|
102
|
+
if (!source) return;
|
|
103
|
+
const current = token;
|
|
104
|
+
const w = Math.max(8, Math.min(8192, Number(width.value) || 512));
|
|
105
|
+
const h = Math.max(8, Math.min(8192, Number(height.value) || 512));
|
|
106
|
+
try {
|
|
107
|
+
const canvas = document.createElement("canvas");
|
|
108
|
+
canvas.width = w;
|
|
109
|
+
canvas.height = h;
|
|
110
|
+
const context = canvas.getContext("2d", { alpha: true });
|
|
111
|
+
if (!transparent.input.checked) {
|
|
112
|
+
context.fillStyle = color.value;
|
|
113
|
+
context.fillRect(0, 0, w, h);
|
|
114
|
+
}
|
|
115
|
+
context.imageSmoothingEnabled = true;
|
|
116
|
+
context.imageSmoothingQuality = "high";
|
|
117
|
+
context.drawImage(source, 0, 0, w, h);
|
|
118
|
+
if (current !== token) return;
|
|
119
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
120
|
+
previewUrl = URL.createObjectURL(await canvasToBlob(canvas, "image/png"));
|
|
121
|
+
preview.src = previewUrl;
|
|
122
|
+
summary.textContent = `${w} x ${h} px · ${file.name}`;
|
|
123
|
+
} catch (caught) {
|
|
124
|
+
error.write(caught?.message || "Could not render this SVG.");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function save() {
|
|
129
|
+
if (!source || busy) return;
|
|
130
|
+
busy = true;
|
|
131
|
+
saveButton.disabled = true;
|
|
132
|
+
try {
|
|
133
|
+
const w = Math.max(8, Math.min(8192, Number(width.value) || 512));
|
|
134
|
+
const h = Math.max(8, Math.min(8192, Number(height.value) || 512));
|
|
135
|
+
const canvas = document.createElement("canvas");
|
|
136
|
+
canvas.width = w;
|
|
137
|
+
canvas.height = h;
|
|
138
|
+
const context = canvas.getContext("2d", { alpha: true });
|
|
139
|
+
if (!transparent.input.checked) {
|
|
140
|
+
context.fillStyle = color.value;
|
|
141
|
+
context.fillRect(0, 0, w, h);
|
|
142
|
+
}
|
|
143
|
+
context.imageSmoothingEnabled = true;
|
|
144
|
+
context.imageSmoothingQuality = "high";
|
|
145
|
+
context.drawImage(source, 0, 0, w, h);
|
|
146
|
+
const blob = await canvasToBlob(canvas, "image/png");
|
|
147
|
+
download(`${baseName(file.name)}-${w}x${h}.png`, blob);
|
|
148
|
+
summary.textContent = `${w} x ${h} px · ${formatBytes(blob.size)}`;
|
|
149
|
+
toast("PNG ready");
|
|
150
|
+
} catch (caught) {
|
|
151
|
+
error.write(caught?.message || "Could not write the PNG.");
|
|
152
|
+
} finally {
|
|
153
|
+
busy = false;
|
|
154
|
+
saveButton.disabled = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
drop.onFiles(open);
|
|
159
|
+
width.addEventListener("input", () => {
|
|
160
|
+
if (lock.input.checked) height.value = width.value;
|
|
161
|
+
schedule();
|
|
162
|
+
});
|
|
163
|
+
height.addEventListener("input", () => {
|
|
164
|
+
if (lock.input.checked) width.value = height.value;
|
|
165
|
+
schedule();
|
|
166
|
+
});
|
|
167
|
+
transparent.input.addEventListener("change", () => {
|
|
168
|
+
sync();
|
|
169
|
+
schedule();
|
|
170
|
+
});
|
|
171
|
+
color.addEventListener("input", schedule);
|
|
172
|
+
saveButton.addEventListener("click", save);
|
|
173
|
+
clearButton.addEventListener("click", reset);
|
|
174
|
+
sync();
|
|
175
|
+
reset();
|
|
176
|
+
|
|
177
|
+
const shell = toolcard({
|
|
178
|
+
body: bench(
|
|
179
|
+
stack(
|
|
180
|
+
drop,
|
|
181
|
+
panel("Preset", scale),
|
|
182
|
+
el("div", { class: "grid grid-cols-2 gap-3" },
|
|
183
|
+
panel("Width", width),
|
|
184
|
+
panel("Height", height)),
|
|
185
|
+
lock,
|
|
186
|
+
el("div", { class: "grid gap-1.5" },
|
|
187
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Background"),
|
|
188
|
+
el("div", { class: "flex flex-wrap items-center gap-2" }, color, transparent)),
|
|
189
|
+
error
|
|
190
|
+
),
|
|
191
|
+
stack(summary, empty, previewBox)
|
|
192
|
+
),
|
|
193
|
+
foot: actions(clearButton, saveButton),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
slot.append(shell.node);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
unmount() {
|
|
200
|
+
token++;
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default { mount };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Split a list into balanced groups. Two modes: a fixed number of groups, or a
|
|
2
|
+
// fixed size per group. Exclusion pairs keep two people apart, and leads are
|
|
3
|
+
// spread one per group.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, download, bench, stack, panel, textArea, numberInput, checkbox,
|
|
6
|
+
button, actions, toolcard, blank, notice, chipRow, copyButton,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
function shuffle(list) {
|
|
10
|
+
const out = [...list];
|
|
11
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
12
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
13
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseNames(text) {
|
|
19
|
+
return text
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((line) => line.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePairs(text) {
|
|
26
|
+
return text
|
|
27
|
+
.split(/\r?\n/)
|
|
28
|
+
.map((line) => line.split(/\s*(?:\||,|;|\+)\s*/).map((part) => part.trim()).filter(Boolean))
|
|
29
|
+
.filter((pair) => pair.length === 2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function violates(groups, pairs) {
|
|
33
|
+
const where = new Map();
|
|
34
|
+
groups.forEach((group, index) => group.forEach((name) => where.set(name, index)));
|
|
35
|
+
return pairs.some(([a, b]) => where.has(a) && where.has(b) && where.get(a) === where.get(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function build(names, mode, value, pairs, leads, attempts = 400) {
|
|
39
|
+
if (!names.length) return null;
|
|
40
|
+
let best = null;
|
|
41
|
+
for (let round = 0; round < attempts; round++) {
|
|
42
|
+
const pool = shuffle(names);
|
|
43
|
+
let groups;
|
|
44
|
+
if (mode === "groups") {
|
|
45
|
+
const count = Math.max(1, Math.min(value, names.length));
|
|
46
|
+
groups = Array.from({ length: count }, () => []);
|
|
47
|
+
pool.forEach((name, index) => groups[index % count].push(name));
|
|
48
|
+
} else {
|
|
49
|
+
const size = Math.max(1, value);
|
|
50
|
+
groups = [];
|
|
51
|
+
for (let i = 0; i < pool.length; i += size) groups.push(pool.slice(i, i + size));
|
|
52
|
+
}
|
|
53
|
+
if (pairs.length && violates(groups, pairs)) {
|
|
54
|
+
best ??= groups;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
return groups;
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function placeLeads(groups, leads) {
|
|
63
|
+
if (!leads.length) return groups;
|
|
64
|
+
const pool = [...leads];
|
|
65
|
+
// One lead per group first, then any leftover leads join the smallest group.
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
if (!pool.length) break;
|
|
68
|
+
const lead = pool.shift();
|
|
69
|
+
if (group.includes(lead)) continue;
|
|
70
|
+
const at = group.findIndex((name) => !leads.includes(name));
|
|
71
|
+
if (at >= 0) group[at] = lead;
|
|
72
|
+
else group.push(lead);
|
|
73
|
+
}
|
|
74
|
+
for (const lead of pool) {
|
|
75
|
+
const smallest = groups.reduce((a, b) => (a.length <= b.length ? a : b));
|
|
76
|
+
if (!smallest.includes(lead)) smallest.push(lead);
|
|
77
|
+
}
|
|
78
|
+
return groups;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mount(slot) {
|
|
82
|
+
const names = textArea({ rows: 8, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
|
|
83
|
+
const leads = textArea({ rows: 3, placeholder: "Ani\nBudi", "aria-label": "Leads" });
|
|
84
|
+
const exclusions = textArea({ rows: 3, placeholder: "Ani, Budi", "aria-label": "Exclusion pairs" });
|
|
85
|
+
|
|
86
|
+
let mode = "groups";
|
|
87
|
+
const modeRow = chipRow(
|
|
88
|
+
[
|
|
89
|
+
{ value: "groups", label: "Number of groups" },
|
|
90
|
+
{ value: "size", label: "Size per group" },
|
|
91
|
+
],
|
|
92
|
+
{ value: "groups", label: "Split by", onChange: (value) => { mode = value; sync(); } }
|
|
93
|
+
);
|
|
94
|
+
const amount = numberInput({ min: "1", max: "200", step: "1", value: "4", "aria-label": "Group count or size" });
|
|
95
|
+
const amountLabel = el("span", { class: "text-sm font-semibold text-ink-2" }, "Groups");
|
|
96
|
+
const keepLeads = checkbox("One lead per group", { checked: true });
|
|
97
|
+
|
|
98
|
+
const error = notice("warn");
|
|
99
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
100
|
+
const output = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]" });
|
|
101
|
+
const empty = blank("users-round", "Paste names to build the groups.");
|
|
102
|
+
|
|
103
|
+
const generate = button("Generate", { icon: "shuffle", kind: "fill" });
|
|
104
|
+
const copyAll = copyButton(() => summary.dataset.plain ?? "", "Copy groups");
|
|
105
|
+
|
|
106
|
+
let groups = [];
|
|
107
|
+
|
|
108
|
+
function sync() {
|
|
109
|
+
amountLabel.textContent = mode === "groups" ? "Groups" : "People per group";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function render() {
|
|
113
|
+
empty.hidden = groups.length > 0;
|
|
114
|
+
output.hidden = groups.length === 0;
|
|
115
|
+
copyAll.disabled = groups.length === 0;
|
|
116
|
+
output.replaceChildren(
|
|
117
|
+
...groups.map((group, index) =>
|
|
118
|
+
el(
|
|
119
|
+
"section",
|
|
120
|
+
{ class: "grid content-start gap-2 rounded-r2 border border-orbit bg-deck-2 p-3.5" },
|
|
121
|
+
el("h3", { class: "m-0 flex items-baseline justify-between gap-2 text-sm font-bold" },
|
|
122
|
+
el("span", {}, `Group ${index + 1}`),
|
|
123
|
+
el("span", { class: "text-2xs font-semibold text-ink-3 tabular-nums" }, `${group.length}`)),
|
|
124
|
+
el("ul", { class: "m-0 grid list-none gap-1 p-0" },
|
|
125
|
+
...group.map((name) =>
|
|
126
|
+
el("li", { class: "flex items-center gap-1.5 text-sm text-ink-2" },
|
|
127
|
+
el("span", { class: "size-1.5 flex-none rounded-full bg-jade" }), name)))
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function run() {
|
|
134
|
+
const list = parseNames(names.value);
|
|
135
|
+
const leadList = keepLeads.input.checked ? parseNames(leads.value).filter((name) => list.includes(name)) : [];
|
|
136
|
+
const pairs = parsePairs(exclusions.value).filter(([a, b]) => list.includes(a) && list.includes(b));
|
|
137
|
+
error.write("");
|
|
138
|
+
|
|
139
|
+
if (!list.length) {
|
|
140
|
+
groups = [];
|
|
141
|
+
summary.textContent = "";
|
|
142
|
+
render();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (list.length !== new Set(list).size) error.write("A name appears more than once, so the count is off.");
|
|
146
|
+
if (leadList.length && mode === "groups" && leadList.length > Number(amount.value)) {
|
|
147
|
+
error.write("There are more leads than groups, so some groups get two.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
groups = build(list, mode, Math.max(1, Number(amount.value) || 1), pairs, leadList) ?? [];
|
|
151
|
+
if (keepLeads.input.checked) groups = placeLeads(groups, leadList);
|
|
152
|
+
|
|
153
|
+
const sizes = groups.map((group) => group.length);
|
|
154
|
+
const spread = sizes.length ? Math.max(...sizes) - Math.min(...sizes) : 0;
|
|
155
|
+
summary.textContent = `${list.length} people · ${groups.length} group${groups.length === 1 ? "" : "s"} · ${Math.min(...sizes)} to ${Math.max(...sizes)} each${spread > 1 ? ", uneven because of the rules" : ""}`;
|
|
156
|
+
summary.dataset.plain = groups.map((group, index) => `Group ${index + 1}: ${group.join(", ")}`).join("\n");
|
|
157
|
+
render();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
generate.addEventListener("click", () => {
|
|
161
|
+
run();
|
|
162
|
+
if (groups.length) toast("Groups ready");
|
|
163
|
+
});
|
|
164
|
+
amount.addEventListener("input", run);
|
|
165
|
+
keepLeads.input.addEventListener("change", run);
|
|
166
|
+
names.addEventListener("input", run);
|
|
167
|
+
|
|
168
|
+
const downloadButton = button("Download CSV", {
|
|
169
|
+
icon: "download",
|
|
170
|
+
onclick: () => {
|
|
171
|
+
if (!groups.length) return;
|
|
172
|
+
const rows = [["group", "name"]];
|
|
173
|
+
groups.forEach((group, index) => group.forEach((name) => rows.push([String(index + 1), name])));
|
|
174
|
+
const csv = rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n");
|
|
175
|
+
download("groups.csv", csv, "text/csv");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
sync();
|
|
180
|
+
run();
|
|
181
|
+
|
|
182
|
+
const shell = toolcard({
|
|
183
|
+
body: bench(
|
|
184
|
+
stack(
|
|
185
|
+
panel("Names", names, "One per line."),
|
|
186
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Split by"), modeRow),
|
|
187
|
+
panel(amountLabel, amount),
|
|
188
|
+
panel("Leads", leads, "Optional. One per line."),
|
|
189
|
+
keepLeads,
|
|
190
|
+
panel("Keep apart", exclusions, "One pair per line, like Ani, Budi."),
|
|
191
|
+
error
|
|
192
|
+
),
|
|
193
|
+
stack(summary, empty, output)
|
|
194
|
+
),
|
|
195
|
+
foot: actions(copyAll, downloadButton, generate),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
slot.append(shell.node);
|
|
199
|
+
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default { mount };
|