@gradio/dataframe 0.24.4 → 0.24.6
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/CHANGELOG.md +25 -0
- package/Dataframe.test.ts +1202 -122
- package/Index.svelte +1 -0
- package/dist/Index.svelte +1 -0
- package/dist/shared/EmptyRowButton.svelte +0 -1
- package/dist/shared/Table.svelte +258 -51
- package/dist/shared/Table.svelte.d.ts +1 -0
- package/dist/shared/Toolbar.svelte +2 -3
- package/dist/shared/Toolbar.svelte.d.ts +1 -1
- package/dist/shared/types.d.ts +0 -4
- package/dist/shared/types.js +0 -1
- package/dist/shared/utils/selection_utils.d.ts +1 -1
- package/dist/shared/utils/selection_utils.js +6 -3
- package/dist/shared/utils/table_utils.d.ts +6 -5
- package/dist/shared/utils/table_utils.js +45 -40
- package/dist/shared/utils/utils.d.ts +0 -4
- package/dist/standalone/default_i18n.js +7 -0
- package/dist/types.d.ts +1 -0
- package/package.json +7 -7
- package/shared/EmptyRowButton.svelte +0 -1
- package/shared/Table.svelte +258 -51
- package/shared/Toolbar.svelte +2 -3
- package/shared/types.ts +0 -6
- package/shared/utils/selection_utils.ts +8 -3
- package/shared/utils/table_utils.ts +49 -52
- package/shared/utils/utils.ts +0 -1
- package/standalone/default_i18n.ts +12 -0
- package/test/selection_utils.test.ts +40 -7
- package/test/table_utils.test.ts +92 -28
- package/types.ts +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CellValue, Headers,
|
|
1
|
+
import type { CellValue, Headers, TableData } from "../types";
|
|
2
2
|
import { dsvFormat } from "d3-dsv";
|
|
3
3
|
|
|
4
4
|
export function make_cell_id(row: number, col: number): string {
|
|
@@ -11,18 +11,17 @@ export function make_header_id(col: number): string {
|
|
|
11
11
|
|
|
12
12
|
export async function copy_table_data(
|
|
13
13
|
data: TableData,
|
|
14
|
-
selected_cells: [number, number][]
|
|
14
|
+
selected_cells: [number, number][]
|
|
15
15
|
): Promise<void> {
|
|
16
16
|
if (!data || !data.length) return;
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
selected_cells ||
|
|
20
|
-
data.flatMap((row, r) => row.map((_, c) => [r, c] as [number, number]));
|
|
21
|
-
|
|
22
|
-
const csv = cells_to_copy.reduce(
|
|
18
|
+
const csv = selected_cells.reduce(
|
|
23
19
|
(acc: { [key: string]: { [key: string]: string } }, [row, col]) => {
|
|
24
20
|
acc[row] = acc[row] || {};
|
|
25
|
-
|
|
21
|
+
// only the headers are padded out to the column count on the way in, so
|
|
22
|
+
// a selection can name a cell a short row does not have
|
|
23
|
+
const cell = data[row]?.[col];
|
|
24
|
+
const value = cell ? String(cell.value) : "";
|
|
26
25
|
acc[row][col] =
|
|
27
26
|
value.includes(",") || value.includes('"') || value.includes("\n")
|
|
28
27
|
? `"${value.replace(/"/g, '""')}"`
|
|
@@ -35,7 +34,11 @@ export async function copy_table_data(
|
|
|
35
34
|
const rows = Object.keys(csv).sort((a, b) => +a - +b);
|
|
36
35
|
if (!rows.length) return;
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
// every column any selected row contributes: taking them from the first row
|
|
38
|
+
// alone drops the rest of a selection that is not rectangular there
|
|
39
|
+
const cols = Array.from(
|
|
40
|
+
new Set(rows.flatMap((r) => Object.keys(csv[r])))
|
|
41
|
+
).sort((a, b) => +a - +b);
|
|
39
42
|
const text = rows
|
|
40
43
|
.map((r) => cols.map((c) => csv[r][c] || "").join(","))
|
|
41
44
|
.join("\n");
|
|
@@ -47,49 +50,43 @@ export async function copy_table_data(
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
export function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
export async function parse_table_file(
|
|
54
|
+
file: File
|
|
55
|
+
): Promise<{ headers: Headers; values: CellValue[][] }> {
|
|
56
|
+
const text = await file.text();
|
|
57
|
+
if (!text.trim()) return { headers: [], values: [] };
|
|
58
|
+
// the drop zone only accepts .csv and .tsv, so the extension goes first and
|
|
59
|
+
// wins any tie, but a mislabeled file is still read by its content: a
|
|
60
|
+
// separator only takes the file if it splits every line the same way, which
|
|
61
|
+
// is what tells a real separator from one that sits in a header name. the
|
|
62
|
+
// split is d3's rather than a raw count, so a separator inside a quoted
|
|
63
|
+
// field does not throw the detection off.
|
|
64
|
+
const by_extension = file.name.toLowerCase().endsWith(".tsv") ? "\t" : ",";
|
|
65
|
+
const candidates = by_extension === "\t" ? ["\t", ","] : [",", "\t"];
|
|
66
|
+
let consistent: string[][] | undefined;
|
|
67
|
+
let ragged: string[][] | undefined;
|
|
68
|
+
let unsplit: string[][] | undefined;
|
|
69
|
+
for (const delimiter of candidates) {
|
|
70
|
+
// a blank line parses to a single empty field, which both the width
|
|
71
|
+
// checks below and the caller's ragged check would read as a short row
|
|
72
|
+
const parsed = dsvFormat(delimiter)
|
|
73
|
+
.parseRows(text)
|
|
74
|
+
.filter((row) => row.length > 1 || row[0] !== "");
|
|
75
|
+
const width = parsed[0]?.length ?? 0;
|
|
76
|
+
// the extension is first in `candidates`, so this keeps its parse for the
|
|
77
|
+
// single-column case rather than running it again at the end
|
|
78
|
+
unsplit ??= parsed;
|
|
79
|
+
if (width < 2) continue;
|
|
80
|
+
if (parsed.every((row) => row.length === width)) {
|
|
81
|
+
consistent = parsed;
|
|
82
|
+
break;
|
|
65
83
|
}
|
|
84
|
+
// a separator that splits the header but leaves the rows uneven is
|
|
85
|
+
// usually the one that just happens to appear in a header name. keep it
|
|
86
|
+
// only in case the other does not split at all, where a ragged table is
|
|
87
|
+
// still better to report on than an unsplit one
|
|
88
|
+
ragged ??= parsed;
|
|
66
89
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
export function data_uri_to_blob(data_uri: string): Blob {
|
|
70
|
-
const byte_str = atob(data_uri.split(",")[1]);
|
|
71
|
-
const mime_str = data_uri.split(",")[0].split(":")[1].split(";")[0];
|
|
72
|
-
const ab = new ArrayBuffer(byte_str.length);
|
|
73
|
-
const ia = new Uint8Array(ab);
|
|
74
|
-
for (let i = 0; i < byte_str.length; i++) {
|
|
75
|
-
ia[i] = byte_str.charCodeAt(i);
|
|
76
|
-
}
|
|
77
|
-
return new Blob([ab], { type: mime_str });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function handle_file_upload(
|
|
81
|
-
data_uri: string,
|
|
82
|
-
update_headers: (headers: Headers) => HeadersWithIDs[],
|
|
83
|
-
update_values: (values: CellValue[][]) => void
|
|
84
|
-
): void {
|
|
85
|
-
const blob = data_uri_to_blob(data_uri);
|
|
86
|
-
const reader = new FileReader();
|
|
87
|
-
reader.addEventListener("loadend", (e) => {
|
|
88
|
-
if (!e?.target?.result || typeof e.target.result !== "string") return;
|
|
89
|
-
const [delimiter] = guess_delimiter(e.target.result, [",", "\t"]);
|
|
90
|
-
const [head, ...rest] = dsvFormat(delimiter).parseRows(e.target.result);
|
|
91
|
-
update_headers(head);
|
|
92
|
-
update_values(rest);
|
|
93
|
-
});
|
|
94
|
-
reader.readAsText(blob);
|
|
90
|
+
const [head = [], ...rest] = consistent ?? ragged ?? unsplit ?? [];
|
|
91
|
+
return { headers: head.map((h) => h ?? ""), values: rest };
|
|
95
92
|
}
|
package/shared/utils/utils.ts
CHANGED
|
@@ -5,6 +5,18 @@ export const default_i18n: Record<string, string> = {
|
|
|
5
5
|
"dataframe.add_column_left": "Add column left",
|
|
6
6
|
"dataframe.add_column_right": "Add column right",
|
|
7
7
|
"dataframe.delete_column": "Delete column",
|
|
8
|
+
"dataframe.drop_to_upload":
|
|
9
|
+
"Drop CSV or TSV files here to import data into dataframe",
|
|
10
|
+
"dataframe.import_empty_file": "The dropped file is empty.",
|
|
11
|
+
"dataframe.import_no_column_names": "The dropped file has no column names.",
|
|
12
|
+
"dataframe.import_ragged_row":
|
|
13
|
+
"Line {line} of the file has {fields} fields, the header has {columns}.",
|
|
14
|
+
"dataframe.import_static_columns":
|
|
15
|
+
"Cannot import into a table with read-only columns.",
|
|
16
|
+
"dataframe.import_fixed_columns":
|
|
17
|
+
"This table takes exactly {expected} columns, the file has {actual}.",
|
|
18
|
+
"dataframe.import_fixed_rows":
|
|
19
|
+
"This table takes exactly {expected} rows, the file has {actual}.",
|
|
8
20
|
"dataframe.sort_asc": "Sort ascending",
|
|
9
21
|
"dataframe.sort_desc": "Sort descending",
|
|
10
22
|
"dataframe.sort_ascending": "Sort ascending",
|
|
@@ -70,11 +70,13 @@ describe("is_cell_in_selection", () => {
|
|
|
70
70
|
|
|
71
71
|
describe("is_cell_selected", () => {
|
|
72
72
|
test("returns empty string when cell not selected", () => {
|
|
73
|
-
expect(is_cell_selected([0, 0], [[1, 1]])).toBe("");
|
|
73
|
+
expect(is_cell_selected([0, 0], [[1, 1]], null, null)).toBe("");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test("returns 'cell-selected' for isolated cell", () => {
|
|
77
|
-
expect(is_cell_selected([1, 1], [[1, 1]])).toBe(
|
|
77
|
+
expect(is_cell_selected([1, 1], [[1, 1]], null, null)).toBe(
|
|
78
|
+
"cell-selected"
|
|
79
|
+
);
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
test("adds 'no-top' when neighbor above is selected", () => {
|
|
@@ -83,7 +85,9 @@ describe("is_cell_selected", () => {
|
|
|
83
85
|
[
|
|
84
86
|
[0, 0],
|
|
85
87
|
[1, 0]
|
|
86
|
-
]
|
|
88
|
+
],
|
|
89
|
+
0,
|
|
90
|
+
null
|
|
87
91
|
);
|
|
88
92
|
expect(result).toContain("cell-selected");
|
|
89
93
|
expect(result).toContain("no-top");
|
|
@@ -95,19 +99,46 @@ describe("is_cell_selected", () => {
|
|
|
95
99
|
[
|
|
96
100
|
[0, 0],
|
|
97
101
|
[1, 0]
|
|
98
|
-
]
|
|
102
|
+
],
|
|
103
|
+
null,
|
|
104
|
+
1
|
|
99
105
|
);
|
|
100
106
|
expect(result).toContain("cell-selected");
|
|
101
107
|
expect(result).toContain("no-bottom");
|
|
102
108
|
});
|
|
103
109
|
|
|
110
|
+
test("merges rows that are adjacent in the view but not in the data", () => {
|
|
111
|
+
// what a search leaves behind: data rows 0, 2, 4 shown next to each other
|
|
112
|
+
const selection: CellCoordinate[] = [
|
|
113
|
+
[0, 0],
|
|
114
|
+
[2, 0],
|
|
115
|
+
[4, 0]
|
|
116
|
+
];
|
|
117
|
+
const result = is_cell_selected([2, 0], selection, 0, 4);
|
|
118
|
+
expect(result).toContain("no-top");
|
|
119
|
+
expect(result).toContain("no-bottom");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("does not merge a selected row the view is not showing next to it", () => {
|
|
123
|
+
// row 1 is selected but the search has hidden it, so row 2's neighbours on
|
|
124
|
+
// screen are rows 0 and 4. Comparing row ± 1 instead would merge the top.
|
|
125
|
+
const selection: CellCoordinate[] = [
|
|
126
|
+
[1, 0],
|
|
127
|
+
[2, 0]
|
|
128
|
+
];
|
|
129
|
+
const result = is_cell_selected([2, 0], selection, 0, 4);
|
|
130
|
+
expect(result).toBe("cell-selected");
|
|
131
|
+
});
|
|
132
|
+
|
|
104
133
|
test("adds 'no-left' when neighbor left is selected", () => {
|
|
105
134
|
const result = is_cell_selected(
|
|
106
135
|
[0, 1],
|
|
107
136
|
[
|
|
108
137
|
[0, 0],
|
|
109
138
|
[0, 1]
|
|
110
|
-
]
|
|
139
|
+
],
|
|
140
|
+
null,
|
|
141
|
+
null
|
|
111
142
|
);
|
|
112
143
|
expect(result).toContain("cell-selected");
|
|
113
144
|
expect(result).toContain("no-left");
|
|
@@ -119,7 +150,9 @@ describe("is_cell_selected", () => {
|
|
|
119
150
|
[
|
|
120
151
|
[0, 0],
|
|
121
152
|
[0, 1]
|
|
122
|
-
]
|
|
153
|
+
],
|
|
154
|
+
null,
|
|
155
|
+
null
|
|
123
156
|
);
|
|
124
157
|
expect(result).toContain("cell-selected");
|
|
125
158
|
expect(result).toContain("no-right");
|
|
@@ -133,7 +166,7 @@ describe("is_cell_selected", () => {
|
|
|
133
166
|
[1, 0],
|
|
134
167
|
[1, 1]
|
|
135
168
|
];
|
|
136
|
-
const result = is_cell_selected([1, 1], selection);
|
|
169
|
+
const result = is_cell_selected([1, 1], selection, 0, null);
|
|
137
170
|
expect(result).toContain("cell-selected");
|
|
138
171
|
expect(result).toContain("no-top");
|
|
139
172
|
expect(result).toContain("no-left");
|
package/test/table_utils.test.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
make_cell_id,
|
|
4
4
|
make_header_id,
|
|
5
|
-
|
|
6
|
-
data_uri_to_blob
|
|
5
|
+
parse_table_file
|
|
7
6
|
} from "../shared/utils/table_utils";
|
|
8
7
|
import { cast_value_to_type } from "../shared/utils/utils";
|
|
9
8
|
|
|
@@ -69,41 +68,106 @@ describe("cast_value_to_type", () => {
|
|
|
69
68
|
});
|
|
70
69
|
});
|
|
71
70
|
|
|
72
|
-
describe("
|
|
73
|
-
test("detects comma
|
|
74
|
-
const
|
|
75
|
-
expect(
|
|
71
|
+
describe("parse_table_file", () => {
|
|
72
|
+
test("detects a comma-separated file", async () => {
|
|
73
|
+
const file = new File(["a,b,c\n1,2,3\n4,5,6"], "data.csv");
|
|
74
|
+
expect(await parse_table_file(file)).toEqual({
|
|
75
|
+
headers: ["a", "b", "c"],
|
|
76
|
+
values: [
|
|
77
|
+
["1", "2", "3"],
|
|
78
|
+
["4", "5", "6"]
|
|
79
|
+
]
|
|
80
|
+
});
|
|
76
81
|
});
|
|
77
82
|
|
|
78
|
-
test("detects tab
|
|
79
|
-
const
|
|
80
|
-
expect(
|
|
83
|
+
test("detects a tab-separated file", async () => {
|
|
84
|
+
const file = new File(["a\tb\tc\n1\t2\t3\n4\t5\t6"], "data.tsv");
|
|
85
|
+
expect(await parse_table_file(file)).toEqual({
|
|
86
|
+
headers: ["a", "b", "c"],
|
|
87
|
+
values: [
|
|
88
|
+
["1", "2", "3"],
|
|
89
|
+
["4", "5", "6"]
|
|
90
|
+
]
|
|
91
|
+
});
|
|
81
92
|
});
|
|
82
93
|
|
|
83
|
-
test("
|
|
84
|
-
const
|
|
85
|
-
expect(
|
|
94
|
+
test("picks the extension's delimiter when both look consistent", async () => {
|
|
95
|
+
const file = new File(["first,last\tage\nAlice,Smith\t30\n"], "data.tsv");
|
|
96
|
+
expect(await parse_table_file(file)).toEqual({
|
|
97
|
+
headers: ["first,last", "age"],
|
|
98
|
+
values: [["Alice,Smith", "30"]]
|
|
99
|
+
});
|
|
86
100
|
});
|
|
87
101
|
|
|
88
|
-
test("
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
test("reads a mislabeled file by its content", async () => {
|
|
103
|
+
const file = new File(["name,age\nAlice,30\n"], "data.tsv");
|
|
104
|
+
expect(await parse_table_file(file)).toEqual({
|
|
105
|
+
headers: ["name", "age"],
|
|
106
|
+
values: [["Alice", "30"]]
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// counting raw separators misses this: the quoted comma makes the comma counts
|
|
111
|
+
// disagree between the two lines, so nothing looks consistent
|
|
112
|
+
test("reads a mislabeled file whose field holds a quoted comma", async () => {
|
|
113
|
+
const file = new File(['name,note\nA,"x,y"\n'], "data.tsv");
|
|
114
|
+
expect(await parse_table_file(file)).toEqual({
|
|
115
|
+
headers: ["name", "note"],
|
|
116
|
+
values: [["A", "x,y"]]
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// the extension says comma and the header does split on one, but only the
|
|
121
|
+
// tab splits every line, so the name loses to the file's own shape
|
|
122
|
+
test("reads a mislabeled file whose header holds the other separator", async () => {
|
|
123
|
+
const file = new File(["first,last\tage\nAlice\t30\n"], "data.csv");
|
|
124
|
+
expect(await parse_table_file(file)).toEqual({
|
|
125
|
+
headers: ["first,last", "age"],
|
|
126
|
+
values: [["Alice", "30"]]
|
|
127
|
+
});
|
|
92
128
|
});
|
|
93
|
-
});
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
expect(
|
|
130
|
+
// nothing splits this file consistently, so the separator that at least
|
|
131
|
+
// splits the header wins and the caller gets to report a useful mismatch
|
|
132
|
+
test("falls back to the separator that splits the header", async () => {
|
|
133
|
+
const file = new File(["name,age\nAlice,30,Engineer\n"], "data.tsv");
|
|
134
|
+
expect(await parse_table_file(file)).toEqual({
|
|
135
|
+
headers: ["name", "age"],
|
|
136
|
+
values: [["Alice", "30", "Engineer"]]
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("ignores blank lines", async () => {
|
|
141
|
+
const file = new File(["a,b\n1,2\n\n3,4\n\n"], "data.csv");
|
|
142
|
+
expect(await parse_table_file(file)).toEqual({
|
|
143
|
+
headers: ["a", "b"],
|
|
144
|
+
values: [
|
|
145
|
+
["1", "2"],
|
|
146
|
+
["3", "4"]
|
|
147
|
+
]
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("ignores blank lines in a file with windows line endings", async () => {
|
|
152
|
+
const file = new File(["a,b\r\n1,2\r\n\r\n"], "data.csv");
|
|
153
|
+
expect(await parse_table_file(file)).toEqual({
|
|
154
|
+
headers: ["a", "b"],
|
|
155
|
+
values: [["1", "2"]]
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// excel writes one in front of its csv export. Blob.text() decodes as UTF-8,
|
|
160
|
+
// which drops it, so this holds as long as the file is read that way
|
|
161
|
+
test("strips a byte order mark from the first header", async () => {
|
|
162
|
+
const file = new File(["name,age\nAlice,30\n"], "data.csv");
|
|
163
|
+
expect(await parse_table_file(file)).toEqual({
|
|
164
|
+
headers: ["name", "age"],
|
|
165
|
+
values: [["Alice", "30"]]
|
|
166
|
+
});
|
|
100
167
|
});
|
|
101
168
|
|
|
102
|
-
test("
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const blob = data_uri_to_blob(data_uri);
|
|
106
|
-
const text = await blob.text();
|
|
107
|
-
expect(text).toBe("Hello");
|
|
169
|
+
test("returns nothing for a file that holds only whitespace", async () => {
|
|
170
|
+
const file = new File([" \n"], "data.csv");
|
|
171
|
+
expect(await parse_table_file(file)).toEqual({ headers: [], values: [] });
|
|
108
172
|
});
|
|
109
173
|
});
|