@ausecon/core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,37 +10,95 @@ Bun, and Deno.
10
10
 
11
11
  ---
12
12
 
13
- ## Status
13
+ ## Install
14
+
15
+ ```shell
16
+ npm install @ausecon/core effect
17
+ ```
14
18
 
15
- The library is in development. No release exists.
19
+ ## Read ABS Time Series
16
20
 
17
- The first release reads ABS time series by catalogue number and table number,
18
- or by series ID. The package now exports these schemas and errors:
21
+ `readAbs` reads ABS time series from the latest release. It reads every
22
+ table of a catalogue number, chosen tables by table number, or a list of
23
+ series IDs from any catalogue numbers.
19
24
 
20
- - `CatalogueNumber`, such as `6202.0` or `6291.0.55.001`
21
- - `SeriesId`, such as `A84423050A`
22
- - `TableNumber`, such as `1`, `5a`, or `X28`
23
- - `SeriesNotFound`, the error when the Time Series Directory has no record of
24
- a series
25
+ `readAbs` returns an `Effect` that needs an `HttpClient`. To run it without
26
+ writing Effect code, provide Effect's fetch-based client and call
27
+ `Effect.runPromise`:
25
28
 
26
29
  ```ts
27
- import { CatalogueNumber } from "@ausecon/core";
28
- import { Schema } from "effect";
30
+ import { AbsRequest, readAbs } from "@ausecon/core";
31
+ import { Effect } from "effect";
32
+ import { FetchHttpClient } from "effect/http";
29
33
 
30
- Schema.is(CatalogueNumber)("6202.0"); // true
31
- Schema.is(CatalogueNumber)("6202"); // false
34
+ // Table 1 of the Wage Price Index.
35
+ const wages = await Effect.runPromise(
36
+ readAbs(AbsRequest.Catalogue({ catalogueNumber: "6345.0", tableNumbers: ["1"] })).pipe(
37
+ Effect.provide(FetchHttpClient.layer),
38
+ ),
39
+ );
40
+
41
+ console.log(wages[0]);
32
42
  ```
33
43
 
34
- ## Install
44
+ Each observation holds the 12 `readabs` columns in camelCase: `tableId`,
45
+ `sheet`, `tableTitle`, `date`, `seriesDescription`, `value`, `seriesType`,
46
+ `dataType`, `collectionMonth`, `frequency`, `seriesId`, and `unit`. Dates are
47
+ ISO 8601 strings such as `1997-09-01`.
35
48
 
