diff --git a/.changeset/grumpy-seals-exist.md b/.changeset/grumpy-seals-exist.md new file mode 100644 index 00000000..7428daa7 --- /dev/null +++ b/.changeset/grumpy-seals-exist.md @@ -0,0 +1,12 @@ +--- +"@dpkit/inline": minor +"dpkit": minor +"@dpkit/table": minor +"@dpkit/core": minor +"@dpkit/json": minor +"@dpkit/xlsx": minor +"@dpkit/csv": minor +"@dpkit/ods": minor +--- + +Added xlsx/ods support diff --git a/core/dialect/assert.ts b/core/dialect/assert.ts index b7446758..32b82733 100644 --- a/core/dialect/assert.ts +++ b/core/dialect/assert.ts @@ -6,8 +6,8 @@ import { validateDialect } from "./validate.ts" /** * Assert a Dialect descriptor (JSON Object) against its profile */ -export async function assertDialect(descriptor: Descriptor | Dialect) { - const { dialect, errors } = await validateDialect(descriptor) +export async function assertDialect(source: Descriptor | Dialect) { + const { dialect, errors } = await validateDialect(source) if (!dialect) throw new AssertionError(errors) return dialect } diff --git a/core/dialect/validate.ts b/core/dialect/validate.ts index f676dc6b..01ce15cf 100644 --- a/core/dialect/validate.ts +++ b/core/dialect/validate.ts @@ -8,10 +8,8 @@ const DEFAULT_PROFILE = "https://datapackage.org/profiles/1.0/tabledialect.json" /** * Validate a Dialect descriptor (JSON Object) against its profile */ -export async function validateDialect( - descriptorOrDialect: Descriptor | Dialect, -) { - const descriptor = descriptorOrDialect as Descriptor +export async function validateDialect(source: Descriptor | Dialect) { + const descriptor = source as Descriptor const $schema = typeof descriptor.$schema === "string" diff --git a/core/package/assert.ts b/core/package/assert.ts index 1392feee..26d5a44c 100644 --- a/core/package/assert.ts +++ b/core/package/assert.ts @@ -7,13 +7,13 @@ import { validatePackageDescriptor } from "./validate.ts" * Assert a Package descriptor (JSON Object) against its profile */ export async function assertPackage( - descriptorOrPackage: Descriptor | Package, + source: Descriptor | Package, options?: { basepath?: string }, ) { const { errors, dataPackage } = await validatePackageDescriptor( - descriptorOrPackage, + source, options, ) diff --git a/core/package/validate.ts b/core/package/validate.ts index c3df5290..4df8d899 100644 --- a/core/package/validate.ts +++ b/core/package/validate.ts @@ -9,12 +9,12 @@ const DEFAULT_PROFILE = "https://datapackage.org/profiles/1.0/datapackage.json" * Validate a Package descriptor (JSON Object) against its profile */ export async function validatePackageDescriptor( - descriptorOrPackage: Descriptor | Package, + source: Descriptor | Package, options?: { basepath?: string }, ) { - const descriptor = descriptorOrPackage as Descriptor + const descriptor = source as Descriptor const $schema = typeof descriptor.$schema === "string" diff --git a/core/resource/assert.ts b/core/resource/assert.ts index dddd2954..f30207a7 100644 --- a/core/resource/assert.ts +++ b/core/resource/assert.ts @@ -7,15 +7,12 @@ import { validateResourceDescriptor } from "./validate.ts" * Assert a Resource descriptor (JSON Object) against its profile */ export async function assertResource( - descriptorOrResource: Descriptor | Resource, + source: Descriptor | Resource, options?: { basepath?: string }, ) { - const { errors, resource } = await validateResourceDescriptor( - descriptorOrResource, - options, - ) + const { errors, resource } = await validateResourceDescriptor(source, options) if (!resource) throw new AssertionError(errors) return resource diff --git a/core/resource/dialect.ts b/core/resource/dialect.ts new file mode 100644 index 00000000..a29e6fa1 --- /dev/null +++ b/core/resource/dialect.ts @@ -0,0 +1,14 @@ +import { loadDialect } from "../dialect/index.ts" +import type { Resource } from "./Resource.ts" + +export async function loadResourceDialect(dialect: Resource["dialect"]) { + if (!dialect) { + return undefined + } + + if (typeof dialect !== "string") { + return dialect + } + + return await loadDialect(dialect) +} diff --git a/core/resource/index.ts b/core/resource/index.ts index 531c1774..224df1d7 100644 --- a/core/resource/index.ts +++ b/core/resource/index.ts @@ -8,3 +8,5 @@ export { normalizeResource } from "./process/normalize.ts" export { denormalizeResource } from "./process/denormalize.ts" export type { Source } from "./Source.ts" export type { License } from "./License.ts" +export { loadResourceDialect } from "./dialect.ts" +export { loadResourceSchema } from "./schema.ts" diff --git a/core/resource/schema.ts b/core/resource/schema.ts new file mode 100644 index 00000000..499543f6 --- /dev/null +++ b/core/resource/schema.ts @@ -0,0 +1,14 @@ +import { loadSchema } from "../schema/index.ts" +import type { Resource } from "./Resource.ts" + +export async function loadResourceSchema(schema: Resource["schema"]) { + if (!schema) { + return undefined + } + + if (typeof schema !== "string") { + return schema + } + + return await loadSchema(schema) +} diff --git a/core/resource/validate.ts b/core/resource/validate.ts index 06abe1db..ca631847 100644 --- a/core/resource/validate.ts +++ b/core/resource/validate.ts @@ -12,12 +12,12 @@ const DEFAULT_PROFILE = "https://datapackage.org/profiles/1.0/dataresource.json" * Validate a Resource descriptor (JSON Object) against its profile */ export async function validateResourceDescriptor( - descriptorOrResource: Descriptor | Resource, + source: Descriptor | Resource, options?: { basepath?: string }, ) { - const descriptor = descriptorOrResource as Descriptor + const descriptor = source as Descriptor const $schema = typeof descriptor.$schema === "string" diff --git a/core/schema/assert.ts b/core/schema/assert.ts index 230ac22b..c2b62372 100644 --- a/core/schema/assert.ts +++ b/core/schema/assert.ts @@ -6,8 +6,8 @@ import { validateSchema } from "./validate.ts" /** * Assert a Schema descriptor (JSON Object) against its profile */ -export async function assertSchema(descriptor: Descriptor | Schema) { - const { schema, errors } = await validateSchema(descriptor) +export async function assertSchema(source: Descriptor | Schema) { + const { schema, errors } = await validateSchema(source) if (!schema) throw new AssertionError(errors) return schema } diff --git a/core/schema/validate.ts b/core/schema/validate.ts index 242681d5..4016e4dc 100644 --- a/core/schema/validate.ts +++ b/core/schema/validate.ts @@ -8,8 +8,8 @@ const DEFAULT_PROFILE = "https://datapackage.org/profiles/1.0/tableschema.json" /** * Validate a Schema descriptor (JSON Object) against its profile */ -export async function validateSchema(descriptorOrSchema: Descriptor | Schema) { - const descriptor = descriptorOrSchema as Descriptor +export async function validateSchema(source: Descriptor | Schema) { + const descriptor = source as Descriptor const $schema = typeof descriptor.$schema === "string" diff --git a/csv/table/load.ts b/csv/table/load.ts index db11ee7c..64a3f910 100644 --- a/csv/table/load.ts +++ b/csv/table/load.ts @@ -1,8 +1,10 @@ import type { Dialect, Resource } from "@dpkit/core" -import { loadDialect } from "@dpkit/core" +import { loadResourceDialect } from "@dpkit/core" import { prefetchFiles } from "@dpkit/file" -import type { Table } from "@dpkit/table" -import { DataFrame, col, scanCSV } from "nodejs-polars" +import { stripInitialSpace } from "@dpkit/table" +import { joinHeaderRows } from "@dpkit/table" +import { skipCommentRows } from "@dpkit/table" +import { DataFrame, scanCSV } from "nodejs-polars" import type { ScanCsvOptions } from "nodejs-polars" import { Utf8, concat } from "nodejs-polars" @@ -16,11 +18,7 @@ export async function loadCsvTable(resource: Partial) { return DataFrame().lazy() } - const dialect = - typeof resource.dialect === "string" - ? await loadDialect(resource.dialect) - : resource.dialect - + const dialect = await loadResourceDialect(resource.dialect) const scanOptions = getScanOptions(resource, dialect) let table = scanCSV(firstPath, scanOptions) @@ -42,9 +40,9 @@ export async function loadCsvTable(resource: Partial) { } if (dialect) { - table = await joinHeaderRows(table, dialect) - table = skipCommentRows(table, dialect) - table = stripInitialSpace(table, dialect) + table = await joinHeaderRows(table, { dialect }) + table = skipCommentRows(table, { dialect }) + table = stripInitialSpace(table, { dialect }) } return table @@ -86,81 +84,11 @@ function getScanOptions(resource: Partial, dialect?: Dialect) { return options } -// TODO: move to the table package? - -async function joinHeaderRows(table: Table, dialect: Dialect) { - const headerOffset = getHeaderOffset(dialect) - const headerRows = getHeaderRows(dialect) - const headerJoin = dialect?.headerJoin ?? " " - if (headerRows.length < 2) { - return table - } - - const extraLabelsFrame = await table - .withRowCount() - .withColumn(col("row_nr").add(1)) - .filter(col("row_nr").add(headerOffset).isIn(headerRows)) - .select(table.columns.map(name => col(name).str.concat(headerJoin))) - .collect() - - const labels = table.columns - const extraLabels = extraLabelsFrame.row(0) - - const mapping = Object.fromEntries( - labels.map((label, index) => [ - label, - [label, extraLabels[index]].join(headerJoin), - ]), - ) - - return table - .withRowCount() - .withColumn(col("row_nr").add(1)) - .filter(col("row_nr").add(headerOffset).isIn(headerRows).not()) - .rename(mapping) - .drop("row_nr") -} - -function skipCommentRows(table: Table, dialect: Dialect) { - const commentOffset = getCommentOffset(dialect) - if (!dialect?.commentRows) { - return table - } - - return table - .withRowCount() - .withColumn(col("row_nr").add(1)) - .filter(col("row_nr").add(commentOffset).isIn(dialect.commentRows).not()) - .drop("row_nr") -} - -function stripInitialSpace(table: Table, dialect: Dialect) { - if (!dialect?.skipInitialSpace) { - return table - } - - return table.select( - // TODO: rebase on stripCharsStart when it's fixed in polars - // https://github.com/pola-rs/nodejs-polars/issues/336 - table.columns.map(name => col(name).str.strip().as(name)), - ) -} - function getRowsToSkip(dialect?: Dialect) { const headerRows = getHeaderRows(dialect) return headerRows[0] ? headerRows[0] - 1 : 0 } -function getHeaderOffset(dialect?: Dialect) { - const headerRows = getHeaderRows(dialect) - return headerRows.at(0) ?? 0 -} - -function getCommentOffset(dialect?: Dialect) { - const headerRows = getHeaderRows(dialect) - return headerRows.at(-1) ?? 0 -} - function getHeaderRows(dialect?: Dialect) { return dialect?.header !== false ? (dialect?.headerRows ?? [1]) : [] } diff --git a/docs/astro.config.ts b/docs/astro.config.ts index ab1d7f1f..40893833 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -17,8 +17,11 @@ const PACKAGES = { "@dpkit/file": "../file", "@dpkit/github": "../github", "@dpkit/inline": "../inline", + "@dpkit/json": "../json", + "@dpkit/ods": "../ods", "@dpkit/parquet": "../parquet", "@dpkit/table": "../table", + "@dpkit/xlsx": "../xlsx", "@dpkit/zenodo": "../zenodo", "@dpkit/zip": "../zip", } @@ -109,7 +112,7 @@ function generatePackageEntrypoints() { } function generatePackageSidebars() { - return Object.entries(PACKAGES).map(([name, path]) => + return Object.entries(PACKAGES).map(([name, _path]) => generatePackageSidebar({ name }), ) } diff --git a/docs/content/docs/guides/ods.md b/docs/content/docs/guides/ods.md new file mode 100644 index 00000000..663c5666 --- /dev/null +++ b/docs/content/docs/guides/ods.md @@ -0,0 +1,189 @@ +--- +title: Working with ODS +sidebar: + label: ODS + order: 2 +--- +Comprehensive OpenDocument Spreadsheet (ODS) file handling with sheet selection, advanced header processing, and high-performance data operations. + +## Introduction + +The ODS plugin is a part of the [dpkit](https://github.com/datisthq/dpkit) ecosystem providing these capabilities: + +- `loadOdsTable` +- `saveOdsTable` + +These functions handle ODS files at the IO and dialect level, supporting LibreOffice Calc and OpenOffice Calc formats. + +For complete loading and processing of ODS files, the [dpkit](https://github.com/datisthq/dpkit) ecosystem provides the `readTable` function which is a high-level function that handles both loading and processing of ODS files, and `saveTable` for saving ODS files. + +The ODS plugin automatically handles `.ods` files when using dpkit: + +```typescript +import { readTable, saveTable } from "dpkit" + +const table = await readTable({path: "table.ods"}) +// the field types will be automatically inferred +// or you can provide a Table Schema + +await saveTable(table, {path: "output.ods"}) +``` + +## Basic Usage + +### Reading ODS Files + +:::tip +The ouput of `readTable` is a Polars LazyDataFrame, allowing you to use all of the power of Polars for data processing. +::: + +```typescript +import { readTable } from "dpkit" + +// Load a simple ODS file +const table = await readTable({ path: "data.ods" }) + +// Load with custom dialect (specify sheet) +const table = await readTable({ + path: "data.ods", + dialect: { + sheetName: "Sheet2", + header: true + } +}) + +// Load multiple ODS files (concatenated) +const table = await readTable({ + path: ["part1.ods", "part2.ods", "part3.ods"] +}) + +// Table is a Polars LazyDataFrame +const df = table.collect() +df.describe() +``` + +### Saving ODS Files + +```typescript +import { saveTable } from "dpkit" + +// Save with default options +await saveTable(table, { path: "output.ods" }) + +// Save with custom sheet name +await saveTable(table, { + path: "output.ods", + dialect: { + sheetName: "Data" + } +}) +``` + +## Advanced Features + +### Sheet Selection + +```typescript +import { readTable } from "dpkit" + +// Select by sheet number (1-indexed) +const table = await readTable({ + path: "workbook.ods", + dialect: { + sheetNumber: 2 // Load second sheet + } +}) + +// Select by sheet name +const table = await readTable({ + path: "workbook.ods", + dialect: { + sheetName: "Sales Data" + } +}) +``` + +### Multi-Header Row Processing + +```typescript +import { readTable } from "dpkit" + +// ODS with multiple header rows: +// Year | 2023 | 2023 | 2024 | 2024 +// Quarter | Q1 | Q2 | Q1 | Q2 +// Revenue | 100 | 120 | 110 | 130 + +const table = await readTable({ + path: "multi-header.ods", + dialect: { + headerRows: [1, 2], + headerJoin: "_" + } +}) +// Resulting columns: ["Year_Quarter", "2023_Q1", "2023_Q2", "2024_Q1", "2024_Q2"] +``` + +### Comment Row Handling + +```typescript +import { readTable } from "dpkit" + +// ODS with comment rows +const table = await readTable({ + path: "with-comments.ods", + dialect: { + commentRows: [1, 2], // Skip first two rows + header: true + } +}) + +// Skip rows with comment character +const table = await readTable({ + path: "data.ods", + dialect: { + commentChar: "#" // Skip rows starting with # + } +}) +``` + +### Remote File Loading + +```typescript +import { readTable } from "dpkit" + +// Load from URL +const table = await readTable({ + path: "https://example.com/data.ods" +}) + +// Load multiple remote files +const table = await readTable({ + path: [ + "https://api.example.com/data-2023.ods", + "https://api.example.com/data-2024.ods" + ] +}) +``` + +### Header Options + +```typescript +import { readTable } from "dpkit" + +// No header row (use generated column names) +const table = await readTable({ + path: "data.ods", + dialect: { + header: false + } +}) +// Columns will be: column_1, column_2, column_3, etc. + +// Custom header row offset +const table = await readTable({ + path: "data.ods", + dialect: { + headerRows: [3] // Use third row as header + } +}) +``` diff --git a/docs/content/docs/guides/xlsx.md b/docs/content/docs/guides/xlsx.md new file mode 100644 index 00000000..2e61466b --- /dev/null +++ b/docs/content/docs/guides/xlsx.md @@ -0,0 +1,189 @@ +--- +title: Working with XLSX +sidebar: + label: XLSX + order: 1 +--- +Comprehensive Excel (.xlsx) file handling with sheet selection, advanced header processing, and high-performance data operations. + +## Introduction + +The XLSX plugin is a part of the [dpkit](https://github.com/datisthq/dpkit) ecosystem providing these capabilities: + +- `loadXlsxTable` +- `saveXlsxTable` + +These functions handle XLSX files at the IO and dialect level, supporting Microsoft Excel formats. + +For complete loading and processing of XLSX files, the [dpkit](https://github.com/datisthq/dpkit) ecosystem provides the `readTable` function which is a high-level function that handles both loading and processing of XLSX files, and `saveTable` for saving XLSX files. + +The XLSX plugin automatically handles `.xlsx` files when using dpkit: + +```typescript +import { readTable, saveTable } from "dpkit" + +const table = await readTable({path: "table.xlsx"}) +// the field types will be automatically inferred +// or you can provide a Table Schema + +await saveTable(table, {path: "output.xlsx"}) +``` + +## Basic Usage + +### Reading XLSX Files + +:::tip +The ouput of `readTable` is a Polars LazyDataFrame, allowing you to use all of the power of Polars for data processing. +::: + +```typescript +import { readTable } from "dpkit" + +// Load a simple XLSX file +const table = await readTable({ path: "data.xlsx" }) + +// Load with custom dialect (specify sheet) +const table = await readTable({ + path: "data.xlsx", + dialect: { + sheetName: "Sheet2", + header: true + } +}) + +// Load multiple XLSX files (concatenated) +const table = await readTable({ + path: ["part1.xlsx", "part2.xlsx", "part3.xlsx"] +}) + +// Table is a Polars LazyDataFrame +const df = table.collect() +df.describe() +``` + +### Saving XLSX Files + +```typescript +import { saveTable } from "dpkit" + +// Save with default options +await saveTable(table, { path: "output.xlsx" }) + +// Save with custom sheet name +await saveTable(table, { + path: "output.xlsx", + dialect: { + sheetName: "Data" + } +}) +``` + +## Advanced Features + +### Sheet Selection + +```typescript +import { readTable } from "dpkit" + +// Select by sheet number (1-indexed) +const table = await readTable({ + path: "workbook.xlsx", + dialect: { + sheetNumber: 2 // Load second sheet + } +}) + +// Select by sheet name +const table = await readTable({ + path: "workbook.xlsx", + dialect: { + sheetName: "Sales Data" + } +}) +``` + +### Multi-Header Row Processing + +```typescript +import { readTable } from "dpkit" + +// XLSX with multiple header rows: +// Year | 2023 | 2023 | 2024 | 2024 +// Quarter | Q1 | Q2 | Q1 | Q2 +// Revenue | 100 | 120 | 110 | 130 + +const table = await readTable({ + path: "multi-header.xlsx", + dialect: { + headerRows: [1, 2], + headerJoin: "_" + } +}) +// Resulting columns: ["Year_Quarter", "2023_Q1", "2023_Q2", "2024_Q1", "2024_Q2"] +``` + +### Comment Row Handling + +```typescript +import { readTable } from "dpkit" + +// XLSX with comment rows +const table = await readTable({ + path: "with-comments.xlsx", + dialect: { + commentRows: [1, 2], // Skip first two rows + header: true + } +}) + +// Skip rows with comment character +const table = await readTable({ + path: "data.xlsx", + dialect: { + commentChar: "#" // Skip rows starting with # + } +}) +``` + +### Remote File Loading + +```typescript +import { readTable } from "dpkit" + +// Load from URL +const table = await readTable({ + path: "https://example.com/data.xlsx" +}) + +// Load multiple remote files +const table = await readTable({ + path: [ + "https://api.example.com/data-2023.xlsx", + "https://api.example.com/data-2024.xlsx" + ] +}) +``` + +### Header Options + +```typescript +import { readTable } from "dpkit" + +// No header row (use generated column names) +const table = await readTable({ + path: "data.xlsx", + dialect: { + header: false + } +}) +// Columns will be: column_1, column_2, column_3, etc. + +// Custom header row offset +const table = await readTable({ + path: "data.xlsx", + dialect: { + headerRows: [3] // Use third row as header + } +}) +``` \ No newline at end of file diff --git a/dpkit/package.json b/dpkit/package.json index 622d7174..42e88bb7 100644 --- a/dpkit/package.json +++ b/dpkit/package.json @@ -31,8 +31,10 @@ "@dpkit/github": "workspace:*", "@dpkit/inline": "workspace:*", "@dpkit/json": "workspace:*", + "@dpkit/ods": "workspace:*", "@dpkit/parquet": "workspace:*", "@dpkit/table": "workspace:*", + "@dpkit/xlsx": "workspace:*", "@dpkit/zenodo": "workspace:*", "@dpkit/zip": "workspace:*" }, diff --git a/dpkit/plugin.ts b/dpkit/plugin.ts index 43ab813a..894a847a 100644 --- a/dpkit/plugin.ts +++ b/dpkit/plugin.ts @@ -6,8 +6,10 @@ import { FolderPlugin } from "@dpkit/folder" import { GithubPlugin } from "@dpkit/github" import { InlinePlugin } from "@dpkit/inline" import { JsonPlugin } from "@dpkit/json" +import { OdsPlugin } from "@dpkit/ods" import { ParquetPlugin } from "@dpkit/parquet" import type { TablePlugin } from "@dpkit/table" +import { XlsxPlugin } from "@dpkit/xlsx" import { ZenodoPlugin } from "@dpkit/zenodo" import { ZipPlugin } from "@dpkit/zip" @@ -32,6 +34,8 @@ dpkit.register(ZipPlugin) // Table functions dpkit.register(ArrowPlugin) dpkit.register(CsvPlugin) -dpkit.register(JsonPlugin) dpkit.register(InlinePlugin) +dpkit.register(JsonPlugin) +dpkit.register(OdsPlugin) dpkit.register(ParquetPlugin) +dpkit.register(XlsxPlugin) diff --git a/dpkit/table/infer.ts b/dpkit/table/infer.ts index e9ed01af..bef87aa7 100644 --- a/dpkit/table/infer.ts +++ b/dpkit/table/infer.ts @@ -1,30 +1,26 @@ import type { Dialect, Resource, Schema } from "@dpkit/core" -import { loadDialect, loadSchema } from "@dpkit/core" +import { loadResourceDialect, loadResourceSchema } from "@dpkit/core" import type { Table } from "@dpkit/table" import { inferSchema } from "@dpkit/table" import { inferDialect } from "../dialect/index.ts" import { loadTable } from "./load.ts" -// TODO: Allow non-tabular resources returning undefineds? +// TODO: Allow non-tabular resources returning undefined? export async function inferTable( resource: Partial, ): Promise<{ dialect: Dialect; schema: Schema; table: Table }> { - const dialect = - typeof resource.dialect === "string" - ? await loadDialect(resource.dialect) - : resource.dialect - ? resource.dialect - : await inferDialect(resource) + let dialect = await loadResourceDialect(resource.dialect) + if (!dialect) { + dialect = await inferDialect(resource) + } const table = await loadTable({ ...resource, dialect }) - const schema = - typeof resource.schema === "string" - ? await loadSchema(resource.schema) - : resource.schema - ? resource.schema - : await inferSchema(table) + let schema = await loadResourceSchema(resource.schema) + if (!schema) { + schema = await inferSchema(table) + } return { dialect, schema, table } } diff --git a/excel/index.ts b/excel/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/inline/table/load.spec.ts b/inline/table/load.spec.ts index d5f7635c..746c36ad 100644 --- a/inline/table/load.spec.ts +++ b/inline/table/load.spec.ts @@ -2,41 +2,81 @@ import { describe, expect, it } from "vitest" import { loadInlineTable } from "./load.ts" describe("loadInlineTable", () => { - it.each([ - { - description: "should handle no data", - records: [], - }, - { - description: "should handle bad data", + it("should handle no data", async () => { + const resource = { + name: "test", + type: "table", + data: undefined, + schema: undefined, + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([]).toEqual(df.toRecords()) + }) + + it("should handle bad data", async () => { + const resource = { + name: "test", + type: "table", data: "bad", - records: [], - }, - { - description: "should read arrays", + schema: undefined, + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([]).toEqual(df.toRecords()) + }) + + it("should read arrays", async () => { + const resource = { + name: "test", + type: "table" as const, data: [ ["id", "name"], [1, "english"], [2, "中文"], ], - records: [ - { id: 1, name: "english" }, - { id: 2, name: "中文" }, - ], - }, - { - description: "should read objects", + } + + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { id: 1, name: "english" }, + { id: 2, name: "中文" }, + ]).toEqual(df.toRecords()) + }) + + it("should read objects", async () => { + const resource = { + name: "test", + type: "table", data: [ { id: 1, name: "english" }, { id: 2, name: "中文" }, ], - records: [ - { id: 1, name: "english" }, - { id: 2, name: "中文" }, - ], - }, - { - description: "should handle longer rows", + schema: undefined, + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { id: 1, name: "english" }, + { id: 2, name: "中文" }, + ]).toEqual(df.toRecords()) + }) + + it("should handle longer rows", async () => { + const resource = { + name: "test", + type: "table", data: [ ["id", "name"], [1, "english"], @@ -48,13 +88,22 @@ describe("loadInlineTable", () => { { name: "name", type: "string" }, ], }, - records: [ - { id: 1, name: "english" }, - { id: 2, name: "中文" }, - ], - }, - { - description: "should handle shorter rows", + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { id: 1, name: "english" }, + { id: 2, name: "中文" }, + ]).toEqual(df.toRecords()) + }) + + it("should handle shorter rows", async () => { + const resource = { + name: "test", + type: "table", data: [ ["id", "name"], [1, "english"], @@ -66,13 +115,22 @@ describe("loadInlineTable", () => { { name: "name", type: "string" }, ], }, - records: [ - { id: 1, name: "english" }, - { id: 2, name: null }, - ], - }, - { - description: "should handle various data types", + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { id: 1, name: "english" }, + { id: 2, name: null }, + ]).toEqual(df.toRecords()) + }) + + it("should handle various data types", async () => { + const resource = { + name: "test", + type: "table", data: [ { string: "string", @@ -83,24 +141,62 @@ describe("loadInlineTable", () => { datetime: new Date("2025-01-01"), }, ], - records: [ - { - string: "string", - number: 1, - boolean: true, - date: new Date("2025-01-01"), - time: new Date("2025-01-01"), - datetime: new Date("2025-01-01"), - }, + schema: undefined, + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { + string: "string", + number: 1, + boolean: true, + date: new Date("2025-01-01"), + time: new Date("2025-01-01"), + datetime: new Date("2025-01-01"), + }, + ]).toEqual(df.toRecords()) + }) + + it("should handle objects with shorter rows", async () => { + const resource = { + name: "test", + type: "table", + data: [{ id: 1, name: "english" }, { id: 2, name: "中文" }, { id: 3 }], + } + + // @ts-ignore + const table = await loadInlineTable(resource) + const df = await table.collect() + + expect([ + { id: 1, name: "english" }, + { id: 2, name: "中文" }, + { id: 3, name: null }, + ]).toEqual(df.toRecords()) + }) + + it("should handle objects with longer rows", async () => { + const resource = { + name: "test", + type: "table", + data: [ + { id: 1, name: "english" }, + { id: 2, name: "中文" }, + { id: 3, name: "german", extra: "extra" }, ], - }, - ])("$description", async ({ data, schema, records }) => { - const resource = { name: "test", type: "table", data, schema } + } // @ts-ignore const table = await loadInlineTable(resource) const df = await table.collect() - expect(records).toEqual(df.toRecords()) + expect([ + { id: 1, name: "english", extra: null }, + { id: 2, name: "中文", extra: null }, + { id: 3, name: "german", extra: "extra" }, + ]).toEqual(df.toRecords()) }) }) diff --git a/inline/table/load.ts b/inline/table/load.ts index a1cd917d..1306ad9d 100644 --- a/inline/table/load.ts +++ b/inline/table/load.ts @@ -1,79 +1,19 @@ import type { Resource } from "@dpkit/core" +import { loadResourceDialect } from "@dpkit/core" +import { getRecordsFromRows } from "@dpkit/table" import { DataFrame } from "nodejs-polars" export async function loadInlineTable(resource: Partial) { - const polarsData = getPolarsData({ data: resource.data }) - - const table = DataFrame(polarsData).lazy() - return table -} - -export type PolarsData = Record - -function getPolarsData(props: { data: Resource["data"] }) { - const { data } = props + const dialect = await loadResourceDialect(resource.dialect) + const data = resource.data if (!Array.isArray(data)) { - return {} - } - - const isArrays = data.every(row => Array.isArray(row)) - - const polarsData = isArrays - ? getPolarsDataFromArrays({ data }) - : getPolarsDataFromObjects({ data }) - - const maxLength = Math.max( - ...Object.values(polarsData).map(values => values.length), - ) - - // Pad shorter columns with nulls - for (const key in polarsData) { - const values = polarsData[key] ?? [] - if (values.length < maxLength) { - polarsData[key] = [ - ...values, - ...Array(maxLength - values.length).fill(null), - ] - } + return DataFrame().lazy() } - return polarsData -} - -function getPolarsDataFromArrays(props: { data: unknown[][] }) { - const polarsData: PolarsData = {} + const isRows = data.every(row => Array.isArray(row)) + const records = isRows ? getRecordsFromRows(data, dialect) : data - const [header, ...rows] = props.data - - if (!header) { - return polarsData - } - - for (const row of rows) { - for (const [index, value] of row.entries()) { - const key = header[index] - if (typeof key === "string") { - polarsData[key] = polarsData[key] || [] - polarsData[key].push(value) - } - } - } - - return polarsData -} - -function getPolarsDataFromObjects(props: { data: Record[] }) { - const polarsData: PolarsData = {} - - for (const row of props.data) { - for (const [key, value] of Object.entries(row)) { - if (typeof key === "string") { - polarsData[key] = polarsData[key] || [] - polarsData[key].push(value) - } - } - } - - return polarsData + const table = DataFrame(records).lazy() + return table } diff --git a/json/table/load.ts b/json/table/load.ts index 0158befc..599b7b46 100644 --- a/json/table/load.ts +++ b/json/table/load.ts @@ -1,7 +1,6 @@ -import { readFile } from "node:fs/promises" import type { Dialect, Resource } from "@dpkit/core" -import { loadDialect } from "@dpkit/core" -import { prefetchFiles } from "@dpkit/file" +import { loadResourceDialect } from "@dpkit/core" +import { loadFile, prefetchFiles } from "@dpkit/file" import type { Table } from "@dpkit/table" import { concat } from "nodejs-polars" import { DataFrame, scanJson } from "nodejs-polars" @@ -26,10 +25,7 @@ async function loadTable( return DataFrame().lazy() } - const dialect = - typeof resource.dialect === "string" - ? await loadDialect(resource.dialect) - : resource.dialect + const dialect = await loadResourceDialect(resource.dialect) const tables: Table[] = [] for (const path of paths) { @@ -39,7 +35,7 @@ async function loadTable( continue } - const buffer = await readFile(path) + const buffer = await loadFile(path) let data = decodeJsonBuffer(buffer, { isLines }) if (dialect) { data = processData(data, dialect) diff --git a/ods/index.ts b/ods/index.ts index e69de29b..5f4f33d9 100644 --- a/ods/index.ts +++ b/ods/index.ts @@ -0,0 +1,2 @@ +export * from "./table/index.ts" +export * from "./plugin.ts" diff --git a/ods/package.json b/ods/package.json index 2a5b2f26..a509f82e 100644 --- a/ods/package.json +++ b/ods/package.json @@ -21,5 +21,15 @@ ], "scripts": { "build": "tsc" + }, + "dependencies": { + "@dpkit/core": "workspace:*", + "@dpkit/file": "workspace:*", + "@dpkit/table": "workspace:*", + "nodejs-polars": "^0.21.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + }, + "devDependencies": { + "@dpkit/test": "workspace:*" } } diff --git a/ods/plugin.ts b/ods/plugin.ts new file mode 100644 index 00000000..fcc0c481 --- /dev/null +++ b/ods/plugin.ts @@ -0,0 +1,26 @@ +import type { Resource } from "@dpkit/core" +import { inferResourceFormat } from "@dpkit/core" +import type { TablePlugin } from "@dpkit/table" +import type { SaveTableOptions, Table } from "@dpkit/table" +import { loadOdsTable, saveOdsTable } from "./table/index.ts" + +export class OdsPlugin implements TablePlugin { + async loadTable(resource: Partial) { + const isOds = getIsOds(resource) + if (!isOds) return undefined + + return await loadOdsTable(resource) + } + + async saveTable(table: Table, options: SaveTableOptions) { + const isOds = getIsOds(options) + if (!isOds) return undefined + + return await saveOdsTable(table, options) + } +} + +function getIsOds(resource: Partial) { + const format = inferResourceFormat(resource) + return ["ods"].includes(format ?? "") +} diff --git a/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-multipart-remote-file_2072534240/recording.har b/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-multipart-remote-file_2072534240/recording.har new file mode 100644 index 00000000..880df6a0 --- /dev/null +++ b/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-multipart-remote-file_2072534240/recording.har @@ -0,0 +1,292 @@ +{ + "log": { + "_recordingName": "loadOdsTable-file variations-should load multipart remote file", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "af5eaef181e9db6c212c09b3de666321", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 105, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods" + }, + "response": { + "bodySize": 8339, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 8339, + "text": "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" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "8339" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Fri, 22 Aug 2025 10:52:56 GMT" + }, + { + "name": "etag", + "value": "W/\"8629962f95105d08f779258e479dd1612b08c2b82a07db0dfa5a0e0e27cc40d1\"" + }, + { + "name": "expires", + "value": "Fri, 22 Aug 2025 10:57:56 GMT" + }, + { + "name": "source-age", + "value": "36" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "2" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "526f193b1f4c3573291df13aa12007e013f81d7e" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "42AA:3FECAD:1DA453:20D210:68A84BE3" + }, + { + "name": "x-served-by", + "value": "cache-lis1490055-LIS" + }, + { + "name": "x-timer", + "value": "S1755859976.229530,VS0,VE0" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 876, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-22T10:52:56.143Z", + "time": 136, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 136 + } + }, + { + "_id": "af5eaef181e9db6c212c09b3de666321", + "_order": 1, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 105, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods" + }, + "response": { + "bodySize": 8339, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 8339, + "text": "UEsDBBQAAAgAAG5gWkmFbDmKLgAAAC4AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnNwcmVhZHNoZWV0UEsDBBQAAAgAAG5gWklDwNtOpQUAAKUFAAAYAAAAVGh1bWJuYWlscy90aHVtYm5haWwucG5niVBORw0KGgoAAAANSUhEUgAAAL0AAAD/CAMAAACTmSdlAAACcFBMVEUlLT42MTU8OTsxQkQ/SEZCOThSSztNRURIR09ISk9BR1JCS1ZJVl1WT0RWTEpZT0hXTldRUE5dVU9SUFtcU1FcU1lcWltLUGFMWGRLXHdRUGBTWmNSXGxdWmFUXHdPYHZWYmtdYmpVZnBaZn1YaHFfantiTktlWlVjXFxlW2FmZV50ZFZ6alpjYmNmY2hlaGxsaWZub21ibHhpbXRvcGxrdXt9b2V6cGF6dGl1dnh5d3R+e3d5eXlmdoZhdolkeYxqdIJqeItyfI5ugZd2gpF0g5p1iJ17hpZ2iKJ9j6aEdmODcmmPfGyAfn2Nf3GRfG2DgH2Pg3eeh3KYh3idjHyhi3elk32DhIWHiI2JhoOFjJSRiYOYioCQjpOWkYybk4WXl5ecnJ2DkqGClKmJmKeKmKicnKSVoaqboaSfoa2RpbaYqrqkl4OtnY2impSrnZS5n4q3oY63oZCwpp65qZqho6ajpquiqa6ooqCsqKWhrbi1rqW4rqKxsbG9u7G9ur2Yq8WfscShtcSjvc2susa1vsm7u8G1wMq5xtS7yda4yuG/0+O/0erHt5/Bs6PBua/MuqbIvK7Du7TAvLnJvrTXvqzNxLfMwbjVw67awK7TxLXVybvbwrHdzb3jz7nk0LvOyMTGzNTK0dvUzMXb2M/S0tLX2tza2tjE1uXB0+rK2uzK2vDU2+LX3ujb5Ovgz8Di08Ln3Mzr3s3l29Pp3tHk4dzr49fs5Nrx5c/27t357Nzm5ufu6ODr6+zl7PPq7vHs8fTp9v3r+fz07+Tx7uv08e368eb69O3//O/09PT39/j3+fv7+fX+/v4AAAD///8PlikHAAAC8ElEQVR42u3X2U9TQRTHcUCjEtwCbmwGlIgLBMEiIi4IIRjbJpWoFXChQBBFEKviQtBqhbgh2ogaEHdcqqJIoC4REEG2nv/Jqfrii+lbmeT7u8m5uW+fTGbOPRMkOidI6lR19euq/6qq8Y2u+oiJcGuw3/qOMPMST0RWtik9PD0jrzs5KyfA+odbJcl//TZZMFhTOM10rTV3JLYgzhzyPbD6R1tkjdtvfZ4srC6V6aYbrWWjsbud8i2wax87nrhppt/693my6m60NTT8xoOy0ZS+RRvzA6vXu2Oi11E/rh4V76QW+juH/v02uit8L7tDB33fgWWqnrA29lRaznksdYeV3lNo7bLnWBo1WPsvCSL3N/fObYn0zkq6fivS+Dpqz5HeJ/bcH4s10dfGlZS0bJf5K4Y+LzW6I4aLky/XOrzRU18/0jb7hXyIebm3Tel3XKyONLqj6htvZ9sd3qipr++x2U6LPLO99TyVC72288sfDzSNnK0b7HknTZp1zLaUtVe17PftV1Spt/nSpbp9pU76M7aMNFvF5GpXQ0PDPqfIz3hN9K7KfhmL7zxa2pk4ZHAdjD51TOk/Bk1qob+Z74qRsXRpdsj6IcO9DWFFJ5X+eOgrTXZOd4KMhaTOCTMEDxnkU/5+pfeufJ6th7593YCou1TzJRmW1BpLprnYKQVVsqtKB31H2sDvmbLZN5XNyFSZ5zSVq66zs1yHfZ9pLvKdUO+E78f7ZzYe/zsqcztBjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aNHjx49evTo0aP/T34BRo0+5QSGTH4AAAAASUVORK5CYIJQSwMEFAAICAgAbmBaSQAAAAAAAAAAAAAAAAwAAABzZXR0aW5ncy54bWztWdty2zYQfe9XaPguU5IvE3MsZWQlrtzm4hFlt80bRK4kjEEsBwBFK2/tb+RH8hnJHxUkRVdmyJqSiEw60ydbBHB2tTw4ewBdvHwIWGsFQlLkfat71LFawD30KV/0rdvpVfuF9XLw0wXO59QDx0cvCoCrtgSl9BTZ0su5dLLhvhUJ7iCRVDqcBCAd5TkYAs+XOduznTRY9uSBUX7ft5ZKhY5tx3F8FB8foVjY3fPzczsdzad6yOd0UTdUNns7FCI+BkoWZMmkwXqdzomdfbZamyS3StOzBnkd8q8/uNgEyP60qYIgqU1r8zhJrW/pkM6KQvxYNats3dM1d1TSGYOhADLF0MoH1TrUg5Qra9C5sL8F2Qn4DcyVGeTfqK+WZdAnp93Tg9HHQBfL0sy7x6cndeHbAQnblPvwAH4xFMTl7yhdo9kl1nUShvjaL2QpldAEsAYJHbo7ZZqAFvKcEl2Q5xJ9uuTLpy+fv/759a9uDQqOIiFR3KCkSu+A3xtkylPkPxpEHqOgH5ErwtyQUfUWfSi+gSWKAzgOQlHPFHoh+7xATe7R7fwN4A89RVeQok8IX1SUp7cfeJ5vw7qVw06qVOVA3Gb1O0e9RKUwaBD4A2Iw1SiNMjoBvSMsKqJmat3ZtwZkAYm8/iv62Z7g7hLjnwUtKvcMkQHh1kCJCMqRSx5uN4yq4VTZ626tVPMrmsqjuB+sPp5AxmZEVPbx7ot9y/s/zf6hWRLgUhua+xsBiSOootycMFnBuTphPoDANH+5O6fr4L9DZQp6v434HHKCOkKGogDNMNlG3d7Zca93etbAezVQlTGROvUo4BOMx0B8fT4xEsRdAiitNgbQr+X7SOkzHbjrYIZMulBsu40ESSk/1k2dJY1dK+RrnohnJZ8O2GXX0uUknOKESAVFVjURIAPW3yk7BRmLMAGpyVXp97WU7il3RfhS038ovBvNfLqisjL9hsDLk9+XOhn88IFKd829pUBOP1aztHH7sTkCl0+QoOrfcWQPIkGSF7zLZce1fLW5tnGXRBjZoW+Q+BMtl8jZ2gD8kDEt+9qGqV9wNiLcA9a8pN2GPlFwJbSBgiBk+n8TsvkrCD6UlPCbiHsqIiUnxCYq9jqYgX+l7WZlg/nx9fi/qpam23tTivZcJNNey5wDNWibv4u7Mn6sICu4y67e3/MRQ2nC8pg8Bpg3bGm3AfFOfyi/D7BrdEXK77OuUn2XefwDOrLvcggyavuGkcIRYV5kpo1vyKH3flS8fJwRCWcnl5QTsa7DkWEYsvWtBPGKKNJ8piPt+Iincx1hEAqQSaF3vh+qtK72Nz/Y2VU/ZQ7+BlBLBwjFYP4v5gMAAAwdAABQSwMEFAAICAgAbmBaSQAAAAAAAAAAAAAAAAsAAABjb250ZW50LnhtbM1YzY7bNhC+9ykEFeiN5tqbBbKq7RxS5NDu9pBNi15paSSzoUSBpCz72L5GnqH3IEDfIXmH9D06pH5MOZarttugF++a/Gbm48fhzOwun+1zEexAaS6LVTifXYUBFLFMeJGtwh9evSBPw2frL5YyTXkMUSLjKofCkFgWBn8GaF3oqNldhZUqIsk011HBctCRiSNZQtFZRT46crGaFW0OYrK5A/vWBvZmqrHFDmzZZnpkB/atE8XqqcYWi6L65qmcarzXgqQSVc9LZvgJi73gxetVuDWmjCit63pWX8+kyuj89vaWut2ecNzjykoJh0piCgJsME3nszntsDkYNpWfxfqUiirfgJosDTPsk1stFWiE4HFtYk5z5NsM8muXTc6uXTYic7xlanKeOfAwVa6T6alynfi2OTPbkft9Su9x033c3x3zSuVTY1nsQKpY8XLyMRu0by+l7Klag+axO7qLq6sntPnuoeuL8FpxA8qDxxfhMRNxr7jMz4mGuDlFBIGdTfn+EVkh9IjBgjbbPVgno65/ur97iLeQsyOY/zWY8EIbVhyVUfYSRk96QxWUUplemHR68cXbWvTctiYX46XD7nbQTCXJWSjSuaZYRvARkx2H+stBbb2cD7fUgfx6etFgfkUtpn+SeN3H4q+yvj+lsiqSpgw0YsC+BMXtFhPOLBp48DNYyH/gsu1pnodBoecgutffH+msGylJrjEZMOllGXnWw76h8v00dzbRZZKeejx59LHW1+bc3b56Se0esW0TG0MbyRsXFuG6mw2aWqBpv5DijEBSFgNJIBZ6vWxqfL8cNN8t71V4x7FhOHmDB1bgS8NC3EFzLg6r8CtWSv31Ca5ZDIOBa4snGRQoBJYLXXOtB4iSmxgL6o4p7rKPXqb2DfzMfqwu0/IwUygdtIH833B6oQBGCHlb/yWD76WR7rzB82+/Cx6eBy8hqwRToxKNGTyaXnQs79p1VhmJrZTHxPnpE9J9Ds4Wy3kfrD2Dq1M4GYgqL8LO0l8kJb4vUIaDDlIZbRSw12QD+NTQoQ3deWzhNU9sV18sZjdP89zx9+iMc1Nj3JSsT4jhis+q2bKLW+DZFh/8k9nNAmNf5ltpILI0PGeC+MZGVTCdtmHnaXeLOU6AoEjJMiDds0tZJczJmbzzNON4wnUp2KHl03qzYwMO2ySXCXoSipjNp1TpaF60GxuZHI7lDYdLlugtgFkvm9C2eWAG22JENBgbsWN19JnyIiGCbUDgdJEyoZ1oDcp9thbNod+/ef/2wy8ffkWxmmVHiRw1HJi2qXcG6jK4dexm8RaqCQ4OwAxgO1p0iKRRmsQgBPG9dFcwJGyz4ExIm5gn7NBf3zOYqICYQ4lIbRRKFQZd8z2zh47sTrnmyZK2vy7pqfPHD2ePcjEgPRHicZVJhWQmHOxglz1PvcH2zOefVScoMsH19v8m1WKSVIvPKtXHt7/98eb3j+/e/U2x/JWOlRUpITjj4R+6dgTTXtsbFCg6KGF05N836z8BUEsHCM7rnUltBAAA/xEAAFBLAwQUAAgICABuYFpJAAAAAAAAAAAAAAAACAAAAG1ldGEueG1sjZNPj9MwEMXvfIrI7NWxnTQhtdKsxIHTIpAoErcqtWeLwbEr29mUb4/zr3ShB45583szz2Onfrx0OnkB55U1O8RSihIwwkplTjv0df8BV+ixeVPb52clgEsr+g5MwB2ENolW4/lc2qHeGW5brzw3bQeeB8HtGcxq4bc0nwbNykUr83OHvodw5oQMw5AOeWrdibDtdkum6opKceXOvdMTJQUBDeMET1jKyMqOCf831MjeRrLWXgeN+Bx6GpdRuiHz90qfnJT63gEim5OYsA0tflEwvEXJcvybhWeoWbc7xmjqKYxw0IZI4GiGJqOsxIzirNyzglPGiyKtypLRMt9WNbnjqKXg96w5z6q0eJezKLOsJis2TwWpQrx4LHs39Wo+79nHPPuyjPin/NolfgkNvmF/0Ys8s9cH5ENs4YMSyaSH9qgBC9ubEJeCZlGA1qtWLpo9/gARVpUisjQ+gYGYyrrmSR0dfJo2SoqUpZs0e3hSpr8cvlXlodwkN8Dh7OzYjzDa0Yf3vdISZ0v+Px1r8uqCyL2fofkNUEsHCObwUoaYAQAASgMAAFBLAwQUAAgICABuYFpJAAAAAAAAAAAAAAAACgAAAHN0eWxlcy54bWzlWdlu3DYUfe9XCDIQJEA1ksZJmpl4xg8Jgi5JEDhp32mJ0rChRIGkZsljv6df1S/pJSVytFvZ2qC1jcRDnnt5eHfJV9fHjDp7zAVh+cYNF4Hr4DxiMcnTjfvruxfeE/d6+90VSxIS4XXMojLDufSEPFEsHBDOxbra3Lglz9cMCSLWOcqwWMtozQqcG6F1E73WR1UrWtlccQ1uSkt8lHOFFbYli27nn6zBTemYo8NcYYUFmzbFEzZX+CiolzAvYlmBJOmwOFKSv9+4OymLte8fDofF4XLBeOqHq9XK17uWcGRxRcmpRsWRjylWhwk/XIS+wWZYorn8FLZJKS+zW8xnmwZJ1PNqwbEACFxXxeU8RU2ZVnzt09nRtU9HzBztEJ8dZxrcDpXLeH6oXMZN2QzJ3Yh/n/ivYFP/8+rlOa54NvcshW2ZKuKkmH3NCt2UZ4xZqkqgSnZNdxkED/3qcwN9mIQfOJGYN+DRJDxCNLIWZ9mQ0QAX+oDw8F6FvEFzdelRzY98jgvGpSWSzC92YJ2lTdWdzOh4qqpdA015HA9Cgc6lD2kLSePtCT5ctGrZtP1XvgY169ekQBj4CmNTAMx7LrY8te0gYWUeV2lXGQMfC8yJ2kJUi61bGpoRQ9knqKx7SENDq7ASTE222SsNqmHMy4RHcggyVqwb0q3MF+JSDrni3Y2v9jzVVaBu1uc0munS3ZrOmTDomgmKsBfjiIrtVVXx7LJTfVYkN+5LAuVTX955i3IIUihLBpoRetq491DBxNMOrlp0nZZqhfdSnMOtIXnEgQjRQhRERlBe9ogTHRv+NLXn+Hf0WzlNq4GZQ+kkJM4+h9MLjvEIocbW12Twmkmm7+s8+/kX5+0z5wanJUV81ERjAl/MXv5Y3NXr1QBnbhXjBJW0HuuM5pqwLhlehCl1DbxAHKUcFTuvgLzBXBKYBastQIMWVngxERLlaiwMl4tHWXY2oUqXvqAmOhL+CVtTlKclSmGXSr0QQYGQHPi9fOd2VXhQl1HeDVaNMXoM5MPO7NQKzcaz1321ajKg+Dit2IJ2pKvabv30WvtowPjbq2p4qmeolkfqWAvcDsipP2Uk17UsBbmYpEQKML4+aECn1RGVnMPIfxo6Kgwv3wTmFntGwSlqXpa8xGMkIMxIhqhXUAg6IADNryrQit3A5iR3s5tyVhb6kUQf7duzVSxtHXtB/bF/sVN2y6jR1Y4kg21E0/avP/60GjtKtgMbH2NNdywJdERTBkPzRZIE8NW9pDd8yf+X8SvbZaiwiZXHpHpE2CNa4vsP7qXy6caGLCoKWrvCa4W0P+7ISrLvvudVmrp3l8c7CpypMBNNo1f9a5HPbB5GzVgLMfu2kYxXwKnGOgtkKFnwKCmLaPW3hpvGfXaDxbTLHNvQ7FsNr+3uiYTVHOuXFwTmSRIZfRoMAyzm8PiNDUYwSuIRyIHE6ikPlZKNIOryoA/Vv7uWwwGTdAcjKuRI/LHGWX6SddqG1Q8j3Sw7N/z+0T9ipN4wfRnHWKGBaUSZEByT5p5gJVfTSEKORjk81GGkjAmlLwfzJYiKxqg3OOWAyc9KN26E1bPD+Gxjo4R8UJPQ40K6Y5Hzec6sLRp+kkmNO+aYlLPqBYsHlVzRXwV9kn5nyKw/quDOQDjy2tNnocYmik6slK07vSqy0B0A9Ump9wRwAS9jsWot3JO3Z5/s4Hbnqae1ljAG7us4THXDXe2EH/ToqlcRT2GD4gSWg/Yir8Cd1VsmpXoJYcdff5xPTeQf5wiTep9gm4zfs/8Mvy3/M35TG7eMx+p95nLxcFXAXVUddy4C/aUBBYqrN+bBInxihFD0Xo1OeWxK90UUqG9rmgYCBrMU90Kkd81vPYa+TWPdHc/+aIWqNzIkrApxnkP1otI0NSk2M2CgsFX0t1f6bxNF/b/YYVyht9fX11d+d7FeKTpG6LhdOdG0aCJg7j/1mlxlG3v6m1JQVBDh1AuKejUnb0NzZmOtR8Ooaxl+kobfs+Vd5r2p38VOWHfZs67p+KlqXYrQRxrcuV/hJJG0Cak+P+gZonVSa0knUed0GKDw6CwFE5oFefoxB5IrCB97YeAtH7tblVie/qlZKOD2e8cQBvZBsNY/lvRQCLX5/atx5fhNoH4+3K5WTWC19pXizx/Oen/4r6DbvwFQSwcIXv2vylcGAABFHQAAUEsDBBQACAgIAG5gWkkAAAAAAAAAAAAAAAAnAAAAQ29uZmlndXJhdGlvbnMyL2FjY2VsZXJhdG9yL2N1cnJlbnQueG1sAwBQSwcIAAAAAAIAAAAAAAAAUEsDBBQAAAgAAG5gWkkAAAAAAAAAAAAAAAAaAAAAQ29uZmlndXJhdGlvbnMyL3Rvb2xwYW5lbC9QSwMEFAAACAAAbmBaSQAAAAAAAAAAAAAAABgAAABDb25maWd1cmF0aW9uczIvZmxvYXRlci9QSwMEFAAACAAAbmBaSQAAAAAAAAAAAAAAABgAAABDb25maWd1cmF0aW9uczIvbWVudWJhci9QSwMEFAAACAAAbmBaSQAAAAAAAAAAAAAAAB8AAABDb25maWd1cmF0aW9uczIvaW1hZ2VzL0JpdG1hcHMvUEsDBBQAAAgAAG5gWkkAAAAAAAAAAAAAAAAaAAAAQ29uZmlndXJhdGlvbnMyL3BvcHVwbWVudS9QSwMEFAAACAAAbmBaSQAAAAAAAAAAAAAAABwAAABDb25maWd1cmF0aW9uczIvcHJvZ3Jlc3NiYXIvUEsDBBQAAAgAAG5gWkkAAAAAAAAAAAAAAAAYAAAAQ29uZmlndXJhdGlvbnMyL3Rvb2xiYXIvUEsDBBQAAAgAAG5gWkkAAAAAAAAAAAAAAAAaAAAAQ29uZmlndXJhdGlvbnMyL3N0YXR1c2Jhci9QSwMEFAAICAgAbmBaSQAAAAAAAAAAAAAAAAwAAABtYW5pZmVzdC5yZGbNk81ugzAQhO88hWXO2EAvBQVyKMq5ap/ANYZYBS/ymhLevo6TVlGkquqf1OOuRjPfjrSb7WEcyIuyqMFUNGMpJcpIaLXpKzq7Lrml2zra2LYrH5od8WqDpZ8qunduKjlfloUtNwxsz7OiKHia8zxPvCLB1ThxSAzGtI4ICR6NQmn15HwaOc7iCWZXUXTroJB59yA9i906qaCyCmG2Ur2HtiCRgUCNCUzKhHSDHLpOS8UzlvNROcGh7eLHYL3Tg6I8YPArjs/Y3ogMpuVe4L2w7lyD33yVaHruY3p108Xx3yOUYJwy7k/quzt5/+f+Ls//GeKvtHZEbEDOo2f6kOe08h9VR69QSwcItPdo0gUBAACDAwAAUEsDBBQACAgIAG5gWkkAAAAAAAAAAAAAAAAVAAAATUVUQS1JTkYvbWFuaWZlc3QueG1srVRLasMwEN3nFEbbYqnNqog4WQR6gvQAqjx2BNJIaEYhuX3tkF8pKTHNbr7vPc0MWqz2wVc7yOQiNuJNvooK0MbWYd+Iz81H/S5Wy9kiGHQdEOuzUQ19SBe3ESWjjoYcaTQBSLPVMQG20ZYAyPpnvT4yXbwbAXOxnFVXvs55qIf+fLhWd8X7OhneNkLdA7mGA7TO1HxI0AiTknfW8FCmdtjKo2B5q1NSymBa2gKwUFOkbLYlfKFxnhSfTZmwvyPFBdODGvOTWAiYh92QHOZ5B5lhz2pMTwK2EXl8/7NxA7B5OijxwcPzZ7CO2Lm+5OOB0FwZa8HD4MasbMn57/n8j+vBi6WCowRZnLS3CBOXcorJ3HYPEA9VL6dhLtSvr2D5DVBLBwhLxhbZIQEAAEUEAABQSwECFAAUAAAIAABuYFpJhWw5ii4AAAAuAAAACAAAAAAAAAAAAAAAAAAAAAAAbWltZXR5cGVQSwECFAAUAAAIAABuYFpJQ8DbTqUFAAClBQAAGAAAAAAAAAAAAAAAAABUAAAAVGh1bWJuYWlscy90aHVtYm5haWwucG5nUEsBAhQAFAAICAgAbmBaScVg/i/mAwAADB0AAAwAAAAAAAAAAAAAAAAALwYAAHNldHRpbmdzLnhtbFBLAQIUABQACAgIAG5gWknO651JbQQAAP8RAAALAAAAAAAAAAAAAAAAAE8KAABjb250ZW50LnhtbFBLAQIUABQACAgIAG5gWknm8FKGmAEAAEoDAAAIAAAAAAAAAAAAAAAAAPUOAABtZXRhLnhtbFBLAQIUABQACAgIAG5gWkle/a/KVwYAAEUdAAAKAAAAAAAAAAAAAAAAAMMQAABzdHlsZXMueG1sUEsBAhQAFAAICAgAbmBaSQAAAAACAAAAAAAAACcAAAAAAAAAAAAAAAAAUhcAAENvbmZpZ3VyYXRpb25zMi9hY2NlbGVyYXRvci9jdXJyZW50LnhtbFBLAQIUABQAAAgAAG5gWkkAAAAAAAAAAAAAAAAaAAAAAAAAAAAAAAAAAKkXAABDb25maWd1cmF0aW9uczIvdG9vbHBhbmVsL1BLAQIUABQAAAgAAG5gWkkAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAOEXAABDb25maWd1cmF0aW9uczIvZmxvYXRlci9QSwECFAAUAAAIAABuYFpJAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAXGAAAQ29uZmlndXJhdGlvbnMyL21lbnViYXIvUEsBAhQAFAAACAAAbmBaSQAAAAAAAAAAAAAAAB8AAAAAAAAAAAAAAAAATRgAAENvbmZpZ3VyYXRpb25zMi9pbWFnZXMvQml0bWFwcy9QSwECFAAUAAAIAABuYFpJAAAAAAAAAAAAAAAAGgAAAAAAAAAAAAAAAACKGAAAQ29uZmlndXJhdGlvbnMyL3BvcHVwbWVudS9QSwECFAAUAAAIAABuYFpJAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAADCGAAAQ29uZmlndXJhdGlvbnMyL3Byb2dyZXNzYmFyL1BLAQIUABQAAAgAAG5gWkkAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAPwYAABDb25maWd1cmF0aW9uczIvdG9vbGJhci9QSwECFAAUAAAIAABuYFpJAAAAAAAAAAAAAAAAGgAAAAAAAAAAAAAAAAAyGQAAQ29uZmlndXJhdGlvbnMyL3N0YXR1c2Jhci9QSwECFAAUAAgICABuYFpJtPdo0gUBAACDAwAADAAAAAAAAAAAAAAAAABqGQAAbWFuaWZlc3QucmRmUEsBAhQAFAAICAgAbmBaSUvGFtkhAQAARQQAABUAAAAAAAAAAAAAAAAAqRoAAE1FVEEtSU5GL21hbmlmZXN0LnhtbFBLBQYAAAAAEQARAHAEAAANHAAAAAA=" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "8339" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Fri, 22 Aug 2025 10:52:56 GMT" + }, + { + "name": "etag", + "value": "W/\"8629962f95105d08f779258e479dd1612b08c2b82a07db0dfa5a0e0e27cc40d1\"" + }, + { + "name": "expires", + "value": "Fri, 22 Aug 2025 10:57:56 GMT" + }, + { + "name": "source-age", + "value": "36" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "3" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "5eeb7499fef5210ae0e14783f7084bd35eb19e45" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "42AA:3FECAD:1DA453:20D210:68A84BE3" + }, + { + "name": "x-served-by", + "value": "cache-lis1490055-LIS" + }, + { + "name": "x-timer", + "value": "S1755859976.471604,VS0,VE0" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 876, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-22T10:52:56.143Z", + "time": 391, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 391 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-remote-file_2229433724/recording.har b/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-remote-file_2229433724/recording.har new file mode 100644 index 00000000..f061e023 --- /dev/null +++ b/ods/table/fixtures/generated/loadOdsTable-file-variations-should-load-remote-file_2229433724/recording.har @@ -0,0 +1,153 @@ +{ + "log": { + "_recordingName": "loadOdsTable-file variations-should load remote file", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "af5eaef181e9db6c212c09b3de666321", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 105, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods" + }, + "response": { + "bodySize": 8339, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 8339, + "text": "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" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "8339" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Fri, 22 Aug 2025 10:52:56 GMT" + }, + { + "name": "etag", + "value": "W/\"8629962f95105d08f779258e479dd1612b08c2b82a07db0dfa5a0e0e27cc40d1\"" + }, + { + "name": "expires", + "value": "Fri, 22 Aug 2025 10:57:56 GMT" + }, + { + "name": "source-age", + "value": "36" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "1" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "15c0ce308391faf0b45cce92f158803f532f421e" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "42AA:3FECAD:1DA453:20D210:68A84BE3" + }, + { + "name": "x-served-by", + "value": "cache-lis1490055-LIS" + }, + { + "name": "x-timer", + "value": "S1755859976.078452,VS0,VE1" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 876, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-22T10:52:55.558Z", + "time": 572, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 572 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/ods/table/index.ts b/ods/table/index.ts new file mode 100644 index 00000000..4159eaf3 --- /dev/null +++ b/ods/table/index.ts @@ -0,0 +1,2 @@ +export { loadOdsTable } from "./load.ts" +export { saveOdsTable } from "./save.ts" diff --git a/ods/table/load.spec.ts b/ods/table/load.spec.ts new file mode 100644 index 00000000..8ec7454f --- /dev/null +++ b/ods/table/load.spec.ts @@ -0,0 +1,209 @@ +import { getTempFilePath } from "@dpkit/file" +import { useRecording } from "@dpkit/test" +import { describe, expect, it } from "vitest" +import { loadOdsTable } from "./load.ts" +import { writeTestData } from "./test.ts" + +useRecording() + +const row1 = ["id", "name"] +const row2 = [1, "english"] +const row3 = [2, "中文"] + +const record1 = { id: 1, name: "english" } +const record2 = { id: 2, name: "中文" } + +describe("loadOdsTable", () => { + describe("file variations", () => { + it("should load local file", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadOdsTable({ path }) + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should load local file (multipart)", async () => { + const path1 = getTempFilePath() + const path2 = getTempFilePath() + await writeTestData(path1, [row1, row2, row3]) + await writeTestData(path2, [row1, row2, row3]) + + const table = await loadOdsTable({ path: [path1, path2] }) + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + record1, + record2, + ]) + }) + + // TODO: rebase on own remote fixture + it("should load remote file", async () => { + const table = await loadOdsTable({ + path: "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods", + }) + + expect((await table.collect()).toRecords()).toEqual([ + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + ]) + }) + + // TODO: rebase on own remote fixture + it("should load multipart remote file", async () => { + const table = await loadOdsTable({ + path: [ + "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods", + "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.ods", + ], + }) + + expect((await table.collect()).toRecords()).toEqual([ + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + ]) + }) + }) + + describe("dialect variations", () => { + it("should support sheetNumber", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3], { sheetNumber: 2 }) + + const table = await loadOdsTable({ + path, + dialect: { sheetNumber: 2 }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should support sheetName", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3], { sheetName: "Sheet2" }) + + const table = await loadOdsTable({ + path, + dialect: { sheetName: "Sheet2" }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should support no header", async () => { + const path = getTempFilePath() + await writeTestData(path, [row2, row3]) + + const table = await loadOdsTable({ + path, + dialect: { header: false }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { column_1: 1, column_2: "english" }, + { column_1: 2, column_2: "中文" }, + ]) + }) + + it("should support headerRows offset", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadOdsTable({ + path, + dialect: { headerRows: [2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { 1: 2, english: "中文" }, + ]) + }) + + it("should support multiline headerRows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadOdsTable({ + path, + dialect: { headerRows: [1, 2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { "id 1": 2, "name english": "中文" }, + ]) + }) + + it("should support headerJoin", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadOdsTable({ + path, + dialect: { headerRows: [1, 2], headerJoin: "-" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { "id-1": 2, "name-english": "中文" }, + ]) + }) + + it("should support commentRows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadOdsTable({ + path, + dialect: { commentRows: [2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([record2]) + }) + + it("should support commentChar", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, ["#comment"]]) + + const table = await loadOdsTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should handle longer rows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, [3, "german", "bad"]]) + + const table = await loadOdsTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + { id: 3, name: "german" }, + ]) + }) + + it("should handle shorter rows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, [3]]) + + const table = await loadOdsTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + { id: 3, name: null }, + ]) + }) + }) +}) diff --git a/ods/table/load.ts b/ods/table/load.ts new file mode 100644 index 00000000..7309ec8d --- /dev/null +++ b/ods/table/load.ts @@ -0,0 +1,41 @@ +import { loadResourceDialect } from "@dpkit/core" +import type { Resource } from "@dpkit/core" +import { loadFile, prefetchFiles } from "@dpkit/file" +import type { DataRow, Table } from "@dpkit/table" +import { getRecordsFromRows } from "@dpkit/table" +import { DataFrame, concat } from "nodejs-polars" +import { read, utils } from "xlsx" + +export async function loadOdsTable(resource: Partial) { + const paths = await prefetchFiles(resource.path) + if (!paths.length) { + return DataFrame().lazy() + } + + const dialect = await loadResourceDialect(resource.dialect) + + const tables: Table[] = [] + for (const path of paths) { + const buffer = await loadFile(path) + + const book = read(buffer, { type: "buffer" }) + const sheetIndex = dialect?.sheetNumber ? dialect.sheetNumber - 1 : 0 + const sheetName = dialect?.sheetName ?? book.SheetNames[sheetIndex] + const sheet = sheetName ? book.Sheets[sheetName] : undefined + + if (sheet) { + const rows = utils.sheet_to_json(sheet, { + header: 1, + raw: true, + }) as DataRow[] + + const records = getRecordsFromRows(rows, dialect) + const table = DataFrame(records).lazy() + + tables.push(table) + } + } + + const table = concat(tables) + return table +} diff --git a/ods/table/save.spec.ts b/ods/table/save.spec.ts new file mode 100644 index 00000000..d99afb6f --- /dev/null +++ b/ods/table/save.spec.ts @@ -0,0 +1,19 @@ +import { getTempFilePath } from "@dpkit/file" +import { readRecords } from "nodejs-polars" +import { describe, expect, it } from "vitest" +import { saveOdsTable } from "./save.ts" +import { readTestData } from "./test.ts" + +const row1 = { id: 1, name: "english" } +const row2 = { id: 2, name: "中文" } +const table = readRecords([row1, row2]).lazy() + +describe("saveOdsTable", () => { + it("should save table to file", async () => { + const path = getTempFilePath() + await saveOdsTable(table, { path }) + + const data = await readTestData(path) + expect(data).toEqual([row1, row2]) + }) +}) diff --git a/ods/table/save.ts b/ods/table/save.ts new file mode 100644 index 00000000..9bcd3faf --- /dev/null +++ b/ods/table/save.ts @@ -0,0 +1,21 @@ +import { loadResourceDialect } from "@dpkit/core" +import { saveFile } from "@dpkit/file" +import type { SaveTableOptions, Table } from "@dpkit/table" +import { utils, write } from "xlsx" + +export async function saveOdsTable(table: Table, options: SaveTableOptions) { + const { path } = options + + const df = await table.collect() + const dialect = await loadResourceDialect(options.dialect) + const sheetName = dialect?.sheetName ?? "Sheet1" + + const sheet = utils.json_to_sheet(df.toRecords()) + const book = utils.book_new() + utils.book_append_sheet(book, sheet, sheetName) + + const buffer = write(book, { type: "buffer", bookType: "ods" }) + await saveFile(path, buffer) + + return path +} diff --git a/ods/table/test.ts b/ods/table/test.ts new file mode 100644 index 00000000..b70b0b94 --- /dev/null +++ b/ods/table/test.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises" +import { writeFile } from "node:fs/promises" +import { read, utils, write } from "xlsx" + +// We intentionally don't use dpkit's function here to isolate the tests + +export async function readTestData(path: string) { + const buffer = await readFile(path) + const book = read(buffer, { type: "buffer" }) + const sheetName = book.SheetNames[0] + const sheet = sheetName ? book.Sheets[sheetName] : undefined + return sheet ? utils.sheet_to_json(sheet) : [] +} + +export async function writeTestData( + path: string, + rows: unknown[][], + options?: { sheetNumber?: number; sheetName?: string }, +) { + const book = utils.book_new() + const sheet = utils.aoa_to_sheet(rows) + const sheetNumber = options?.sheetNumber ?? 1 + + for (let i = 0; i < sheetNumber; i++) { + const sheetName = options?.sheetName ?? `Sheet${i + 1}` + utils.book_append_sheet(book, sheet, sheetName) + } + + const buffer = write(book, { type: "buffer", bookType: "ods" }) + await writeFile(path, buffer) +} diff --git a/ods/typedoc.json b/ods/typedoc.json new file mode 100644 index 00000000..f8e49f3a --- /dev/null +++ b/ods/typedoc.json @@ -0,0 +1,4 @@ +{ + "entryPoints": ["index.ts"], + "skipErrorChecking": true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49aa62f1..dcd346fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,12 +261,18 @@ importers: '@dpkit/json': specifier: workspace:* version: link:../json + '@dpkit/ods': + specifier: workspace:* + version: link:../ods '@dpkit/parquet': specifier: workspace:* version: link:../parquet '@dpkit/table': specifier: workspace:* version: link:../table + '@dpkit/xlsx': + specifier: workspace:* + version: link:../xlsx '@dpkit/zenodo': specifier: workspace:* version: link:../zenodo @@ -274,8 +280,6 @@ importers: specifier: workspace:* version: link:../zip - excel: {} - file: dependencies: '@dpkit/core': @@ -359,7 +363,27 @@ importers: specifier: workspace:* version: link:../test - ods: {} + ods: + dependencies: + '@dpkit/core': + specifier: workspace:* + version: link:../core + '@dpkit/file': + specifier: workspace:* + version: link:../file + '@dpkit/table': + specifier: workspace:* + version: link:../table + nodejs-polars: + specifier: ^0.21.0 + version: 0.21.0 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + devDependencies: + '@dpkit/test': + specifier: workspace:* + version: link:../test parquet: dependencies: @@ -404,6 +428,28 @@ importers: specifier: ^6.0.6 version: 6.0.6 + xlsx: + dependencies: + '@dpkit/core': + specifier: workspace:* + version: link:../core + '@dpkit/file': + specifier: workspace:* + version: link:../file + '@dpkit/table': + specifier: workspace:* + version: link:../table + nodejs-polars: + specifier: ^0.21.0 + version: 0.21.0 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + devDependencies: + '@dpkit/test': + specifier: workspace:* + version: link:../test + zenodo: dependencies: '@dpkit/core': @@ -3938,6 +3984,12 @@ packages: utf-8-validate: optional: true + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -8088,6 +8140,8 @@ snapshots: ws@8.18.3: {} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xxhash-wasm@1.1.0: {} yaml@2.8.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bfa5aa97..24b6f845 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,7 +10,6 @@ packages: - db - docs - dpkit - - excel - file - folder - github @@ -20,5 +19,6 @@ packages: - parquet - table - test + - xlsx - zenodo - zip diff --git a/table/data/index.ts b/table/data/index.ts new file mode 100644 index 00000000..d2f09ca2 --- /dev/null +++ b/table/data/index.ts @@ -0,0 +1,2 @@ +export { getRecordsFromRows } from "./record.ts" +export type { DataRow, DataRecord } from "./record.ts" diff --git a/table/data/record.ts b/table/data/record.ts new file mode 100644 index 00000000..82995d95 --- /dev/null +++ b/table/data/record.ts @@ -0,0 +1,116 @@ +import type { Dialect } from "@dpkit/core" + +export type DataRow = unknown[] +export type DataRecord = Record + +export function getRecordsFromRows(rows: DataRow[], dialect?: Dialect) { + const records: DataRecord[] = [] + + const header = getHeaderFromRows(rows, dialect) + const content = getContentFromRows(rows, dialect) + + const labels = getLabelsFromHeader(header, dialect) + if (!labels) { + return records + } + + for (const row of content) { + const isCommentedRow = getIsCommentedRow(row, dialect) + if (isCommentedRow) { + continue + } + + records.push( + Object.fromEntries(labels.map((label, index) => [label, row[index]])), + ) + } + + return records +} + +function getHeaderFromRows(rows: DataRow[], dialect?: Dialect) { + const hasHeader = dialect?.header !== false + const headerRows = dialect?.headerRows ?? [1] + + if (!hasHeader) { + const length = Math.max(...rows.map(row => row.length)) + const labels = Array.from({ length }, (_, idx) => `column_${idx + 1}`) + + return [labels] + } + + const header: DataRow[] = [] + for (const number of headerRows) { + const row = rows[number - 1] + if (row) { + header.push(row) + } + } + + return header +} + +function getContentFromRows(rows: DataRow[], dialect?: Dialect) { + const hasHeader = dialect?.header !== false + const headerRows = dialect?.headerRows ?? (hasHeader ? [1] : []) + const commentRows = dialect?.commentRows ?? [] + const skipRows = headerRows[0] ? headerRows[0] - 1 : 0 + + const content: DataRow[] = [] + for (const [index, row] of rows.entries()) { + const number = index + 1 + + if (number <= skipRows) { + continue + } + + if (headerRows.includes(number)) { + continue + } + + if (commentRows.includes(number)) { + continue + } + + const isCommentedRow = getIsCommentedRow(row, dialect) + if (isCommentedRow) { + continue + } + + content.push(row) + } + + return content +} + +function getLabelsFromHeader(header: DataRow[], dialect?: Dialect) { + if (!header[0]) { + return undefined + } + + const labels = header[0].map(String) + const headerJoin = dialect?.headerJoin ?? " " + + for (const row of header.slice(1)) { + for (const [index, label] of row.entries()) { + const prefix = labels[index] ?? "" + labels[index] = [prefix, label].filter(Boolean).join(headerJoin) + } + } + + return labels +} + +function getIsCommentedRow(row: unknown[], dialect?: Dialect) { + const commentChar = dialect?.commentChar + + if (!commentChar) { + return false + } + + if (typeof row[0] !== "string") { + return false + } + + return row[0].startsWith(commentChar) +} diff --git a/table/index.ts b/table/index.ts index d5e46f0e..71ebbec6 100644 --- a/table/index.ts +++ b/table/index.ts @@ -1,3 +1,4 @@ +export * from "./data/index.ts" export * from "./error/index.ts" export * from "./field/index.ts" export * from "./schema/index.ts" diff --git a/table/table/helpers.ts b/table/table/helpers.ts new file mode 100644 index 00000000..a7638d24 --- /dev/null +++ b/table/table/helpers.ts @@ -0,0 +1,84 @@ +import type { Dialect } from "@dpkit/core" +import { col } from "nodejs-polars" +import type { Table } from "../table/index.ts" + +export async function joinHeaderRows( + table: Table, + options: { dialect: Dialect }, +) { + const { dialect } = options + + const headerOffset = getHeaderOffset(dialect) + const headerRows = getHeaderRows(dialect) + const headerJoin = dialect?.headerJoin ?? " " + if (headerRows.length < 2) { + return table + } + + const extraLabelsFrame = await table + .withRowCount() + .withColumn(col("row_nr").add(1)) + .filter(col("row_nr").add(headerOffset).isIn(headerRows)) + .select(table.columns.map(name => col(name).str.concat(headerJoin))) + .collect() + + const labels = table.columns + const extraLabels = extraLabelsFrame.row(0) + + const mapping = Object.fromEntries( + labels.map((label, index) => [ + label, + [label, extraLabels[index]].join(headerJoin), + ]), + ) + + return table + .withRowCount() + .withColumn(col("row_nr").add(1)) + .filter(col("row_nr").add(headerOffset).isIn(headerRows).not()) + .rename(mapping) + .drop("row_nr") +} + +export function skipCommentRows(table: Table, options: { dialect: Dialect }) { + const { dialect } = options + + const commentOffset = getCommentOffset(dialect) + if (!dialect?.commentRows) { + return table + } + + return table + .withRowCount() + .withColumn(col("row_nr").add(1)) + .filter(col("row_nr").add(commentOffset).isIn(dialect.commentRows).not()) + .drop("row_nr") +} + +export function stripInitialSpace(table: Table, options: { dialect: Dialect }) { + const { dialect } = options + + if (!dialect?.skipInitialSpace) { + return table + } + + return table.select( + // TODO: rebase on stripCharsStart when it's fixed in polars + // https://github.com/pola-rs/nodejs-polars/issues/336 + table.columns.map(name => col(name).str.strip().as(name)), + ) +} + +function getHeaderOffset(dialect?: Dialect) { + const headerRows = getHeaderRows(dialect) + return headerRows.at(0) ?? 0 +} + +function getHeaderRows(dialect?: Dialect) { + return dialect?.header !== false ? (dialect?.headerRows ?? [1]) : [] +} + +function getCommentOffset(dialect?: Dialect) { + const headerRows = getHeaderRows(dialect) + return headerRows.at(-1) ?? 0 +} diff --git a/table/table/index.ts b/table/table/index.ts index de48fdc4..5e83a27b 100644 --- a/table/table/index.ts +++ b/table/table/index.ts @@ -1,3 +1,6 @@ export { processTable } from "./process.ts" export { inspectTable } from "./inspect.ts" export type { Table } from "./Table.ts" +export { skipCommentRows } from "./helpers.ts" +export { joinHeaderRows } from "./helpers.ts" +export { stripInitialSpace } from "./helpers.ts" diff --git a/excel/CHANGELOG.md b/xlsx/CHANGELOG.md similarity index 91% rename from excel/CHANGELOG.md rename to xlsx/CHANGELOG.md index 2eee8dd2..18f034b5 100644 --- a/excel/CHANGELOG.md +++ b/xlsx/CHANGELOG.md @@ -1,4 +1,4 @@ -# @dpkit/excel +# @dpkit/xlsx ## 0.3.0 diff --git a/excel/README.md b/xlsx/README.md similarity index 95% rename from excel/README.md rename to xlsx/README.md index aac3981c..3361aeeb 100644 --- a/excel/README.md +++ b/xlsx/README.md @@ -1,3 +1,3 @@ -# @dpkit/excel +# @dpkit/xlsx dpkit is a fast TypeScript data management framework built on top of the Data Package standard and Polars DataFrames. It supports various formats like CSV, JSON, and Parquet and integrates with data platforms such as CKAN, Zenodo, and GitHub. For more information, please visit the [documentation portal](https://dpkit.datist.io). diff --git a/xlsx/index.ts b/xlsx/index.ts new file mode 100644 index 00000000..5f4f33d9 --- /dev/null +++ b/xlsx/index.ts @@ -0,0 +1,2 @@ +export * from "./table/index.ts" +export * from "./plugin.ts" diff --git a/excel/package.json b/xlsx/package.json similarity index 61% rename from excel/package.json rename to xlsx/package.json index 99ad5994..7fd2fb92 100644 --- a/excel/package.json +++ b/xlsx/package.json @@ -1,5 +1,5 @@ { - "name": "@dpkit/excel", + "name": "@dpkit/xlsx", "type": "module", "version": "0.3.0", "exports": "./build/index.js", @@ -17,9 +17,19 @@ "validation", "quality", "fair", - "excel" + "xlsx" ], "scripts": { "build": "tsc" + }, + "dependencies": { + "@dpkit/core": "workspace:*", + "@dpkit/file": "workspace:*", + "@dpkit/table": "workspace:*", + "nodejs-polars": "^0.21.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + }, + "devDependencies": { + "@dpkit/test": "workspace:*" } } diff --git a/xlsx/plugin.ts b/xlsx/plugin.ts new file mode 100644 index 00000000..7a555bba --- /dev/null +++ b/xlsx/plugin.ts @@ -0,0 +1,26 @@ +import type { Resource } from "@dpkit/core" +import { inferResourceFormat } from "@dpkit/core" +import type { TablePlugin } from "@dpkit/table" +import type { SaveTableOptions, Table } from "@dpkit/table" +import { loadXlsxTable, saveXlsxTable } from "./table/index.ts" + +export class XlsxPlugin implements TablePlugin { + async loadTable(resource: Partial) { + const isXlsx = getIsXlsx(resource) + if (!isXlsx) return undefined + + return await loadXlsxTable(resource) + } + + async saveTable(table: Table, options: SaveTableOptions) { + const isXlsx = getIsXlsx(options) + if (!isXlsx) return undefined + + return await saveXlsxTable(table, options) + } +} + +function getIsXlsx(resource: Partial) { + const format = inferResourceFormat(resource) + return ["xlsx"].includes(format ?? "") +} diff --git a/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-multipart-remote-file_2310816283/recording.har b/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-multipart-remote-file_2310816283/recording.har new file mode 100644 index 00000000..60c6f26f --- /dev/null +++ b/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-multipart-remote-file_2310816283/recording.har @@ -0,0 +1,292 @@ +{ + "log": { + "_recordingName": "loadXlsxTable-file variations-should load multipart remote file", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "1b1c5630b27a21df7a331df2b634c18e", + "_order": 1, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 106, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx" + }, + "response": { + "bodySize": 6230, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 6230, + "text": "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" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "6230" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Thu, 21 Aug 2025 14:47:30 GMT" + }, + { + "name": "etag", + "value": "W/\"cd696b652199bc7fb2221c1c0e1b45da1d93915d77c5e2623482a7c71447e884\"" + }, + { + "name": "expires", + "value": "Thu, 21 Aug 2025 14:52:30 GMT" + }, + { + "name": "source-age", + "value": "68" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "0" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "9656126350399d8864dd4c54c3dcc622dfc86abc" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "4BBA:DBAB0:B31D7:C68DB:68A7313D" + }, + { + "name": "x-served-by", + "value": "cache-lis1490030-LIS" + }, + { + "name": "x-timer", + "value": "S1755787651.670179,VS0,VE1" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 873, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-21T14:47:30.215Z", + "time": 507, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 507 + } + }, + { + "_id": "1b1c5630b27a21df7a331df2b634c18e", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 106, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx" + }, + "response": { + "bodySize": 6230, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 6230, + "text": "UEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAALAAAAX3JlbHMvLnJlbHOtks9KAzEQh+99ipB7d7YVRGSzvYjQm0h9gJjM/mE3mTAZdX17gwhaqaUHj0l+8803Q5rdEmb1ipxHikZvqlorjI78GHujnw736xu9a1fNI85WSiQPY8qq1MRs9CCSbgGyGzDYXFHCWF464mClHLmHZN1ke4RtXV8D/2To9oip9t5o3vuNVof3hJewqetGh3fkXgJGOdHiV6KQLfcoRi8zvBFPz0RTVaAaTrtsL3f5e04IKNZbseCIcZ24VLOMmL91PLmHcp0/E+eErv5zObgIRo/+vJJN6cto1cDRJ2g/AFBLBwhmqoK34AAAADsCAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABoAAAB4bC9fcmVscy93b3JrYm9vay54bWwucmVsc72Sz2rDMAyH730Ko/viJBtjjDi9lEGvW/cAxlHi0MQ2kvanbz+PjS2FUnYoOxnJ9vf7QGrW7/OkXpF4jMFAVZSgMLjYjWEw8Lx7uLqDdbtqHnGykp+wHxOr/CewAS+S7rVm53G2XMSEId/0kWYruaRBJ+v2dkBdl+WtpiUD2iOm2nYGaNtVoHaHhH9hx74fHW6ie5kxyIkIzXKYkDPR0oBi4KsuMgf06fj6kvFvkfbsEeXX4KeV5T6P6pzM9T/L1Odkbi46GG8JuyehvGXL+Szb3zKrRh/tXvsBUEsHCHDlsOnaAAAAsgIAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAGAAAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbL1W3W7bNhS+31NowhBsQGZJ9E/SRHaROvU6LI2NOGmB3dEWZRGhSJWk7CZ322v0GXZfFNg7tO/QvccOSVlS7WxoLjZfyOI55OF3vkOeT/HTtznz1kQqKvjQjzqh7xG+FAnlq6F/cz358dj3lMY8wUxwMvTviPKfjr6JN0LeqowQ7UEAroZ+pnVxEgRqmZEcq44oCAdPKmSONQzlKlCFJDixi3IWoDAcBDmm3HcRTuTXxBBpSpfkXCzLnHDtgkjCsAb4KqOF8kex3WEmvZQyTeRLkQDsFDNFwFfgFZkTfVNYv74WMzBs3cEoDqrFozihsINhxZMkHfpn0cmzrplhJ7yiZKNa795SMCF/Tob+oOd7CUlxyfRPkiZjYx/6WpbE9yRdZbDlBUn1dktPZWIzgQRLhtUXRrP6gnLypXVaagbG+V2+EExtAxvPldjAZi+AYShm2/ErkaI2aLyYE0aWmiS1SRQG0ZgwZtL0vTUkNPS5YZ353obyRGxmUmhYZQ9JBcecgIUQtyZ/kzmcnHsh8vkSM2A0CtvjSxts12q4v8B3orQcOi+QavEZ4jH8rUkFDHhtxtV+BTZHskoAsn3jStVrKmkCt9+3RZvYMwWHoKoVsPeCmPIAil5n0Pe3i7YTRzGUWNmnKTbDhTIUVlxkNElIQ02O3w59BP/U3Clzge4sJ4bNRGfwFnX6BuQjovXDKl73gXjHneP+UdRD1XPwuNhRGB1XwVG/gYva4dGg3w0HEUK93pPHRkf9LRVh9KQOH/5b+MCxbUtwjjUexVJsHthwWSotclekHeO2nu6U7yCzhe6aQnvCXakLsibMopKGWcjQvJj7oCznsMA0l/UojIO1AVjNeLY/I6pnBID6f4aOaujIAutZYHwHmIPuZiAHXUuYk8Ilw3W7fHUxnf5yM/v+uzN0eIALoU7ntuUjN/jWNEV0iA7DH+IgNRsQvmJUZf99/v+QfLdOvruXPNpJvvvVyXcfTh6BIrST//z+j7/e/fn5w4ed9IPWKS4k5XpaWLnyMmjWoLNNh1/t9fzaAqJV8yEkvRdcYzYGDSSyxSkIuabLfUfglO8llisKGzOrQGHn6PioX8lSM4R2aj8E+uio/kF7WAgNlXnIk1nNaQKkArSiGQe16paFt2B4eXvGk9cZ1aSpOrT0sciNpCujPNzYREENEXC9EolbiplSqbSRjssyX9h9fCfldQOvxq+rXttm7LygcEqMCoEJtsNO0gohtcRUG0lZkam0+YDs8euM8CmwahwFkXN67/quaqlcqchkF1OFFVwzU3Eiz53OqL1S1YiAJ8fkxPLnJTRNoYZc2+D1uto8TZLn6+ZejGKRJE7+Rwc4L07H9nnwphT69Bo+ZpR3CR8qVyLH/PCKrOCDQzqnnRehj+8+vv/026ffoUk0kUxQh6cdNEL2b2ZnVu44aKOHYf2FOPobUEsHCB7NXvs6BAAAZQoAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAGAAAAHhsL3dvcmtzaGVldHMvc2hlZXQyLnhtbMWXTW/bOBCG7/srBB32ttGXpThZ20VjKdsF0iao0xbYG21RFhGJ1JK03eTX75CUaZlOi6LA1jk41EtqOM9wCM1M3nxtG2+LuSCMTv3oIvQ9TFesJHQ99T893v4x9j0hES1Rwyie+s9Y+G9mv012jD+JGmPpgQEqpn4tZXcdBGJV4xaJC9ZhCjMV4y2S8MjXgeg4RqV+qW2COAyzoEWE+sbCNf8RG6yqyArnbLVpMZXGCMcNkuC+qEkn/NlE7/DAvYo0EvP3rAS3K9QIDHMdWuMFlp86PS8f2QMIU1/yDfaD2STo351NSgIbqKB4HFdT/210XUShWqJXfCZ4JwZjb8Uaxv8up3428r0SV2jTyL84KedK7+17nKxr2PIOV3LvkSdqtrsFwE2DxJGo3r4jFB+r9xvZgLh4bpesEXvDauYj28Fm7yDCcJjDiX8wZ1aQaLnADV5JXFq7knXKpTluGgXqe1sgmvpUhb3xvR2hJds9cCbhNZ0lxpTKgCVjT4pfkUPmvDDWLlaogYhG4fD5g7blqir2d+iZbXQMzaw6Ioo9BFttYYFKuSWTkrUfVfAcZ29iSJ5F1xCIJ/j9bIfqnDSmOkBj64B3eO7d7vQ2vWUI2r/9kX/bzM13zfSu9nZuvudO/E07hvrYo/inPDqK35FTgU3g4Xif2Lf63sFF6fMZMuwdVlYgwsnFKPX3L+0XziZwDYT+VReiQZ0YpFlNyhJT+9iir1M/TjMYEaqPT8hnlTmpSrlS1qBlF0kah6NwHGVJnKXK5R+3HYVx2huP00trPjwynxjLSZhFOriBIdBYOZJoNuFs98qGq42AmBpwR9zHyNwSx7M+eHDszNzkO7zFjfaKqyAAoBqoNIU7m2liJdy4wtwVclcoBkIAFL8WJbYosXYDDgDWU1C3s2gSbFWo92xmxVivEHpFfLxiblZcHWBdoRgI/xvsCNLxddrE0iYntA7LjVkBH5MDbuLgJi6uKxTJL8BNXmcdWdaRm6Qjx825K+SuUIzOB5JakNQFSV0QV8hdoUjPB5JZkMwFyVwQV8hdocjOB3JpQS5dkEsXxBVyVyguzwcytiBjF2TsgrhC7grF+HwgVxbkygW5ckFcIXeF4up8IFF4+LaGJx/X0GU5UfITpRgqBicY1A0dJ1Ted7ox8Wooy6GjOtTy65Pq3irQnlg+xskLoxI1c+h2MB/ECFo2SVanE4Hpcd4jviawcaN7jajvPfQIClT935SFeljrrgFCdZFG0ThM7Z/vVQzqfj0Vp3vj4OGm85YNWj29peWXmkh8OCaoPuesVd2ZUD0EVRrriCKFnUqOBs1PRbiQqgv4sGmXehffdGW2zuyfv/QV2zAkeUfgw6saCpBgO2Sak45xyRGRqvpd43uuwaCBoY81pvcQNjXRYb4gL9iUnYOGZSPwretT7ytMPagjxTw35bA4OQvrEcTJhPRWR88rSVXBIVGpjdv3rHxflsX2kMizCStL08lBTg3GMDQWZ7+jtvtzrn+jWP970Cv76Ukw3B8ebbs++w9QSwcIkysgSHsEAADyDwAAUEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAAUAAAAeGwvc2hhcmVkU3RyaW5ncy54bWxdjkEKwjAQRfeeIsxeU0VEJIkLwRPoAUI7toFmUjtT0bN4BvcieBu9hxVBweV7Dz7fLI+xVgdsOSSyMB5loJDyVAQqLWw36+EcFIunwteJ0MIJGZZuYJhF5akjsTAF1VHYd7j6cj9KbKESaRZac15h9DxKDVJfdqmNXnpsS81Ni77gClFirSdZNtPRBwJnODgjLhRGizP6TR9DPuK/QyrrwNW/flwvz/P9cbv9gu5/uxdQSwcIFzy6AbEAAAD1AAAAUEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAAPAAAAeGwvd29ya2Jvb2sueG1sjVJLbtswEN33FAT3tT4JDMewHBQJgmbRD5o0WY/IkcWaIgVybDfdtdfoRXqM5EYd0VbidlF0Jc7MmzfvzWhx/rWzYoshGu8qWUxyKdApr41bVfLz7dXrmRSRwGmw3mElHzDK8+Wrxc6Hde39WnC/i5Vsifp5lkXVYgdx4nt0XGl86IA4DKss9gFBxxaROpuVeT7NOjBO7hnm4X84fNMYhZdebTp0tCcJaIFYfWxNH+Vy0RiLd3tDAvr+PXQs+wKsktnyWfbHIGpQ601/xehKNmAjstHW7z7UX1AROwJrpdBAWJzlpyPkDwpPjOQxnBwSdwZ38aU+hAIYsMVbqCvJm21MiHQzbCCFw7i3Pphv3hHYGxW8tZWksDlISUjujcdJtkZG/Q0nqD8NW6jkNGfmnXGaudGsWh41K87KMXdvNLV86OnJ7JRXf5+SSc3Dy5sNZUeO0s3Gr3BpoY8/H389fX/6UQyqOH2tmTT9KsTVrYmmtqwrzA0XwrUuB9JjgmROlEft5T/aT5KmUQjbV3xBQxgYf+E3jl0Wg/GAzTuvmeINiznUn897iC/RErDNSZ4XiXa82PI3UEsHCNrr7J+2AQAADgMAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAADQAAAHhsL3N0eWxlcy54bWztWV1vmzAUfd+vsPy+EtIkXaeQrs2aadJWVW0qTZr24IABq8ZGxmmT/vpd8xWyhAa1m5p25AU43Hvu4dgXTBieLCKO7qhKmBQOtg86GFHhSo+JwME308n7DxglmgiPcCmog5c0wSejd8NELzm9DinVCBhE4uBQ6/ijZSVuSCOSHMiYCjjjSxURDYcqsJJYUeIlJiniVrfTGVgRYQKPhmIeTSKdIFfOhXbwYQmhLH8sPaj95fzi/Or0G0bZua8eCB70sLU1+NN6WL8mrLMeNjBhVi5nNPSlWKnq4wwAHhJRdEe4g08VI9zkuCFRCZiRorZBfBIxvsyArgGSh/xsJy2SUdUSVtM7rykd3QgGE4ii79fNbUg3xnDG+do0MMBoGBOtqRITOED5/nQZw/AJmJQZTRq3IzpQZGl3+80TEsmZZ1QEY8mlQiqYOXiS/wzNbPPEeJzyVzjLaukGrnImlQctV1xnFxcQ8hgJpCD8s7yHdvQJTyguwZu4gEZDTn0NZRQLQrPVMjZqpNYygp0iwwjJmJ9WAaVt7mAdMvcWF/U2UKi+gWVaNuBtyvIdMMalnF+bjB/+yp0OiFn4iMQxX55yFoiIGlyrOShP0bM0fw2ayD9iLpXU1NXpbS6Ds6qm7+EmYEam2IXUfHf9RjMakqI8CqViDxBoZnJABVXQN4gJL5UGmUmomLidygnTpcuaLvSV1CQTAUFw39XMNRSZWxjdKxJPIazIAZviUjgKmQcFSj4u3Vvq5ZdjDF34W53Kw9esqmKPepUH7jLLrpplRqwVk4vptmKaNFgrphXTimnFtGJe6kHZO9wrNfZeqentlZruPqk5LsRY1eV7tpivrONte/s6vpneKvLcrjNrebTwiwZ8nYv6La8/jUf5ie8/e+9ZGfq83qiba/+tbX/xVdteedat9ay/2zMXAKpay1Z/XL5Fy/7p4utV9OYTHgNN59mGZ40mWuvZW2zOvbNs/x8BL2RZezd71jTrveppZuXvVJUvJeX71QBXUDSbM66ZyKeJO09AwVmGlSXMhzsHX5ivoXzlg1VDc/g4zVhGESlY7H4tzaABDfrZ+VVSDWqpejuo5kpR4S5LpqNapqNmTGu6PtSy9R9nu6TKzKmS6Dgd3tWAwsHq6/roN1BLBwgYa2o4WQMAAKIfAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABAAAABkb2NQcm9wcy9hcHAueG1snZBNS8QwEIbv/ooSvG6T2g/DknYRxJOyglW8Lelkuhtpk5DEpfvvjQrunmVOLw88886IzTJP2RF90Na0pMgZydCAVdrsW/LaP6w4yUKURsnJGmzJCQPZdFfi2VuHPmoMWTKY0JJDjG5NaYADzjLkCZtERutnGVP0e2rHUQPeW/ic0UR6w1hDcYloFKqV+xOSX+P6GP8rVRa++4W3/uSSrxO9jXLq9YwdE/QcxJ1zkwYZ0/Hdox48bn9stMpZmvL6ScL25X238Ca7wLvU9QMh0rocFWfI2VhVqNRtWUPB1QD10IBCXhTIZFGDoJd7BD3/rvsCUEsHCIcjpoLzAAAAgAEAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAEQAAAGRvY1Byb3BzL2NvcmUueG1sbZDLasMwEEX3/QqjvT12A6EY29l11UIhKXQrpKkjYj3QTOPk7yuL1k0hy8s9c5But7vYqThjJONdL5qqFgU65bVxYy/eD8/lkyiIpdNy8g57cUUSu+GhU6FVPuJb9AEjG6QiiRy1KvTiyBxaAFJHtJKqRLhUfvpoJacYRwhSneSI8FjXW7DIUkuWsAjLsBrFj1KrVRm+4pQFWgFOaNExQVM18Mdaw9eAdy9+yxuaMVq6C+dmJS9kVmqe52reZC69v4GP15d9/mpp3DKVQjEs+0Q8m2XWoe7gNub0f7zhG1BLBwiufeaq3wAAAIgBAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABMAAABbQ29udGVudF9UeXBlc10ueG1sxZRPSwMxEMXv/RRLrrKbbQ8istseBI9asJ4lJtNu6G4SZmJtv72TtRX8g7a46CUhZN77vQxJqtm2a7MNIFnvajEuSpGB095Yt6rF/eI6vxCz6aha7AJQxrWOatHEGC6lJN1Ap6jwARzvLD12KvISVzIovVYrkJOyPJfauwgu5jF5iGl1yzi0BrK5wnijOqiFfEBoSRZpFNnVqyAxa6FCaK1WkfPJjTMfaPmelJR9DTU20BkXCPk1advuYc8e14/erwsu/iNwQlIDEEn20zixT8P65dJqMF4/dSwpKCAo05vxKd78T8wx+acc1CgEcxeRbxsNnOGd9xH9OFyFQSPwXHTKup/6EHctDN6A3vQ7Mgvn6ANJxvwaDltWGjB5YEvAaI9ka49wOvzw+pL6M3FUyf7Dmr4AUEsHCLHfskU1AQAA3wQAAFBLAQIUABQACAgIADm6okpmqoK34AAAADsCAAALAAAAAAAAAAAAAAAAAAAAAABfcmVscy8ucmVsc1BLAQIUABQACAgIADm6okpw5bDp2gAAALICAAAaAAAAAAAAAAAAAAAAABkBAAB4bC9fcmVscy93b3JrYm9vay54bWwucmVsc1BLAQIUABQACAgIADm6okoezV77OgQAAGUKAAAYAAAAAAAAAAAAAAAAADsCAAB4bC93b3Jrc2hlZXRzL3NoZWV0MS54bWxQSwECFAAUAAgICAA5uqJKkysgSHsEAADyDwAAGAAAAAAAAAAAAAAAAAC7BgAAeGwvd29ya3NoZWV0cy9zaGVldDIueG1sUEsBAhQAFAAICAgAObqiShc8ugGxAAAA9QAAABQAAAAAAAAAAAAAAAAAfAsAAHhsL3NoYXJlZFN0cmluZ3MueG1sUEsBAhQAFAAICAgAObqiStrr7J+2AQAADgMAAA8AAAAAAAAAAAAAAAAAbwwAAHhsL3dvcmtib29rLnhtbFBLAQIUABQACAgIADm6okoYa2o4WQMAAKIfAAANAAAAAAAAAAAAAAAAAGIOAAB4bC9zdHlsZXMueG1sUEsBAhQAFAAICAgAObqiSocjpoLzAAAAgAEAABAAAAAAAAAAAAAAAAAA9hEAAGRvY1Byb3BzL2FwcC54bWxQSwECFAAUAAgICAA5uqJKrn3mqt8AAACIAQAAEQAAAAAAAAAAAAAAAAAnEwAAZG9jUHJvcHMvY29yZS54bWxQSwECFAAUAAgICAA5uqJKsd+yRTUBAADfBAAAEwAAAAAAAAAAAAAAAABFFAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLBQYAAAAACgAKAIUCAAC7FQAAAAA=" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "6230" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Thu, 21 Aug 2025 14:47:30 GMT" + }, + { + "name": "etag", + "value": "W/\"cd696b652199bc7fb2221c1c0e1b45da1d93915d77c5e2623482a7c71447e884\"" + }, + { + "name": "expires", + "value": "Thu, 21 Aug 2025 14:52:30 GMT" + }, + { + "name": "source-age", + "value": "68" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "1" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "d02e204150490597c8408874bbf588df0b45f5ca" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "4BBA:DBAB0:B31D7:C68DB:68A7313D" + }, + { + "name": "x-served-by", + "value": "cache-lis1490030-LIS" + }, + { + "name": "x-timer", + "value": "S1755787651.678922,VS0,VE1" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 873, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-21T14:47:30.215Z", + "time": 518, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 518 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-remote-file_504643743/recording.har b/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-remote-file_504643743/recording.har new file mode 100644 index 00000000..4cd156e6 --- /dev/null +++ b/xlsx/table/fixtures/generated/loadXlsxTable-file-variations-should-load-remote-file_504643743/recording.har @@ -0,0 +1,153 @@ +{ + "log": { + "_recordingName": "loadXlsxTable-file variations-should load remote file", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "1b1c5630b27a21df7a331df2b634c18e", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [], + "headersSize": 106, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [], + "url": "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx" + }, + "response": { + "bodySize": 6230, + "content": { + "encoding": "base64", + "mimeType": "application/octet-stream", + "size": 6230, + "text": "UEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAALAAAAX3JlbHMvLnJlbHOtks9KAzEQh+99ipB7d7YVRGSzvYjQm0h9gJjM/mE3mTAZdX17gwhaqaUHj0l+8803Q5rdEmb1ipxHikZvqlorjI78GHujnw736xu9a1fNI85WSiQPY8qq1MRs9CCSbgGyGzDYXFHCWF464mClHLmHZN1ke4RtXV8D/2To9oip9t5o3vuNVof3hJewqetGh3fkXgJGOdHiV6KQLfcoRi8zvBFPz0RTVaAaTrtsL3f5e04IKNZbseCIcZ24VLOMmL91PLmHcp0/E+eErv5zObgIRo/+vJJN6cto1cDRJ2g/AFBLBwhmqoK34AAAADsCAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABoAAAB4bC9fcmVscy93b3JrYm9vay54bWwucmVsc72Sz2rDMAyH730Ko/viJBtjjDi9lEGvW/cAxlHi0MQ2kvanbz+PjS2FUnYoOxnJ9vf7QGrW7/OkXpF4jMFAVZSgMLjYjWEw8Lx7uLqDdbtqHnGykp+wHxOr/CewAS+S7rVm53G2XMSEId/0kWYruaRBJ+v2dkBdl+WtpiUD2iOm2nYGaNtVoHaHhH9hx74fHW6ie5kxyIkIzXKYkDPR0oBi4KsuMgf06fj6kvFvkfbsEeXX4KeV5T6P6pzM9T/L1Odkbi46GG8JuyehvGXL+Szb3zKrRh/tXvsBUEsHCHDlsOnaAAAAsgIAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAGAAAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbL1W3W7bNhS+31NowhBsQGZJ9E/SRHaROvU6LI2NOGmB3dEWZRGhSJWk7CZ322v0GXZfFNg7tO/QvccOSVlS7WxoLjZfyOI55OF3vkOeT/HTtznz1kQqKvjQjzqh7xG+FAnlq6F/cz358dj3lMY8wUxwMvTviPKfjr6JN0LeqowQ7UEAroZ+pnVxEgRqmZEcq44oCAdPKmSONQzlKlCFJDixi3IWoDAcBDmm3HcRTuTXxBBpSpfkXCzLnHDtgkjCsAb4KqOF8kex3WEmvZQyTeRLkQDsFDNFwFfgFZkTfVNYv74WMzBs3cEoDqrFozihsINhxZMkHfpn0cmzrplhJ7yiZKNa795SMCF/Tob+oOd7CUlxyfRPkiZjYx/6WpbE9yRdZbDlBUn1dktPZWIzgQRLhtUXRrP6gnLypXVaagbG+V2+EExtAxvPldjAZi+AYShm2/ErkaI2aLyYE0aWmiS1SRQG0ZgwZtL0vTUkNPS5YZ353obyRGxmUmhYZQ9JBcecgIUQtyZ/kzmcnHsh8vkSM2A0CtvjSxts12q4v8B3orQcOi+QavEZ4jH8rUkFDHhtxtV+BTZHskoAsn3jStVrKmkCt9+3RZvYMwWHoKoVsPeCmPIAil5n0Pe3i7YTRzGUWNmnKTbDhTIUVlxkNElIQ02O3w59BP/U3Clzge4sJ4bNRGfwFnX6BuQjovXDKl73gXjHneP+UdRD1XPwuNhRGB1XwVG/gYva4dGg3w0HEUK93pPHRkf9LRVh9KQOH/5b+MCxbUtwjjUexVJsHthwWSotclekHeO2nu6U7yCzhe6aQnvCXakLsibMopKGWcjQvJj7oCznsMA0l/UojIO1AVjNeLY/I6pnBID6f4aOaujIAutZYHwHmIPuZiAHXUuYk8Ilw3W7fHUxnf5yM/v+uzN0eIALoU7ntuUjN/jWNEV0iA7DH+IgNRsQvmJUZf99/v+QfLdOvruXPNpJvvvVyXcfTh6BIrST//z+j7/e/fn5w4ed9IPWKS4k5XpaWLnyMmjWoLNNh1/t9fzaAqJV8yEkvRdcYzYGDSSyxSkIuabLfUfglO8llisKGzOrQGHn6PioX8lSM4R2aj8E+uio/kF7WAgNlXnIk1nNaQKkArSiGQe16paFt2B4eXvGk9cZ1aSpOrT0sciNpCujPNzYREENEXC9EolbiplSqbSRjssyX9h9fCfldQOvxq+rXttm7LygcEqMCoEJtsNO0gohtcRUG0lZkam0+YDs8euM8CmwahwFkXN67/quaqlcqchkF1OFFVwzU3Eiz53OqL1S1YiAJ8fkxPLnJTRNoYZc2+D1uto8TZLn6+ZejGKRJE7+Rwc4L07H9nnwphT69Bo+ZpR3CR8qVyLH/PCKrOCDQzqnnRehj+8+vv/026ffoUk0kUxQh6cdNEL2b2ZnVu44aKOHYf2FOPobUEsHCB7NXvs6BAAAZQoAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAGAAAAHhsL3dvcmtzaGVldHMvc2hlZXQyLnhtbMWXTW/bOBCG7/srBB32ttGXpThZ20VjKdsF0iao0xbYG21RFhGJ1JK03eTX75CUaZlOi6LA1jk41EtqOM9wCM1M3nxtG2+LuSCMTv3oIvQ9TFesJHQ99T893v4x9j0hES1Rwyie+s9Y+G9mv012jD+JGmPpgQEqpn4tZXcdBGJV4xaJC9ZhCjMV4y2S8MjXgeg4RqV+qW2COAyzoEWE+sbCNf8RG6yqyArnbLVpMZXGCMcNkuC+qEkn/NlE7/DAvYo0EvP3rAS3K9QIDHMdWuMFlp86PS8f2QMIU1/yDfaD2STo351NSgIbqKB4HFdT/210XUShWqJXfCZ4JwZjb8Uaxv8up3428r0SV2jTyL84KedK7+17nKxr2PIOV3LvkSdqtrsFwE2DxJGo3r4jFB+r9xvZgLh4bpesEXvDauYj28Fm7yDCcJjDiX8wZ1aQaLnADV5JXFq7knXKpTluGgXqe1sgmvpUhb3xvR2hJds9cCbhNZ0lxpTKgCVjT4pfkUPmvDDWLlaogYhG4fD5g7blqir2d+iZbXQMzaw6Ioo9BFttYYFKuSWTkrUfVfAcZ29iSJ5F1xCIJ/j9bIfqnDSmOkBj64B3eO7d7vQ2vWUI2r/9kX/bzM13zfSu9nZuvudO/E07hvrYo/inPDqK35FTgU3g4Xif2Lf63sFF6fMZMuwdVlYgwsnFKPX3L+0XziZwDYT+VReiQZ0YpFlNyhJT+9iir1M/TjMYEaqPT8hnlTmpSrlS1qBlF0kah6NwHGVJnKXK5R+3HYVx2huP00trPjwynxjLSZhFOriBIdBYOZJoNuFs98qGq42AmBpwR9zHyNwSx7M+eHDszNzkO7zFjfaKqyAAoBqoNIU7m2liJdy4wtwVclcoBkIAFL8WJbYosXYDDgDWU1C3s2gSbFWo92xmxVivEHpFfLxiblZcHWBdoRgI/xvsCNLxddrE0iYntA7LjVkBH5MDbuLgJi6uKxTJL8BNXmcdWdaRm6Qjx825K+SuUIzOB5JakNQFSV0QV8hdoUjPB5JZkMwFyVwQV8hdocjOB3JpQS5dkEsXxBVyVyguzwcytiBjF2TsgrhC7grF+HwgVxbkygW5ckFcIXeF4up8IFF4+LaGJx/X0GU5UfITpRgqBicY1A0dJ1Ted7ox8Wooy6GjOtTy65Pq3irQnlg+xskLoxI1c+h2MB/ECFo2SVanE4Hpcd4jviawcaN7jajvPfQIClT935SFeljrrgFCdZFG0ThM7Z/vVQzqfj0Vp3vj4OGm85YNWj29peWXmkh8OCaoPuesVd2ZUD0EVRrriCKFnUqOBs1PRbiQqgv4sGmXehffdGW2zuyfv/QV2zAkeUfgw6saCpBgO2Sak45xyRGRqvpd43uuwaCBoY81pvcQNjXRYb4gL9iUnYOGZSPwretT7ytMPagjxTw35bA4OQvrEcTJhPRWR88rSVXBIVGpjdv3rHxflsX2kMizCStL08lBTg3GMDQWZ7+jtvtzrn+jWP970Cv76Ukw3B8ebbs++w9QSwcIkysgSHsEAADyDwAAUEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAAUAAAAeGwvc2hhcmVkU3RyaW5ncy54bWxdjkEKwjAQRfeeIsxeU0VEJIkLwRPoAUI7toFmUjtT0bN4BvcieBu9hxVBweV7Dz7fLI+xVgdsOSSyMB5loJDyVAQqLWw36+EcFIunwteJ0MIJGZZuYJhF5akjsTAF1VHYd7j6cj9KbKESaRZac15h9DxKDVJfdqmNXnpsS81Ni77gClFirSdZNtPRBwJnODgjLhRGizP6TR9DPuK/QyrrwNW/flwvz/P9cbv9gu5/uxdQSwcIFzy6AbEAAAD1AAAAUEsDBBQACAgIADm6okoAAAAAAAAAAAAAAAAPAAAAeGwvd29ya2Jvb2sueG1sjVJLbtswEN33FAT3tT4JDMewHBQJgmbRD5o0WY/IkcWaIgVybDfdtdfoRXqM5EYd0VbidlF0Jc7MmzfvzWhx/rWzYoshGu8qWUxyKdApr41bVfLz7dXrmRSRwGmw3mElHzDK8+Wrxc6Hde39WnC/i5Vsifp5lkXVYgdx4nt0XGl86IA4DKss9gFBxxaROpuVeT7NOjBO7hnm4X84fNMYhZdebTp0tCcJaIFYfWxNH+Vy0RiLd3tDAvr+PXQs+wKsktnyWfbHIGpQ601/xehKNmAjstHW7z7UX1AROwJrpdBAWJzlpyPkDwpPjOQxnBwSdwZ38aU+hAIYsMVbqCvJm21MiHQzbCCFw7i3Pphv3hHYGxW8tZWksDlISUjujcdJtkZG/Q0nqD8NW6jkNGfmnXGaudGsWh41K87KMXdvNLV86OnJ7JRXf5+SSc3Dy5sNZUeO0s3Gr3BpoY8/H389fX/6UQyqOH2tmTT9KsTVrYmmtqwrzA0XwrUuB9JjgmROlEft5T/aT5KmUQjbV3xBQxgYf+E3jl0Wg/GAzTuvmeINiznUn897iC/RErDNSZ4XiXa82PI3UEsHCNrr7J+2AQAADgMAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAADQAAAHhsL3N0eWxlcy54bWztWV1vmzAUfd+vsPy+EtIkXaeQrs2aadJWVW0qTZr24IABq8ZGxmmT/vpd8xWyhAa1m5p25AU43Hvu4dgXTBieLCKO7qhKmBQOtg86GFHhSo+JwME308n7DxglmgiPcCmog5c0wSejd8NELzm9DinVCBhE4uBQ6/ijZSVuSCOSHMiYCjjjSxURDYcqsJJYUeIlJiniVrfTGVgRYQKPhmIeTSKdIFfOhXbwYQmhLH8sPaj95fzi/Or0G0bZua8eCB70sLU1+NN6WL8mrLMeNjBhVi5nNPSlWKnq4wwAHhJRdEe4g08VI9zkuCFRCZiRorZBfBIxvsyArgGSh/xsJy2SUdUSVtM7rykd3QgGE4ii79fNbUg3xnDG+do0MMBoGBOtqRITOED5/nQZw/AJmJQZTRq3IzpQZGl3+80TEsmZZ1QEY8mlQiqYOXiS/wzNbPPEeJzyVzjLaukGrnImlQctV1xnFxcQ8hgJpCD8s7yHdvQJTyguwZu4gEZDTn0NZRQLQrPVMjZqpNYygp0iwwjJmJ9WAaVt7mAdMvcWF/U2UKi+gWVaNuBtyvIdMMalnF+bjB/+yp0OiFn4iMQxX55yFoiIGlyrOShP0bM0fw2ayD9iLpXU1NXpbS6Ds6qm7+EmYEam2IXUfHf9RjMakqI8CqViDxBoZnJABVXQN4gJL5UGmUmomLidygnTpcuaLvSV1CQTAUFw39XMNRSZWxjdKxJPIazIAZviUjgKmQcFSj4u3Vvq5ZdjDF34W53Kw9esqmKPepUH7jLLrpplRqwVk4vptmKaNFgrphXTimnFtGJe6kHZO9wrNfZeqentlZruPqk5LsRY1eV7tpivrONte/s6vpneKvLcrjNrebTwiwZ8nYv6La8/jUf5ie8/e+9ZGfq83qiba/+tbX/xVdteedat9ay/2zMXAKpay1Z/XL5Fy/7p4utV9OYTHgNN59mGZ40mWuvZW2zOvbNs/x8BL2RZezd71jTrveppZuXvVJUvJeX71QBXUDSbM66ZyKeJO09AwVmGlSXMhzsHX5ivoXzlg1VDc/g4zVhGESlY7H4tzaABDfrZ+VVSDWqpejuo5kpR4S5LpqNapqNmTGu6PtSy9R9nu6TKzKmS6Dgd3tWAwsHq6/roN1BLBwgYa2o4WQMAAKIfAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABAAAABkb2NQcm9wcy9hcHAueG1snZBNS8QwEIbv/ooSvG6T2g/DknYRxJOyglW8Lelkuhtpk5DEpfvvjQrunmVOLw88886IzTJP2RF90Na0pMgZydCAVdrsW/LaP6w4yUKURsnJGmzJCQPZdFfi2VuHPmoMWTKY0JJDjG5NaYADzjLkCZtERutnGVP0e2rHUQPeW/ic0UR6w1hDcYloFKqV+xOSX+P6GP8rVRa++4W3/uSSrxO9jXLq9YwdE/QcxJ1zkwYZ0/Hdox48bn9stMpZmvL6ScL25X238Ca7wLvU9QMh0rocFWfI2VhVqNRtWUPB1QD10IBCXhTIZFGDoJd7BD3/rvsCUEsHCIcjpoLzAAAAgAEAAFBLAwQUAAgICAA5uqJKAAAAAAAAAAAAAAAAEQAAAGRvY1Byb3BzL2NvcmUueG1sbZDLasMwEEX3/QqjvT12A6EY29l11UIhKXQrpKkjYj3QTOPk7yuL1k0hy8s9c5But7vYqThjJONdL5qqFgU65bVxYy/eD8/lkyiIpdNy8g57cUUSu+GhU6FVPuJb9AEjG6QiiRy1KvTiyBxaAFJHtJKqRLhUfvpoJacYRwhSneSI8FjXW7DIUkuWsAjLsBrFj1KrVRm+4pQFWgFOaNExQVM18Mdaw9eAdy9+yxuaMVq6C+dmJS9kVmqe52reZC69v4GP15d9/mpp3DKVQjEs+0Q8m2XWoe7gNub0f7zhG1BLBwiufeaq3wAAAIgBAABQSwMEFAAICAgAObqiSgAAAAAAAAAAAAAAABMAAABbQ29udGVudF9UeXBlc10ueG1sxZRPSwMxEMXv/RRLrrKbbQ8istseBI9asJ4lJtNu6G4SZmJtv72TtRX8g7a46CUhZN77vQxJqtm2a7MNIFnvajEuSpGB095Yt6rF/eI6vxCz6aha7AJQxrWOatHEGC6lJN1Ap6jwARzvLD12KvISVzIovVYrkJOyPJfauwgu5jF5iGl1yzi0BrK5wnijOqiFfEBoSRZpFNnVqyAxa6FCaK1WkfPJjTMfaPmelJR9DTU20BkXCPk1advuYc8e14/erwsu/iNwQlIDEEn20zixT8P65dJqMF4/dSwpKCAo05vxKd78T8wx+acc1CgEcxeRbxsNnOGd9xH9OFyFQSPwXHTKup/6EHctDN6A3vQ7Mgvn6ANJxvwaDltWGjB5YEvAaI9ka49wOvzw+pL6M3FUyf7Dmr4AUEsHCLHfskU1AQAA3wQAAFBLAQIUABQACAgIADm6okpmqoK34AAAADsCAAALAAAAAAAAAAAAAAAAAAAAAABfcmVscy8ucmVsc1BLAQIUABQACAgIADm6okpw5bDp2gAAALICAAAaAAAAAAAAAAAAAAAAABkBAAB4bC9fcmVscy93b3JrYm9vay54bWwucmVsc1BLAQIUABQACAgIADm6okoezV77OgQAAGUKAAAYAAAAAAAAAAAAAAAAADsCAAB4bC93b3Jrc2hlZXRzL3NoZWV0MS54bWxQSwECFAAUAAgICAA5uqJKkysgSHsEAADyDwAAGAAAAAAAAAAAAAAAAAC7BgAAeGwvd29ya3NoZWV0cy9zaGVldDIueG1sUEsBAhQAFAAICAgAObqiShc8ugGxAAAA9QAAABQAAAAAAAAAAAAAAAAAfAsAAHhsL3NoYXJlZFN0cmluZ3MueG1sUEsBAhQAFAAICAgAObqiStrr7J+2AQAADgMAAA8AAAAAAAAAAAAAAAAAbwwAAHhsL3dvcmtib29rLnhtbFBLAQIUABQACAgIADm6okoYa2o4WQMAAKIfAAANAAAAAAAAAAAAAAAAAGIOAAB4bC9zdHlsZXMueG1sUEsBAhQAFAAICAgAObqiSocjpoLzAAAAgAEAABAAAAAAAAAAAAAAAAAA9hEAAGRvY1Byb3BzL2FwcC54bWxQSwECFAAUAAgICAA5uqJKrn3mqt8AAACIAQAAEQAAAAAAAAAAAAAAAAAnEwAAZG9jUHJvcHMvY29yZS54bWxQSwECFAAUAAgICAA5uqJKsd+yRTUBAADfBAAAEwAAAAAAAAAAAAAAAABFFAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLBQYAAAAACgAKAIUCAAC7FQAAAAA=" + }, + "cookies": [], + "headers": [ + { + "name": "accept-ranges", + "value": "bytes" + }, + { + "name": "access-control-allow-origin", + "value": "*" + }, + { + "name": "cache-control", + "value": "max-age=300" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "content-length", + "value": "6230" + }, + { + "name": "content-security-policy", + "value": "default-src 'none'; style-src 'unsafe-inline'; sandbox" + }, + { + "name": "content-type", + "value": "application/octet-stream" + }, + { + "name": "cross-origin-resource-policy", + "value": "cross-origin" + }, + { + "name": "date", + "value": "Thu, 21 Aug 2025 14:46:29 GMT" + }, + { + "name": "etag", + "value": "W/\"cd696b652199bc7fb2221c1c0e1b45da1d93915d77c5e2623482a7c71447e884\"" + }, + { + "name": "expires", + "value": "Thu, 21 Aug 2025 14:51:29 GMT" + }, + { + "name": "source-age", + "value": "7" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000" + }, + { + "name": "vary", + "value": "Authorization,Accept-Encoding" + }, + { + "name": "via", + "value": "1.1 varnish" + }, + { + "name": "x-cache", + "value": "HIT" + }, + { + "name": "x-cache-hits", + "value": "1" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-fastly-request-id", + "value": "a7180c4798eae974b738b59b9cd9680ab8852d2e" + }, + { + "name": "x-frame-options", + "value": "deny" + }, + { + "name": "x-github-request-id", + "value": "4BBA:DBAB0:B31D7:C68DB:68A7313D" + }, + { + "name": "x-served-by", + "value": "cache-lis1490059-LIS" + }, + { + "name": "x-timer", + "value": "S1755787590.959358,VS0,VE1" + }, + { + "name": "x-xss-protection", + "value": "1; mode=block" + } + ], + "headersSize": 872, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2025-08-21T14:46:29.268Z", + "time": 745, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 745 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/xlsx/table/index.ts b/xlsx/table/index.ts new file mode 100644 index 00000000..e300b5a3 --- /dev/null +++ b/xlsx/table/index.ts @@ -0,0 +1,2 @@ +export { loadXlsxTable } from "./load.ts" +export { saveXlsxTable } from "./save.ts" diff --git a/xlsx/table/load.spec.ts b/xlsx/table/load.spec.ts new file mode 100644 index 00000000..ee660a0a --- /dev/null +++ b/xlsx/table/load.spec.ts @@ -0,0 +1,209 @@ +import { getTempFilePath } from "@dpkit/file" +import { useRecording } from "@dpkit/test" +import { describe, expect, it } from "vitest" +import { loadXlsxTable } from "./load.ts" +import { writeTestData } from "./test.ts" + +useRecording() + +const row1 = ["id", "name"] +const row2 = [1, "english"] +const row3 = [2, "中文"] + +const record1 = { id: 1, name: "english" } +const record2 = { id: 2, name: "中文" } + +describe("loadXlsxTable", () => { + describe("file variations", () => { + it("should load local file", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadXlsxTable({ path }) + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should load local file (multipart)", async () => { + const path1 = getTempFilePath() + const path2 = getTempFilePath() + await writeTestData(path1, [row1, row2, row3]) + await writeTestData(path2, [row1, row2, row3]) + + const table = await loadXlsxTable({ path: [path1, path2] }) + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + record1, + record2, + ]) + }) + + // TODO: rebase on own remote fixture + it("should load remote file", async () => { + const table = await loadXlsxTable({ + path: "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx", + }) + + expect((await table.collect()).toRecords()).toEqual([ + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + ]) + }) + + // TODO: rebase on own remote fixture + it("should load multipart remote file", async () => { + const table = await loadXlsxTable({ + path: [ + "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx", + "https://github.com/frictionlessdata/frictionless-py/raw/refs/heads/main/data/table.xlsx", + ], + }) + + expect((await table.collect()).toRecords()).toEqual([ + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + { id: 1, name: "english" }, + { id: 2, name: "中国人" }, + ]) + }) + }) + + describe("dialect variations", () => { + it("should support sheetNumber", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3], { sheetNumber: 2 }) + + const table = await loadXlsxTable({ + path, + dialect: { sheetNumber: 2 }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should support sheetName", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3], { sheetName: "Sheet2" }) + + const table = await loadXlsxTable({ + path, + dialect: { sheetName: "Sheet2" }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should support no header", async () => { + const path = getTempFilePath() + await writeTestData(path, [row2, row3]) + + const table = await loadXlsxTable({ + path, + dialect: { header: false }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { column_1: 1, column_2: "english" }, + { column_1: 2, column_2: "中文" }, + ]) + }) + + it("should support headerRows offset", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadXlsxTable({ + path, + dialect: { headerRows: [2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { 1: 2, english: "中文" }, + ]) + }) + + it("should support multiline headerRows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadXlsxTable({ + path, + dialect: { headerRows: [1, 2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { "id 1": 2, "name english": "中文" }, + ]) + }) + + it("should support headerJoin", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadXlsxTable({ + path, + dialect: { headerRows: [1, 2], headerJoin: "-" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + { "id-1": 2, "name-english": "中文" }, + ]) + }) + + it("should support commentRows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3]) + + const table = await loadXlsxTable({ + path, + dialect: { commentRows: [2] }, + }) + + expect((await table.collect()).toRecords()).toEqual([record2]) + }) + + it("should support commentChar", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, ["#comment"]]) + + const table = await loadXlsxTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([record1, record2]) + }) + + it("should handle longer rows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, [3, "german", "bad"]]) + + const table = await loadXlsxTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + { id: 3, name: "german" }, + ]) + }) + + it("should handle shorter rows", async () => { + const path = getTempFilePath() + await writeTestData(path, [row1, row2, row3, [3]]) + + const table = await loadXlsxTable({ + path, + dialect: { commentChar: "#" }, + }) + + expect((await table.collect()).toRecords()).toEqual([ + record1, + record2, + { id: 3, name: null }, + ]) + }) + }) +}) diff --git a/xlsx/table/load.ts b/xlsx/table/load.ts new file mode 100644 index 00000000..54d847b3 --- /dev/null +++ b/xlsx/table/load.ts @@ -0,0 +1,41 @@ +import { loadResourceDialect } from "@dpkit/core" +import type { Resource } from "@dpkit/core" +import { loadFile, prefetchFiles } from "@dpkit/file" +import type { DataRow, Table } from "@dpkit/table" +import { getRecordsFromRows } from "@dpkit/table" +import { DataFrame, concat } from "nodejs-polars" +import { read, utils } from "xlsx" + +export async function loadXlsxTable(resource: Partial) { + const paths = await prefetchFiles(resource.path) + if (!paths.length) { + return DataFrame().lazy() + } + + const dialect = await loadResourceDialect(resource.dialect) + + const tables: Table[] = [] + for (const path of paths) { + const buffer = await loadFile(path) + + const book = read(buffer, { type: "buffer" }) + const sheetIndex = dialect?.sheetNumber ? dialect.sheetNumber - 1 : 0 + const sheetName = dialect?.sheetName ?? book.SheetNames[sheetIndex] + const sheet = sheetName ? book.Sheets[sheetName] : undefined + + if (sheet) { + const rows = utils.sheet_to_json(sheet, { + header: 1, + raw: true, + }) as DataRow[] + + const records = getRecordsFromRows(rows, dialect) + const table = DataFrame(records).lazy() + + tables.push(table) + } + } + + const table = concat(tables) + return table +} diff --git a/xlsx/table/save.spec.ts b/xlsx/table/save.spec.ts new file mode 100644 index 00000000..f34bd63b --- /dev/null +++ b/xlsx/table/save.spec.ts @@ -0,0 +1,19 @@ +import { getTempFilePath } from "@dpkit/file" +import { readRecords } from "nodejs-polars" +import { describe, expect, it } from "vitest" +import { saveXlsxTable } from "./save.ts" +import { readTestData } from "./test.ts" + +const row1 = { id: 1, name: "english" } +const row2 = { id: 2, name: "中文" } +const table = readRecords([row1, row2]).lazy() + +describe("saveXlsxTable", () => { + it("should save table to file", async () => { + const path = getTempFilePath() + await saveXlsxTable(table, { path }) + + const data = await readTestData(path) + expect(data).toEqual([row1, row2]) + }) +}) diff --git a/xlsx/table/save.ts b/xlsx/table/save.ts new file mode 100644 index 00000000..8bc145ad --- /dev/null +++ b/xlsx/table/save.ts @@ -0,0 +1,21 @@ +import { loadResourceDialect } from "@dpkit/core" +import { saveFile } from "@dpkit/file" +import type { SaveTableOptions, Table } from "@dpkit/table" +import { utils, write } from "xlsx" + +export async function saveXlsxTable(table: Table, options: SaveTableOptions) { + const { path } = options + + const df = await table.collect() + const dialect = await loadResourceDialect(options.dialect) + const sheetName = dialect?.sheetName ?? "Sheet1" + + const sheet = utils.json_to_sheet(df.toRecords()) + const book = utils.book_new() + utils.book_append_sheet(book, sheet, sheetName) + + const buffer = write(book, { type: "buffer", bookType: "xlsx" }) + await saveFile(path, buffer) + + return path +} diff --git a/xlsx/table/test.ts b/xlsx/table/test.ts new file mode 100644 index 00000000..8f52e25f --- /dev/null +++ b/xlsx/table/test.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises" +import { writeFile } from "node:fs/promises" +import { read, utils, write } from "xlsx" + +// We intentionally don't use dpkit's function here to isolate the tests + +export async function readTestData(path: string) { + const buffer = await readFile(path) + const book = read(buffer, { type: "buffer" }) + const sheetName = book.SheetNames[0] + const sheet = sheetName ? book.Sheets[sheetName] : undefined + return sheet ? utils.sheet_to_json(sheet) : [] +} + +export async function writeTestData( + path: string, + rows: unknown[][], + options?: { sheetNumber?: number; sheetName?: string }, +) { + const book = utils.book_new() + const sheet = utils.aoa_to_sheet(rows) + const sheetNumber = options?.sheetNumber ?? 1 + + for (let i = 0; i < sheetNumber; i++) { + const sheetName = options?.sheetName ?? `Sheet${i + 1}` + utils.book_append_sheet(book, sheet, sheetName) + } + + const buffer = write(book, { type: "buffer", bookType: "xlsx" }) + await writeFile(path, buffer) +} diff --git a/excel/tsconfig.json b/xlsx/tsconfig.json similarity index 100% rename from excel/tsconfig.json rename to xlsx/tsconfig.json diff --git a/xlsx/typedoc.json b/xlsx/typedoc.json new file mode 100644 index 00000000..f8e49f3a --- /dev/null +++ b/xlsx/typedoc.json @@ -0,0 +1,4 @@ +{ + "entryPoints": ["index.ts"], + "skipErrorChecking": true +}