@ausecon/core 0.0.0 → 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 +74 -16
- package/dist/index.d.ts +217 -2
- package/dist/index.js +526 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,37 +10,95 @@ Bun, and Deno.
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
npm install @ausecon/core effect
|
|
17
|
+
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
## Read ABS Time Series
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
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 {
|
|
28
|
-
import {
|
|
30
|
+
import { AbsRequest, readAbs } from "@ausecon/core";
|
|
31
|
+
import { Effect } from "effect";
|
|
32
|
+
import { FetchHttpClient } from "effect/http";
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|