36
- ```shell
37
- npm install @ausecon/core effect
49
+ Leave out `tableNumbers` to read every table of the catalogue number. To read
50
+ series IDs instead, pass a series request:
51
+
52
+ ```ts
53
+ const series = await Effect.runPromise(
54
+ readAbs(AbsRequest.Series({ seriesIds: ["A2603039T", "A2454446F"] })).pipe(
55
+ Effect.provide(FetchHttpClient.layer),
56
+ ),
57
+ );
38
58
  ```
39
59
 
60
+ Pass `{ hasMetadata: false }` as the second argument to get only the series ID,
61
+ date, and value of each observation.
62
+
63
+ The read trims spaces from each catalogue number, table number, and series
64
+ ID, and upper-cases table numbers and series IDs. A four-digit catalogue
65
+ number such as `6345` gains `.0`.
66
+
67
+ ## Errors
68
+
69
+ `Effect.runPromise` rejects when the read fails. Inside Effect code, the
70
+ error channel holds one of these tagged errors:
71
+
72
+ - `InvalidRequest`: a catalogue number, table number, or series ID is not
73
+ valid.
74
+ - `CatalogueNotFound`: the Time Series Directory has no series for the
75
+ catalogue number.
76
+ - `TableNotFound`: the catalogue number has no table with a requested table
77
+ number.
78
+ - `SeriesNotFound`: the Time Series Directory has no record of a series ID.
79
+ - `WorkbookLayoutError`: a table does not have the ABS time series
80
+ layout.
81
+ - `HttpClientError`: a request failed or the ABS answered with a status
82
+ outside 2xx.
83
+
84
+ `WorkbookLayoutError` names the table ID and, when a sheet fails the check,
85
+ the sheet. A file that does not open as `.xlsx` gives the error without a
86
+ sheet. Each data sheet must hold `Series ID` in column A of row 10, and the
87
+ five metadata labels `Unit`, `Series Type`, `Data Type`, `Frequency`, and
88
+ `Collection Month` in column A of rows 2 to 9. Below row 10, column A must
89
+ hold a date, a blank, or a note that contains `Trend Break` or `see`.
90
+
91
+ ## Your Own `HttpClient`
92
+
93
+ Any `HttpClient` layer works. The library sends its own User-Agent, follows
94
+ redirects, and fails on any status outside 2xx, so the client only needs to
95
+ send requests.
96
+
40
97
  ## Links
41
98
 
42
99
  - [Command-line tool](https://www.npmjs.com/package/ausecon)
43
100
  - [Source and glossary](https://github.com/jackemcpherson/ausecon)
101
+ - [Changelog](https://github.com/jackemcpherson/ausecon/blob/main/CHANGELOG.md)
44
102
  - [`readabs` R package](https://github.com/MattCowgill/readabs)
45
103
  - [`readrba` R package](https://github.com/MattCowgill/readrba)
46
104
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Schema } from "effect";
1
+ import { Data, Effect, Schema } from "effect";
2
+ import { HttpClient, HttpClientError } from "effect/http";
2
3
  /**
3
4
  * An ABS catalogue number, such as `6202.0` or `6291.0.55.001`.
4
5
  */
@@ -18,6 +19,179 @@ export type SeriesId = typeof SeriesId.Type;
18
19
  export declare const TableNumber: Schema.brand<Schema.String, "TableNumber">;
19
20
  /** A decoded ABS table number. */
20
21
  export type TableNumber = typeof TableNumber.Type;
22
+ /**
23
+ * An ABS table ID: the file name of a table without its extension, such as
24
+ * `6202001` or `62020x28`.
25
+ */
26
+ export declare const TableId: Schema.brand<Schema.String, "TableId">;
27
+ /** An ABS table ID. */
28
+ export type TableId = typeof TableId.Type;
29
+ /**
30
+ * A request to read ABS time series, as the caller writes it.
31
+ *
32
+ * The `Catalogue` variant reads the latest release of a catalogue number,
33
+ * optionally limited to some table numbers. The `Series` variant reads one or
34
+ * more series IDs. The read normalises and decodes these strings, so `6345`
35
+ * and lower-case series IDs are accepted.
36
+ */
37
+ export type AbsRequest = Data.TaggedEnum<{
38
+ Catalogue: {
39
+ readonly catalogueNumber: string;
40
+ readonly tableNumbers?: ReadonlyArray<string>;
41
+ };
42
+ Series: {
43
+ readonly seriesIds: ReadonlyArray<string>;
44
+ };
45
+ }>;
46
+ /**
47
+ * Constructors for {@link AbsRequest}.
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const wages = AbsRequest.Catalogue({ catalogueNumber: "6345.0" });
52
+ * const series = AbsRequest.Series({ seriesIds: ["A2603039T"] });
53
+ * ```
54
+ */
55
+ export declare const AbsRequest: {
56
+ readonly $is: <Tag extends "Catalogue" | "Series">(tag: Tag) => (u: unknown) => u is Extract<{
57
+ readonly _tag: "Catalogue";
58
+ readonly catalogueNumber: string;
59
+ readonly tableNumbers?: ReadonlyArray<string>;
60
+ }, {
61
+ readonly _tag: Tag;
62
+ }> | Extract<{
63
+ readonly _tag: "Series";
64
+ readonly seriesIds: ReadonlyArray<string>;
65
+ }, {
66
+ readonly _tag: Tag;
67
+ }>;
68
+ readonly $match: {
69
+ <Cases extends { readonly [Tag in "Catalogue" | "Series"]: (args: Extract<{
70
+ readonly _tag: "Catalogue";
71
+ readonly catalogueNumber: string;
72
+ readonly tableNumbers?: ReadonlyArray<string>;
73
+ } | {
74
+ readonly _tag: "Series";
75
+ readonly seriesIds: ReadonlyArray<string>;
76
+ }, {
77
+ readonly _tag: Tag;
78
+ }>) => any; }>(cases: Cases): (value: {
79
+ readonly _tag: "Catalogue";
80
+ readonly catalogueNumber: string;
81
+ readonly tableNumbers?: ReadonlyArray<string>;
82
+ } | {
83
+ readonly _tag: "Series";
84
+ readonly seriesIds: ReadonlyArray<string>;
85
+ }) => import("effect/Unify").Unify<ReturnType<Cases["Catalogue" | "Series"]>>;
86
+ <Cases extends { readonly [Tag in "Catalogue" | "Series"]: (args: Extract<{
87
+ readonly _tag: "Catalogue";
88
+ readonly catalogueNumber: string;
89
+ readonly tableNumbers?: ReadonlyArray<string>;
90
+ } | {
91
+ readonly _tag: "Series";
92
+ readonly seriesIds: ReadonlyArray<string>;
93
+ }, {
94
+ readonly _tag: Tag;
95
+ }>) => any; }>(value: {
96
+ readonly _tag: "Catalogue";
97
+ readonly catalogueNumber: string;
98
+ readonly tableNumbers?: ReadonlyArray<string>;
99
+ } | {
100
+ readonly _tag: "Series";
101
+ readonly seriesIds: ReadonlyArray<string>;
102
+ }, cases: Cases): import("effect/Unify").Unify<ReturnType<Cases["Catalogue" | "Series"]>>;
103
+ };
104
+ readonly Catalogue: Data.TaggedEnum.ConstructorFrom<{
105
+ readonly _tag: "Catalogue";
106
+ readonly catalogueNumber: string;
107
+ readonly tableNumbers?: ReadonlyArray<string>;
108
+ }, "_tag">;
109
+ readonly Series: Data.TaggedEnum.ConstructorFrom<{
110
+ readonly _tag: "Series";
111
+ readonly seriesIds: ReadonlyArray<string>;
112
+ }, "_tag">;
113
+ };
114
+ /** A decoded catalogue number request. */
115
+ export declare const CatalogueRequest: Schema.TaggedStruct<"Catalogue", {
116
+ readonly catalogueNumber: Schema.brand<Schema.String, "CatalogueNumber">;
117
+ readonly tableNumbers: Schema.$Array<Schema.brand<Schema.String, "TableNumber">>;
118
+ }>;
119
+ /** A decoded catalogue number request. */
120
+ export type CatalogueRequest = typeof CatalogueRequest.Type;
121
+ /** A decoded series ID request. */
122
+ export declare const SeriesRequest: Schema.TaggedStruct<"Series", {
123
+ readonly seriesIds: Schema.NonEmptyArray<Schema.brand<Schema.String, "SeriesId">>;
124
+ }>;
125
+ /** A decoded series ID request. */
126
+ export type SeriesRequest = typeof SeriesRequest.Type;
127
+ /** A decoded {@link AbsRequest}. */
128
+ export declare const DecodedAbsRequest: Schema.Union<readonly [Schema.TaggedStruct<"Catalogue", {
129
+ readonly catalogueNumber: Schema.brand<Schema.String, "CatalogueNumber">;
130
+ readonly tableNumbers: Schema.$Array<Schema.brand<Schema.String, "TableNumber">>;
131
+ }>, Schema.TaggedStruct<"Series", {
132
+ readonly seriesIds: Schema.NonEmptyArray<Schema.brand<Schema.String, "SeriesId">>;
133
+ }>]>;
134
+ /** A decoded {@link AbsRequest}. */
135
+ export type DecodedAbsRequest = typeof DecodedAbsRequest.Type;
136
+ /**
137
+ * Options for a read.
138
+ *
139
+ * Set `hasMetadata` to `false` to return only series ID, date, and value.
140
+ */
141
+ interface ReadOptions {
142
+ readonly hasMetadata?: boolean;
143
+ }
144
+ /**
145
+ * One value of a series for one period, with the series metadata.
146
+ *
147
+ * The fields are the 12 `readabs` columns in camelCase, in the same order.
148
+ */
149
+ export declare const Observation: Schema.Struct<{
150
+ readonly tableId: Schema.brand<Schema.String, "TableId">;
151
+ readonly sheet: Schema.String;
152
+ readonly tableTitle: Schema.String;
153
+ readonly date: Schema.String;
154
+ readonly seriesDescription: Schema.String;
155
+ readonly value: Schema.Number;
156
+ readonly seriesType: Schema.String;
157
+ readonly dataType: Schema.String;
158
+ readonly collectionMonth: Schema.String;
159
+ readonly frequency: Schema.String;
160
+ readonly seriesId: Schema.String;
161
+ readonly unit: Schema.String;
162
+ }>;
163
+ /** One value of a series for one period, with the series metadata. */
164
+ export type Observation = typeof Observation.Type;
165
+ /** One value of a series for one period, without the series metadata. */
166
+ export declare const ObservationValue: Schema.Struct<{
167
+ readonly seriesId: Schema.String;
168
+ readonly date: Schema.String;
169
+ readonly value: Schema.Number;
170
+ }>;
171
+ /** One value of a series for one period, without the series metadata. */
172
+ export type ObservationValue = typeof ObservationValue.Type;
173
+ declare const InvalidRequest_base: Schema.Class<InvalidRequest, Schema.TaggedStruct<"InvalidRequest", {
174
+ readonly message: Schema.String;
175
+ }>, import("effect/Cause").YieldableError>;
176
+ /**
177
+ * The request input failed schema decoding.
178
+ */
179
+ export declare class InvalidRequest extends InvalidRequest_base {}
180
+ declare const CatalogueNotFound_base: Schema.Class<CatalogueNotFound, Schema.TaggedStruct<"CatalogueNotFound", {
181
+ readonly catalogueNumber: Schema.brand<Schema.String, "CatalogueNumber">;
182
+ }>, import("effect/Cause").YieldableError>;
183
+ /**
184
+ * The Time Series Directory has no series for the catalogue number.
185
+ */
186
+ export declare class CatalogueNotFound extends CatalogueNotFound_base {}
187
+ declare const TableNotFound_base: Schema.Class<TableNotFound, Schema.TaggedStruct<"TableNotFound", {
188
+ readonly catalogueNumber: Schema.brand<Schema.String, "CatalogueNumber">;
189
+ readonly tableNumbers: Schema.$Array<Schema.brand<Schema.String, "TableNumber">>;
190
+ }>, import("effect/Cause").YieldableError>;
191
+ /**
192
+ * One or more requested table numbers match no table title.
193
+ */
194
+ export declare class TableNotFound extends TableNotFound_base {}
21
195
  declare const SeriesNotFound_base: Schema.Class<SeriesNotFound, Schema.TaggedStruct<"SeriesNotFound", {
22
196
  readonly seriesIds: Schema.$Array<Schema.brand<Schema.String, "SeriesId">>;
23
197
  }>, import("effect/Cause").YieldableError>;
@@ -25,4 +199,45 @@ declare const SeriesNotFound_base: Schema.Class<SeriesNotFound, Schema.TaggedStr
25
199
  * The Time Series Directory has no record of one or more requested series.
26
200
  */
27
201
  export declare class SeriesNotFound extends SeriesNotFound_base {}
28
- export {};
202
+ declare const WorkbookLayoutError_base: Schema.Class<WorkbookLayoutError, Schema.TaggedStruct<"WorkbookLayoutError", {
203
+ readonly tableId: Schema.brand<Schema.String, "TableId">;
204
+ readonly sheet: Schema.optionalKey<Schema.String>;
205
+ readonly reason: Schema.String;
206
+ }>, import("effect/Cause").YieldableError>;
207
+ /**
208
+ * A table does not have the ABS time series layout.
209
+ *
210
+ * `sheet` is absent when the table cannot be opened as `.xlsx` at all.
211
+ */
212
+ export declare class WorkbookLayoutError extends WorkbookLayoutError_base {}
213
+ /** Every error a read can fail with. */
214
+ type ReadAbsError = InvalidRequest | CatalogueNotFound | TableNotFound | SeriesNotFound | WorkbookLayoutError | HttpClientError.HttpClientError;
215
+ /**
216
+ * Read ABS time series from the latest release.
217
+ *
218
+ * A `Catalogue` request reads the tables of a catalogue number. A `Series`
219
+ * request reads series IDs. The read queries the Time Series Directory,
220
+ * downloads each table once, and returns one flat list of observations.
221
+ *
222
+ * The Effect requires an `HttpClient`. The library never picks one, so it runs
223
+ * on any runtime. The client must follow redirects, or return them so that
224
+ * the library can follow them.
225
+ *
226
+ * @param request - What to read.
227
+ * @param options - Set `hasMetadata: false` to return only series ID, date,
228
+ * and value.
229
+ * @returns An Effect that yields the observations.
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const observations = yield* readAbs(AbsRequest.Catalogue({ catalogueNumber: "6345.0" }));
234
+ * ```
235
+ */
236
+ export declare function readAbs(request: AbsRequest, options: {
237
+ readonly hasMetadata: false;
238
+ }): Effect.Effect<ReadonlyArray<ObservationValue>, ReadAbsError, HttpClient.HttpClient>;
239
+ export declare function readAbs(request: AbsRequest, options?: {
240
+ readonly hasMetadata?: true;
241
+ }): Effect.Effect<ReadonlyArray<Observation>, ReadAbsError, HttpClient.HttpClient>;
242
+ export declare function readAbs(request: AbsRequest, options?: ReadOptions): Effect.Effect<ReadonlyArray<Observation> | ReadonlyArray<ObservationValue>, ReadAbsError, HttpClient.HttpClient>;
243
+ export type { ReadAbsError, ReadOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
- import { Schema } from "effect";
1
+ import { Data, Effect, Match, Option, Schema, Struct } from "effect";
2
+ import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
3
+ import { read, utils } from "xlsx";
4
+ import { XMLParser } from "fast-xml-parser";
2
5
  // src/types.ts
3
6
  /**
4
7
  * An ABS catalogue number, such as `6202.0` or `6291.0.55.001`.
@@ -14,7 +17,528 @@ const SeriesId = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Z]\d+[A-Z
14
17
  */
15
18
  const TableNumber = Schema.String.pipe(Schema.check(Schema.isPattern(/^[A-Z]?\d+[A-Za-z]?(?:_[A-Za-z0-9]+)?$/)), Schema.brand("TableNumber"));
16
19
  /**
20
+ * An ABS table ID: the file name of a table without its extension, such as
21
+ * `6202001` or `62020x28`.
22
+ */
23
+ const TableId = Schema.String.pipe(Schema.brand("TableId"));
24
+ /**
25
+ * Constructors for {@link AbsRequest}.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const wages = AbsRequest.Catalogue({ catalogueNumber: "6345.0" });
30
+ * const series = AbsRequest.Series({ seriesIds: ["A2603039T"] });
31
+ * ```
32
+ */
33
+ const AbsRequest = Data.taggedEnum();
34
+ /** A decoded catalogue number request. */
35
+ const CatalogueRequest = Schema.TaggedStruct("Catalogue", {
36
+ catalogueNumber: CatalogueNumber,
37
+ tableNumbers: Schema.Array(TableNumber)
38
+ });
39
+ /** A decoded series ID request. */
40
+ const SeriesRequest = Schema.TaggedStruct("Series", { seriesIds: Schema.NonEmptyArray(SeriesId) });
41
+ /** A decoded {@link AbsRequest}. */
42
+ const DecodedAbsRequest = Schema.Union([CatalogueRequest, SeriesRequest]);
43
+ /**
44
+ * One value of a series for one period, with the series metadata.
45
+ *
46
+ * The fields are the 12 `readabs` columns in camelCase, in the same order.
47
+ */
48
+ const Observation = Schema.Struct({
49
+ tableId: TableId,
50
+ sheet: Schema.String,
51
+ tableTitle: Schema.String,
52
+ date: Schema.String,
53
+ seriesDescription: Schema.String,
54
+ value: Schema.Number,
55
+ seriesType: Schema.String,
56
+ dataType: Schema.String,
57
+ collectionMonth: Schema.String,
58
+ frequency: Schema.String,
59
+ seriesId: Schema.String,
60
+ unit: Schema.String
61
+ });
62
+ /** One value of a series for one period, without the series metadata. */
63
+ const ObservationValue = Schema.Struct({
64
+ seriesId: Schema.String,
65
+ date: Schema.String,
66
+ value: Schema.Number
67
+ });
68
+ /**
69
+ * The request input failed schema decoding.
70
+ */
71
+ var InvalidRequest = class extends Schema.TaggedError()("InvalidRequest", { message: Schema.String }) {};
72
+ /**
73
+ * The Time Series Directory has no series for the catalogue number.
74
+ */
75
+ var CatalogueNotFound = class extends Schema.TaggedError()("CatalogueNotFound", { catalogueNumber: CatalogueNumber }) {};
76
+ /**
77
+ * One or more requested table numbers match no table title.
78
+ */
79
+ var TableNotFound = class extends Schema.TaggedError()("TableNotFound", {
80
+ catalogueNumber: CatalogueNumber,
81
+ tableNumbers: Schema.Array(TableNumber)
82
+ }) {};
83
+ /**
17
84
  * The Time Series Directory has no record of one or more requested series.
18
85
  */
19
86
  var SeriesNotFound = class extends Schema.TaggedError()("SeriesNotFound", { seriesIds: Schema.Array(SeriesId) }) {};
20
- export { CatalogueNumber, SeriesId, SeriesNotFound, TableNumber };
87
+ /**
88
+ * A table does not have the ABS time series layout.
89
+ *
90
+ * `sheet` is absent when the table cannot be opened as `.xlsx` at all.
91
+ */
92
+ var WorkbookLayoutError = class extends Schema.TaggedError()("WorkbookLayoutError", {
93
+ tableId: TableId,
94
+ sheet: Schema.optionalKey(Schema.String),
95
+ reason: Schema.String
96
+ }) {};
97
+ // src/abs/http.ts
98
+ /**
99
+ * The User-Agent sent with every request. The ABS answers 403 to a request
100
+ * without one.
101
+ */
102
+ const USER_AGENT = "ausecon (+https://github.com/jackemcpherson/ausecon)";
103
+ /**
104
+ * The caller's `HttpClient`, set up for ABS requests.
105
+ *
106
+ * It sends the project User-Agent, follows redirects that the underlying
107
+ * client returns, and fails on any status outside 2xx.
108
+ */
109
+ const absHttpClient = Effect.gen(function* () {
110
+ return (yield* HttpClient.HttpClient).pipe(HttpClient.mapRequest(HttpClientRequest.setHeader("user-agent", USER_AGENT)), HttpClient.followRedirects(), HttpClient.filterStatusOk);
111
+ });
112
+ // src/abs/table-parse.ts
113
+ /** Sheets that `readabs` skips by exact name. */
114
+ const SKIPPED_SHEETS = /* @__PURE__ */ new Set([
115
+ "Index",
116
+ "Inquiries",
117
+ "Enquiries",
118
+ "Contents",
119
+ "Technical note",
120
+ "Technical notes",
121
+ "Note",
122
+ "Notes"
123
+ ]);
124
+ /** `readabs` also skips any sheet whose name contains `Table` and a space. */
125
+ const TABLE_SHEET = /Table /;
126
+ /** Zero-based row of the `Series ID` label: spreadsheet row 10. */
127
+ const SERIES_ID_ROW = 9;
128
+ /** Zero-based row of the first observation: spreadsheet row 11. */
129
+ const FIRST_OBSERVATION_ROW = 10;
130
+ const MILLISECONDS_PER_DAY = 864e5;
131
+ /** Excel serial day zero, 1899-12-30, in UTC. */
132
+ const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
133
+ /** The column A labels of the metadata rows that an observation carries. */
134
+ const METADATA_LABELS = {
135
+ unit: "Unit",
136
+ seriesType: "Series Type",
137
+ dataType: "Data Type",
138
+ frequency: "Frequency",
139
+ collectionMonth: "Collection Month"
140
+ };
141
+ const cellAt = (sheet, row, column) => {
142
+ return sheet[utils.encode_cell({
143
+ r: row,
144
+ c: column
145
+ })];
146
+ };
147
+ const cellText = (cell) => String(cell?.v ?? "");
148
+ const cellNumber = (cell) => cell?.t === "n" ? Number(cell.v) : void 0;
149
+ /** Convert an Excel serial day number to an ISO `YYYY-MM-DD` date in UTC. */
150
+ const excelSerialToIsoDate = (serial) => new Date(EXCEL_EPOCH + Math.floor(serial) * MILLISECONDS_PER_DAY).toISOString().slice(0, 10);
151
+ /** Whether column A text is a note that `readabs` drops, such as `Trend Break` or `see ...`. */
152
+ const isNote = (text) => text.includes("Trend Break") || text.includes("see");
153
+ /** Whether a column A cell below row 10 is neither a date, blank, nor a note. */
154
+ const isUnexpectedRowLabel = (cell) => {
155
+ const text = cellText(cell).trim();
156
+ return cell?.t !== "n" && text !== "" && !isNote(text);
157
+ };
158
+ /** Whether `readabs` reads a sheet as data. */
159
+ const isDataSheet = (sheetName) => !SKIPPED_SHEETS.has(sheetName) && !TABLE_SHEET.test(sheetName);
160
+ /** The zero-based rows from `first` to `last`, inclusive. */
161
+ const rowsBetween = (first, last) => Array.from({ length: Math.max(last - first + 1, 0) }, (_, index) => first + index);
162
+ /**
163
+ * Check that a sheet has the ABS time series layout.
164
+ *
165
+ * Column A of row 10 must hold `Series ID`, rows 2 to 9 must hold each
166
+ * metadata label that an observation carries, and each column A cell below
167
+ * row 10 must hold a date, a blank, or a note.
168
+ */
169
+ const checkLayout = (sheet, name, table) => {
170
+ const layoutError = (reason) => Effect.fail(new WorkbookLayoutError({
171
+ tableId: table.tableId,
172
+ sheet: name,
173
+ reason
174
+ }));
175
+ const ref = sheet["!ref"];
176
+ if (ref === void 0 || cellText(cellAt(sheet, SERIES_ID_ROW, 0)) !== "Series ID") return layoutError("Column A of row 10 is not `Series ID`.");
177
+ const labelRows = new Map(rowsBetween(1, 8).map((row) => [cellText(cellAt(sheet, row, 0)), row]));
178
+ const rowOf = (field) => Option.fromUndefinedOr(labelRows.get(METADATA_LABELS[field]));
179
+ const metadataRows = Option.all({
180
+ unit: rowOf("unit"),
181
+ seriesType: rowOf("seriesType"),
182
+ dataType: rowOf("dataType"),
183
+ frequency: rowOf("frequency"),
184
+ collectionMonth: rowOf("collectionMonth")
185
+ });
186
+ if (Option.isNone(metadataRows)) return layoutError(`Rows 2 to 9 lack the metadata rows: ${Struct.keys(METADATA_LABELS).filter((field) => Option.isNone(rowOf(field))).join(", ")}.`);
187
+ const range = utils.decode_range(ref);
188
+ const rows = rowsBetween(FIRST_OBSERVATION_ROW, range.e.r);
189
+ const unexpectedRow = rows.find((row) => isUnexpectedRowLabel(cellAt(sheet, row, 0)));
190
+ if (unexpectedRow !== void 0) {
191
+ const text = cellText(cellAt(sheet, unexpectedRow, 0));
192
+ return layoutError(`Cell A${unexpectedRow + 1} holds \`${text}\`, which is not a date, a blank, or a note.`);
193
+ }
194
+ return Effect.succeed({
195
+ name,
196
+ sheet,
197
+ lastColumn: range.e.c,
198
+ metadataRows: metadataRows.value,
199
+ periods: rows.flatMap((row) => {
200
+ const serial = cellNumber(cellAt(sheet, row, 0));
201
+ return serial === void 0 ? [] : [[row, excelSerialToIsoDate(serial)]];
202
+ })
203
+ });
204
+ };
205
+ /** Read the observations of one checked data sheet. */
206
+ const sheetObservations = (data, table) => {
207
+ const { sheet, metadataRows } = data;
208
+ return rowsBetween(1, data.lastColumn).flatMap((column) => {
209
+ const seriesId = cellText(cellAt(sheet, SERIES_ID_ROW, column));
210
+ if (seriesId === "") return [];
211
+ const metadata = (field) => cellText(cellAt(sheet, metadataRows[field], column));
212
+ const seriesDescription = cellText(cellAt(sheet, 0, column));
213
+ const seriesType = metadata("seriesType");
214
+ const dataType = metadata("dataType");
215
+ const collectionMonth = metadata("collectionMonth");
216
+ const frequency = metadata("frequency");
217
+ const unit = metadata("unit");
218
+ return data.periods.flatMap(([row, date]) => {
219
+ const value = cellNumber(cellAt(sheet, row, column));
220
+ return value === void 0 ? [] : [{
221
+ tableId: table.tableId,
222
+ sheet: data.name,
223
+ tableTitle: table.tableTitle,
224
+ date,
225
+ seriesDescription,
226
+ value,
227
+ seriesType,
228
+ dataType,
229
+ collectionMonth,
230
+ frequency,
231
+ seriesId,
232
+ unit
233
+ }];
234
+ });
235
+ });
236
+ };
237
+ /**
238
+ * Read the observations of an ABS time series table.
239
+ *
240
+ * Skips the sheets `readabs` skips. Each data sheet must have `Series ID` in
241
+ * column A of row 10. Below it, column A holds a date, a blank, or a note that
242
+ * contains `Trend Break` or `see`; any other text fails the read. Rows with a
243
+ * blank or a note, and blank or text value cells, are left out. Dates come
244
+ * from Excel serial numbers, which is how `.xlsx` stores date cells.
245
+ *
246
+ * @param bytes - The `.xlsx` file contents.
247
+ * @param table - The table ID and title to put on each observation.
248
+ * @returns An Effect that yields the observations, or fails with
249
+ * `WorkbookLayoutError`.
250
+ */
251
+ const parseTable = Effect.fn("parseTable")(function* (bytes, table) {
252
+ const file = yield* Effect.try({
253
+ try: () => read(bytes, {
254
+ type: "array",
255
+ cellDates: false,
256
+ cellHTML: false
257
+ }),
258
+ catch: (cause) => new WorkbookLayoutError({
259
+ tableId: table.tableId,
260
+ reason: `The table cannot be opened as .xlsx: ${String(cause)}`
261
+ })
262
+ });
263
+ return (yield* Effect.forEach(Object.entries(file.Sheets).filter(([name]) => isDataSheet(name)), ([name, sheet]) => checkLayout(sheet, name, table))).flatMap((data) => sheetObservations(data, table));
264
+ });
265
+ // src/abs/download.ts
266
+ /** The most tables downloaded at once. */
267
+ const DOWNLOAD_CONCURRENCY = 4;
268
+ /**
269
+ * Download and parse tables.
270
+ *
271
+ * Downloads each distinct `TableURL` once, with a concurrency limit, after
272
+ * replacing spaces in the URL with `+`.
273
+ *
274
+ * @param tables - The tables to read.
275
+ * @param isWanted - Whether to keep an observation of a table.
276
+ * @returns An Effect that yields the kept observations of every table, in
277
+ * table order.
278
+ */
279
+ const readTables = Effect.fn("readTables")(function* (tables, isWanted) {
280
+ const client = yield* absHttpClient;
281
+ const distinct = [...new Map(tables.map((table) => [table.tableUrl, table])).values()];
282
+ return (yield* Effect.forEach(distinct, (table) => client.get(table.tableUrl.replaceAll(" ", "+")).pipe(Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((body) => parseTable(new Uint8Array(body), table)), Effect.map((observations) => observations.filter((observation) => isWanted(table, observation)))), { concurrency: DOWNLOAD_CONCURRENCY })).flat();
283
+ });
284
+ // src/abs/latest-release.ts
285
+ /**
286
+ * Keep only rows that link to a table, from the latest release of each table.
287
+ *
288
+ * A table is one `TableURL`, as `readabs` groups it in `read_abs`. Rows with an
289
+ * empty `TableURL` link to no table.
290
+ *
291
+ * @param rows - Directory rows.
292
+ * @returns The rows with a `TableURL` whose release date is the latest for
293
+ * their table.
294
+ */
295
+ const latestRows = (rows) => {
296
+ const linked = rows.filter((row) => row.tableUrl.trim() !== "");
297
+ const latestDates = new Map([...Map.groupBy(linked, (row) => row.tableUrl)].map(([tableUrl, group]) => [tableUrl, group.reduce((latest, row) => row.releaseDate > latest ? row.releaseDate : latest, "")]));
298
+ return linked.filter((row) => latestDates.get(row.tableUrl) === row.releaseDate);
299
+ };
300
+ /** The file name of a table URL without its extension. */
301
+ const tableIdOf = (tableUrl) => TableId.make(tableUrl.replace(/[?#].*$/, "").replace(/^.*\//, "").replace(/\.[^.]*$/, ""));
302
+ /**
303
+ * List the distinct tables of directory rows, in row order.
304
+ *
305
+ * @param rows - Directory rows, already filtered to the latest release.
306
+ * @returns One entry per `TableURL`, titled by its first row.
307
+ */
308
+ const tablesOf = (rows) => [...Map.groupBy(rows, (row) => row.tableUrl).values()].flatMap(([first]) => first === void 0 ? [] : [{
309
+ tableId: tableIdOf(first.tableUrl),
310
+ tableTitle: first.tableTitle,
311
+ tableUrl: first.tableUrl
312
+ }]);
313
+ // src/abs/request.ts
314
+ const FOUR_DIGIT_CATALOGUE_NUMBER = /^\d{4}$/;
315
+ const normaliseCatalogueNumber = (catalogueNumber) => {
316
+ const trimmed = catalogueNumber.trim();
317
+ return FOUR_DIGIT_CATALOGUE_NUMBER.test(trimmed) ? `${trimmed}.0` : trimmed;
318
+ };
319
+ const normaliseCode = (code) => code.trim().toUpperCase();
320
+ /**
321
+ * Normalise caller input before schema decoding.
322
+ *
323
+ * Appends `.0` to a four-digit catalogue number and upper-cases table numbers
324
+ * and series IDs.
325
+ */
326
+ const normalise = AbsRequest.$match({
327
+ Catalogue: (request) => AbsRequest.Catalogue({
328
+ catalogueNumber: normaliseCatalogueNumber(request.catalogueNumber),
329
+ tableNumbers: (request.tableNumbers ?? []).map(normaliseCode)
330
+ }),
331
+ Series: (request) => AbsRequest.Series({ seriesIds: request.seriesIds.map(normaliseCode) })
332
+ });
333
+ /**
334
+ * Normalise and decode a request.
335
+ *
336
+ * @param request - The request as the caller wrote it.
337
+ * @returns An Effect that yields the decoded request, or fails with
338
+ * `InvalidRequest`.
339
+ */
340
+ const decodeRequest = Effect.fn("decodeRequest")(function* (request) {
341
+ return yield* Schema.decodeUnknownEffect(DecodedAbsRequest)(normalise(request));
342
+ }, Effect.mapError((error) => new InvalidRequest({ message: error.message })));
343
+ // src/abs/table-number-parse.ts
344
+ const TITLE_PREFIX = /^(?:Tables|Table|TABLE|Data cube)[ _]/;
345
+ const NUMBER = String.raw`[A-Za-z]?\d+[A-Za-z]?(?:_[A-Za-z0-9]+)?`;
346
+ const NUMBER_LIST = new RegExp(String.raw`^${NUMBER}(?:(?:,? and |, )${NUMBER})+(?=[.: ]|$)`);
347
+ const LIST_SEPARATOR = /,? and |, /;
348
+ const NUMBER_RANGE = /^([A-Za-z]?)(\d+)([A-Za-z]?) to ([A-Za-z]?)(\d+)([A-Za-z]?)(?=[.: ]|$)/;
349
+ const NUMBER_END = /[.: ]|$/;
350
+ /** A table number in upper case without leading zeros, for comparison. */
351
+ const comparable = (tableNumber) => tableNumber.toUpperCase().replace(/^([A-Z]?)0+(?=\d)/, "$1");
352
+ const NOTHING = () => false;
353
+ const oneOf = (numbers) => (tableNumber) => numbers.map(comparable).includes(tableNumber);
354
+ const NUMBER_PARTS = /^([A-Z]?)(\d+)([A-Z]?)$/;
355
+ /**
356
+ * Test whether a range names a table number. A range whose ends carry the
357
+ * same letters, such as `01 to 17` or `2a to 9a`, names each number from one
358
+ * end to the other. A range whose letters differ, such as `2a to 4b`, names
359
+ * only its two ends, as ADR 0005 records.
360
+ *
361
+ * The test compares numbers rather than listing the range, so a title with a
362
+ * huge range costs no memory.
363
+ */
364
+ const rangeMatch = (range) => {
365
+ const [, firstPrefix = "", firstDigits = "", firstSuffix = "", lastPrefix = "", lastDigits = "", lastSuffix = ""] = range;
366
+ const prefix = firstPrefix.toUpperCase();
367
+ const suffix = firstSuffix.toUpperCase();
368
+ if (prefix !== lastPrefix.toUpperCase() || suffix !== lastSuffix.toUpperCase()) return oneOf([`${firstPrefix}${firstDigits}${firstSuffix}`, `${lastPrefix}${lastDigits}${lastSuffix}`]);
369
+ const first = Number(firstDigits);
370
+ const last = Number(lastDigits);
371
+ return (tableNumber) => {
372
+ const [, numberPrefix, digits = "", numberSuffix] = NUMBER_PARTS.exec(tableNumber) ?? [];
373
+ const number = Number(digits);
374
+ return numberPrefix === prefix && numberSuffix === suffix && number >= first && number <= last;
375
+ };
376
+ };
377
+ /** Parse the table numbers a title names into a test. */
378
+ const titleMatch = (title) => {
379
+ const prefix = TITLE_PREFIX.exec(title);
380
+ if (prefix === null) return NOTHING;
381
+ const rest = title.slice(prefix[0].length);
382
+ const range = NUMBER_RANGE.exec(rest);
383
+ if (range !== null) return rangeMatch(range);
384
+ const list = NUMBER_LIST.exec(rest);
385
+ if (list !== null) return oneOf(list[0].split(LIST_SEPARATOR));
386
+ const end = rest.search(NUMBER_END);
387
+ const number = rest.slice(0, end);
388
+ return oneOf([/[.:]/.test(rest.charAt(end)) ? number : number.replace(/_.*$/, "")]);
389
+ };
390
+ /**
391
+ * Test whether a table title names a table number.
392
+ *
393
+ * @param title - The `TableTitle` of a table.
394
+ * @param tableNumber - The requested table number.
395
+ * @returns Whether the title names the table number.
396
+ */
397
+ const titleNamesTable = (title, tableNumber) => titleMatch(title)(comparable(tableNumber));
398
+ // src/abs/time-series-directory-parse.ts
399
+ /** A Time Series Directory response that is not a series index. */
400
+ var DirectoryDocumentError = class extends Schema.TaggedError()("DirectoryDocumentError", { reason: Schema.String }) {};
401
+ const DIRECTORY_DATE = /^(\d{2})\/(\d{2})\/(\d{4})$/;
402
+ const SeriesElement = Schema.Struct({
403
+ ProductReleaseDate: Schema.String.pipe(Schema.check(Schema.isPattern(DIRECTORY_DATE))),
404
+ TableURL: Schema.String,
405
+ TableTitle: Schema.String,
406
+ TableOrder: Schema.String.pipe(Schema.check(Schema.isPattern(/^\d+$/))),
407
+ SeriesID: Schema.String
408
+ });
409
+ const SeriesIndex = Schema.Struct({
410
+ NumPages: Schema.optionalKey(Schema.String.pipe(Schema.check(Schema.isPattern(/^[1-9]\d*$/)))),
411
+ Series: Schema.optionalKey(Schema.Array(SeriesElement))
412
+ });
413
+ const DirectoryDocument = Schema.Struct({
414
+ TimeSeriesIndex: Schema.optionalKey(Schema.Union([Schema.Literal(""), SeriesIndex])),
415
+ Error: Schema.optionalKey(Schema.String)
416
+ });
417
+ const parser = new XMLParser({
418
+ ignoreDeclaration: true,
419
+ isArray: (name) => name === "Series",
420
+ parseTagValue: false
421
+ });
422
+ /** Convert a `DD/MM/YYYY` directory date to an ISO `YYYY-MM-DD` string. */
423
+ const isoDate = (directoryDate) => directoryDate.replace(DIRECTORY_DATE, "$3-$2-$1");
424
+ const toRow = (series) => ({
425
+ releaseDate: isoDate(series.ProductReleaseDate),
426
+ tableUrl: series.TableURL,
427
+ tableTitle: series.TableTitle,
428
+ tableOrder: Number(series.TableOrder),
429
+ seriesId: series.SeriesID
430
+ });
431
+ /**
432
+ * Parse one Time Series Directory page.
433
+ *
434
+ * A page without `NumPages` is the only page. An empty index parses to a page
435
+ * with no rows.
436
+ *
437
+ * @param xml - The response body.
438
+ * @returns An Effect that yields the page, or fails with
439
+ * `DirectoryDocumentError` for an `<Error>` document or an unexpected layout.
440
+ */
441
+ const parseDirectoryPage = Effect.fn("parseDirectoryPage")(function* (xml) {
442
+ const parsed = yield* Effect.try({
443
+ try: () => parser.parse(xml, true),
444
+ catch: (cause) => new DirectoryDocumentError({ reason: `The XML is malformed: ${String(cause)}` })
445
+ });
446
+ const document = yield* Schema.decodeUnknownEffect(DirectoryDocument)(parsed).pipe(Effect.mapError((error) => new DirectoryDocumentError({ reason: `Unexpected layout: ${error.message}` })));
447
+ if (document.Error !== void 0) return yield* new DirectoryDocumentError({ reason: `The Time Series Directory answered: ${document.Error}` });
448
+ if (document.TimeSeriesIndex === void 0) return yield* new DirectoryDocumentError({ reason: "No TimeSeriesIndex element." });
449
+ if (document.TimeSeriesIndex === "") return {
450
+ pageCount: 1,
451
+ rows: []
452
+ };
453
+ return {
454
+ pageCount: Number(document.TimeSeriesIndex.NumPages ?? "1"),
455
+ rows: (document.TimeSeriesIndex.Series ?? []).map(toRow)
456
+ };
457
+ });
458
+ // src/abs/time-series-directory.ts
459
+ /** The documented Time Series Directory base URL. */
460
+ const DIRECTORY_URL = "https://abs.gov.au/servlet/TSSearchServlet";
461
+ /** The most Time Series Directory pages fetched at once. */
462
+ const PAGE_CONCURRENCY = 4;
463
+ const fetchPage = (client, request) => Effect.gen(function* () {
464
+ const response = yield* client.execute(request);
465
+ const xml = yield* response.text;
466
+ return yield* parseDirectoryPage(xml).pipe(Effect.mapError((error) => decodeError(request, response, error.reason)));
467
+ });
468
+ const decodeError = (request, response, description) => new HttpClientError.HttpClientError({ reason: new HttpClientError.DecodeError({
469
+ request,
470
+ response,
471
+ description
472
+ }) });
473
+ /**
474
+ * Fetch every Time Series Directory row for one query.
475
+ *
476
+ * Reads `NumPages` from page 1, then fetches the other pages with a small
477
+ * concurrency limit. An unknown catalogue number or series ID yields no rows.
478
+ *
479
+ * @param query - The query parameter, `catno` or `sid`.
480
+ * @param value - The decoded catalogue number or series ID.
481
+ * @returns An Effect that yields the rows of every page, in page order.
482
+ */
483
+ const fetchDirectory = Effect.fn("fetchDirectory")(function* (query, value) {
484
+ const client = yield* absHttpClient;
485
+ const request = HttpClientRequest.get(DIRECTORY_URL).pipe(HttpClientRequest.setUrlParam(query, value));
486
+ const firstPage = yield* fetchPage(client, request);
487
+ const pageNumbers = Array.from({ length: firstPage.pageCount - 1 }, (_, index) => index + 2);
488
+ return [firstPage, ...yield* Effect.forEach(pageNumbers, (pageNumber) => fetchPage(client, request.pipe(HttpClientRequest.setUrlParam("pg", `${pageNumber}`))), { concurrency: PAGE_CONCURRENCY })].flatMap((page) => page.rows);
489
+ });
490
+ // src/abs/time-series.ts
491
+ /**
492
+ * Limit the tables of a catalogue number to the requested table numbers.
493
+ *
494
+ * A request without table numbers reads every table. Otherwise the read keeps
495
+ * each table whose title names a requested table number, as ADR 0005 records,
496
+ * and fails with `TableNotFound` if any requested table number names no table.
497
+ */
498
+ const selectTables = (request, tables) => {
499
+ if (request.tableNumbers.length === 0) return Effect.succeed(tables);
500
+ const matches = request.tableNumbers.map((tableNumber) => [tableNumber, tables.filter((table) => titleNamesTable(table.tableTitle, tableNumber))]);
501
+ const unmatched = matches.flatMap(([tableNumber, found]) => found.length === 0 ? [tableNumber] : []);
502
+ if (unmatched.length > 0) return Effect.fail(new TableNotFound({
503
+ catalogueNumber: request.catalogueNumber,
504
+ tableNumbers: unmatched
505
+ }));
506
+ const selected = new Set(matches.flatMap(([, found]) => found));
507
+ return Effect.succeed(tables.filter((table) => selected.has(table)));
508
+ };
509
+ /** The tables a catalogue request reads, from the latest release. */
510
+ const catalogueTables = (request) => Effect.gen(function* () {
511
+ const rows = latestRows(yield* fetchDirectory("catno", request.catalogueNumber));
512
+ if (rows.length === 0) return yield* new CatalogueNotFound({ catalogueNumber: request.catalogueNumber });
513
+ return yield* selectTables(request, tablesOf(rows));
514
+ });
515
+ const readCatalogue = (request) => catalogueTables(request).pipe(Effect.flatMap((tables) => readTables(tables, () => true)));
516
+ /** The most series IDs looked up in the Time Series Directory at once. */
517
+ const SERIES_CONCURRENCY = 4;
518
+ /**
519
+ * The directory row a series is read from: the lowest `TableOrder` of the
520
+ * latest release, as `readabs` picks it, so no series appears twice.
521
+ */
522
+ const seriesRow = (seriesId) => fetchDirectory("sid", seriesId).pipe(Effect.map((rows) => [seriesId, latestRows(rows).toSorted((first, second) => first.tableOrder - second.tableOrder).at(0)]));
523
+ const readSeries = (request) => Effect.gen(function* () {
524
+ const found = yield* Effect.forEach(new Set(request.seriesIds), seriesRow, { concurrency: SERIES_CONCURRENCY });
525
+ const missing = found.flatMap(([seriesId, row]) => row === void 0 ? [seriesId] : []);
526
+ if (missing.length > 0) return yield* new SeriesNotFound({ seriesIds: missing });
527
+ const rows = found.flatMap(([, row]) => row === void 0 ? [] : [row]);
528
+ const wanted = new Set(rows.map((row) => `${row.tableUrl} ${row.seriesId}`));
529
+ return yield* readTables(tablesOf(rows), (table, observation) => wanted.has(`${table.tableUrl} ${observation.seriesId}`));
530
+ });
531
+ const readDecoded = Match.type().pipe(Match.tag("Catalogue", readCatalogue), Match.tag("Series", readSeries), Match.exhaustive);
532
+ const withoutMetadata = (observation) => ({
533
+ seriesId: observation.seriesId,
534
+ date: observation.date,
535
+ value: observation.value
536
+ });
537
+ const read$1 = Effect.fn("readAbs")(function* (request, options) {
538
+ const observations = yield* readDecoded(yield* decodeRequest(request));
539
+ return options?.hasMetadata === false ? observations.map(withoutMetadata) : observations;
540
+ });
541
+ function readAbs(request, options) {
542
+ return read$1(request, options);
543
+ }
544
+ export { AbsRequest, CatalogueNotFound, CatalogueNumber, CatalogueRequest, DecodedAbsRequest, InvalidRequest, Observation, ObservationValue, SeriesId, SeriesNotFound, SeriesRequest, TableId, TableNotFound, TableNumber, WorkbookLayoutError, readAbs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ausecon/core",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Read Australian economic data from the ABS and RBA.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "effect": "4.0.6",
27
+ "fast-xml-parser": "5.11.2",
27
28
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
28
29
  },
29
30
  "engines": {