@cascateer/sterio 1.0.1

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.
Files changed (58) hide show
  1. package/.env +0 -0
  2. package/.github/workflows/publish.yml +11 -0
  3. package/.vscode/settings.json +4 -0
  4. package/build/routes.ts +413 -0
  5. package/build/swagger.json +775 -0
  6. package/openapitools.json +14 -0
  7. package/package.json +56 -0
  8. package/src/Spotify.service.ts +202 -0
  9. package/src/Sterio.service.ts +208 -0
  10. package/src/Youtube.service.ts +112 -0
  11. package/src/YoutubeMusic.service.ts +52 -0
  12. package/src/api/.openapi-generator/FILES +29 -0
  13. package/src/api/.openapi-generator/VERSION +1 -0
  14. package/src/api/.openapi-generator-ignore +23 -0
  15. package/src/api/apis/DefaultApi.ts +154 -0
  16. package/src/api/apis/index.ts +1 -0
  17. package/src/api/index.ts +4 -0
  18. package/src/api/models/GetYoutubeMusicAlbums200ResponseInner.ts +29 -0
  19. package/src/api/models/PartialSterioAlbumResourcesFull.ts +39 -0
  20. package/src/api/models/SpotifyApiAlbumObjectSimplified.ts +152 -0
  21. package/src/api/models/SpotifyApiArtistObjectSimplified.ts +68 -0
  22. package/src/api/models/SpotifyApiExternalUrlObject.ts +25 -0
  23. package/src/api/models/SpotifyApiImageObject.ts +38 -0
  24. package/src/api/models/SpotifyApiRestrictionsObject.ts +24 -0
  25. package/src/api/models/SterioAlbum.ts +39 -0
  26. package/src/api/models/SterioAlbumResource.ts +29 -0
  27. package/src/api/models/SterioAlbumResourcesTable.ts +34 -0
  28. package/src/api/models/SterioAlbumSongs.ts +38 -0
  29. package/src/api/models/SterioAlbumSongsVideoIdsInner.ts +34 -0
  30. package/src/api/models/YoutubePlaylist.ts +38 -0
  31. package/src/api/models/YoutubeV3Schema36PlaylistItem.ts +59 -0
  32. package/src/api/models/YoutubeV3Schema36PlaylistItemContentDetails.ts +49 -0
  33. package/src/api/models/YoutubeV3Schema36PlaylistItemSnippet.ts +89 -0
  34. package/src/api/models/YoutubeV3Schema36PlaylistItemStatus.ts +26 -0
  35. package/src/api/models/YoutubeV3Schema36ResourceId.ts +44 -0
  36. package/src/api/models/YoutubeV3Schema36Thumbnail.ts +38 -0
  37. package/src/api/models/YoutubeV3Schema36ThumbnailDetails.ts +49 -0
  38. package/src/api/models/index.ts +20 -0
  39. package/src/api/runtime.ts +193 -0
  40. package/src/api/servers.ts +45 -0
  41. package/src/api/tsconfig.json +22 -0
  42. package/src/app.ts +58 -0
  43. package/src/config.ts +6 -0
  44. package/src/controller.ts +148 -0
  45. package/src/lib.ts +112 -0
  46. package/src/main.ts +225 -0
  47. package/src/tables.ts +178 -0
  48. package/src/types.ts +113 -0
  49. package/src/views/spotify/auth-callback.ejs +22 -0
  50. package/tables/document-files/82745273-7eda-4169-8b4a-998d2de00ac2.json +5208 -0
  51. package/tables/spotify-albums/5bff8eba-bd2b-4485-8136-1f89da5db6d7.json +1 -0
  52. package/tables/sterio-albums/81d5b327-68b1-4fef-8e40-491c792e8951.json +1 -0
  53. package/tables/stream-files/f6697f37-7320-41df-aa50-b70264fa1bbf.json +3788 -0
  54. package/tables/youtube-music-albums/55fd9bb8-46ba-4d0e-9376-f2f2cbb1aa38.json +1 -0
  55. package/tables/youtube-playlists/e3153ab8-8e1f-491e-995e-3142d07f4ecc.json +1 -0
  56. package/tables/youtube-videos/9067172e-4803-48f1-8b8e-4b942f3ca228.json +1 -0
  57. package/tsconfig.json +16 -0
  58. package/tsoa.json +14 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ *
3
+ * Represents the configuration of a server including its
4
+ * url template and variable configuration based on the url.
5
+ *
6
+ */
7
+ export class ServerConfiguration<T extends { [key: string]: string }> {
8
+ public constructor(private url: string, private variableConfiguration: T, private description: string) {}
9
+
10
+ /**
11
+ * Sets the value of the variables of this server.
12
+ *
13
+ * @param variableConfiguration a partial variable configuration for the variables contained in the url
14
+ */
15
+ public setVariables(variableConfiguration: Partial<T>) {
16
+ Object.assign(this.variableConfiguration, variableConfiguration);
17
+ }
18
+
19
+ public getConfiguration(): T {
20
+ return this.variableConfiguration;
21
+ }
22
+
23
+ public getDescription(): string {
24
+ return this.description;
25
+ }
26
+
27
+ /**
28
+ * Constructions the URL this server using the url with variables
29
+ * replaced with their respective values
30
+ */
31
+ public getUrl(): string {
32
+ let replacedUrl = this.url;
33
+ for (const key in this.variableConfiguration) {
34
+ if (this.variableConfiguration.hasOwnProperty(key)) {
35
+ const re = new RegExp("{" + key + "}","g");
36
+ replacedUrl = replacedUrl.replace(re, this.variableConfiguration[key]);
37
+ }
38
+ }
39
+ return replacedUrl;
40
+ }
41
+ }
42
+
43
+ const server1 = new ServerConfiguration<{ }>("http://127.0.0.1:2700", { }, "");
44
+
45
+ export const servers = [server1];
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "target": "es5",
5
+ "module": "commonjs",
6
+ "moduleResolution": "node",
7
+ "outDir": "dist",
8
+ "rootDir": ".",
9
+ "lib": [
10
+ "es6",
11
+ "dom",
12
+ "es2017"
13
+ ],
14
+ "typeRoots": [
15
+ "node_modules/@types"
16
+ ]
17
+ },
18
+ "exclude": [
19
+ "dist",
20
+ "node_modules"
21
+ ]
22
+ }
package/src/app.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { nonNullable } from "@cascateer/lib";
2
+ import cors from "cors";
3
+ import express, { ErrorRequestHandler, json } from "express";
4
+ import session from "express-session";
5
+ import { StatusCodes } from "http-status-codes";
6
+ import { resolve } from "path";
7
+ import { RegisterRoutes } from "../build/routes";
8
+ import tsoaConfig from "../tsoa.json";
9
+ import { SpotifyService } from "./Spotify.service";
10
+ import { envConfig } from "./config";
11
+
12
+ const { SPOTIFY_REDIRECT_URI } = envConfig();
13
+
14
+ const [APP_HOST, APP_PORT] = tsoaConfig.spec.host.split(":");
15
+
16
+ const app = express();
17
+
18
+ app.use(json());
19
+ app.use(
20
+ cors({
21
+ credentials: true,
22
+ origin: true,
23
+ }),
24
+ );
25
+ app.use(
26
+ session({
27
+ resave: false,
28
+ saveUninitialized: false,
29
+ secret: "secret secret",
30
+ }),
31
+ );
32
+
33
+ app.set("view engine", "ejs");
34
+ app.set("views", resolve(__dirname, "views"));
35
+
36
+ app.get(new URL(nonNullable(SPOTIFY_REDIRECT_URI)).pathname, (req, res) => {
37
+ const code = req.query.code?.toString();
38
+
39
+ if (code != null) {
40
+ SpotifyService.codes.next(code);
41
+ }
42
+
43
+ res.render("spotify/auth-callback", { code: req.query.code });
44
+ });
45
+
46
+ RegisterRoutes(app);
47
+
48
+ app.use(<ErrorRequestHandler>function (error, req, res, next) {
49
+ console.error(error);
50
+
51
+ res
52
+ .status(StatusCodes.INTERNAL_SERVER_ERROR)
53
+ .send(error.stack ?? error.message);
54
+ });
55
+
56
+ app.listen(+nonNullable(APP_PORT), nonNullable(APP_HOST), () =>
57
+ console.log(`Server is running on http://${APP_HOST}:${APP_PORT}`),
58
+ );
package/src/config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { configDotenv } from "dotenv";
2
+
3
+ export const envConfig = () =>
4
+ configDotenv({
5
+ path: [".env.local", ".env"],
6
+ }).parsed ?? {};
@@ -0,0 +1,148 @@
1
+ import { findDupeBy, property, split } from "@cascateer/lib";
2
+ import { LazyPromise } from "@cascateer/lib/promise";
3
+ import { at, find, get, memoize } from "lodash";
4
+ import { lastValueFrom } from "rxjs";
5
+ import { Body, Controller, Get, Patch, Path, Post, Query, Route } from "tsoa";
6
+ import { SpotifyService } from "./Spotify.service";
7
+ import { SterioService } from "./Sterio.service";
8
+ import {
9
+ SterioAlbum,
10
+ SterioAlbumResourcesTable,
11
+ YoutubePlaylist,
12
+ } from "./types";
13
+ import { YoutubeService } from "./Youtube.service";
14
+ import { YoutubeMusicService } from "./YoutubeMusic.service";
15
+
16
+ @Route("sterio")
17
+ export class SterioController extends Controller {
18
+ @Get("album/{id}")
19
+ async getAlbum(@Path() id: string): Promise<SterioAlbum> {
20
+ return new SterioService().albums.accessSome([id]).then(property(0));
21
+ }
22
+
23
+ @Patch("album")
24
+ async updateAlbum(
25
+ @Body()
26
+ album: SterioAlbum,
27
+ ) {
28
+ return new SterioService().updateAlbum(album.id, album);
29
+ }
30
+
31
+ @Post("album/{id}/resources/table")
32
+ async getAlbumResourcesTable(
33
+ @Path() id: string,
34
+ ): Promise<SterioAlbumResourcesTable> {
35
+ const [{ resources: { youtubeMusic, youtube, spotify } = {} }] =
36
+ await new SterioService().albums.accessSome([id]);
37
+
38
+ if (youtubeMusic != null && youtube != null && spotify != null) {
39
+ const [[youtubeMusicAlbum], [youtubePlaylist], [spotifyAlbum]] =
40
+ await LazyPromise.concatAll([
41
+ new LazyPromise(() =>
42
+ new YoutubeMusicService().albums.accessSome([youtubeMusic.id]),
43
+ ),
44
+ new LazyPromise(() =>
45
+ new YoutubeService().playlists.accessSome([youtube.id]),
46
+ ),
47
+ new LazyPromise(() =>
48
+ new SpotifyService().albums.accessSome([spotify.id]),
49
+ ),
50
+ ]);
51
+
52
+ class KeyGenerator<T> {
53
+ constructor(
54
+ public items: T[],
55
+ public iteratees: {
56
+ uid?: string;
57
+ name: string;
58
+ },
59
+ ) {}
60
+
61
+ keys = memoize((type?: keyof typeof this.iteratees) =>
62
+ this.items.map((item) =>
63
+ (type === "name"
64
+ ? this.iteratees.name
65
+ : this.iteratees.uid || this.iteratees.name
66
+ ).replaceAll(
67
+ /(\$\{([^\/]*)(?:\/([^\/]*)\/(?:([^\/]*)\/(?:([^\/]*))?)?)?\})/g,
68
+ (_, __, path, pattern, replacement, flags) =>
69
+ get(item, path).replace(
70
+ new RegExp(pattern, flags),
71
+ replacement ?? "",
72
+ ),
73
+ ),
74
+ ),
75
+ );
76
+
77
+ at = <U>(mask: KeyGenerator<U>, type?: keyof typeof this.iteratees) =>
78
+ at(
79
+ this.keys(type),
80
+ mask.keys().map((key) => this.keys().indexOf(key)),
81
+ );
82
+ }
83
+
84
+ const youtubeMusicKeyGenerator = new KeyGenerator(
85
+ youtubeMusicAlbum.songs,
86
+ {
87
+ uid: youtubeMusic.iteratee,
88
+ name: "${name}",
89
+ },
90
+ );
91
+
92
+ const youtubeKeyGenerator = new KeyGenerator(youtubePlaylist.items, {
93
+ uid: youtube?.iteratee,
94
+ name: "${snippet.title}",
95
+ });
96
+
97
+ const spotifyKeyGenerator = new KeyGenerator(spotifyAlbum.tracks.items, {
98
+ uid: spotify?.iteratee,
99
+ name: "${name}",
100
+ });
101
+
102
+ if (
103
+ findDupeBy(youtubeMusicKeyGenerator.keys()) == null &&
104
+ findDupeBy(youtubeKeyGenerator.keys()) == null &&
105
+ findDupeBy(spotifyKeyGenerator.keys()) == null
106
+ ) {
107
+ return {
108
+ youtubeMusic: youtubeMusicKeyGenerator.keys("name"),
109
+ youtube: youtubeKeyGenerator.at(youtubeMusicKeyGenerator, "name"),
110
+ spotify: spotifyKeyGenerator.at(youtubeMusicKeyGenerator, "name"),
111
+ };
112
+ }
113
+ }
114
+
115
+ return {
116
+ youtubeMusic: [],
117
+ youtube: [],
118
+ spotify: [],
119
+ };
120
+ }
121
+
122
+ @Get("spotify/albums")
123
+ async getSpotifyAlbums(
124
+ @Query("q") query: string,
125
+ ): Promise<SpotifyApi.AlbumObjectSimplified[]> {
126
+ return new SpotifyService().searchAlbums(query, 10);
127
+ }
128
+
129
+ @Get("youtube/playlists")
130
+ async getYoutubePlaylists(@Query() ids: string): Promise<YoutubePlaylist[]> {
131
+ return new YoutubeService().playlists.accessSome(split(ids));
132
+ }
133
+
134
+ @Get("youtube-music/albums")
135
+ async getYoutubeMusicAlbums(
136
+ @Query("q") query: string,
137
+ ): Promise<{ albumId: string; text: string }[]> {
138
+ return lastValueFrom(new YoutubeMusicService().searchAlbums(query)).then(
139
+ (albums) =>
140
+ new SterioService().albums.accessAll().then((sterioAlbums) =>
141
+ albums.map(({ albumId, ...album }) => ({
142
+ albumId,
143
+ text: `${find(sterioAlbums, { resources: { youtubeMusic: { id: albumId } } }) != null ? "☑" : "☐"} ${album.name} / ${album.artist.name} (${album.year})`,
144
+ })),
145
+ ),
146
+ );
147
+ }
148
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,112 @@
1
+ import {
2
+ AsyncEndoFunction,
3
+ keys,
4
+ MaybePromise,
5
+ property,
6
+ } from "@cascateer/lib";
7
+ import { reduce } from "@cascateer/lib/observable";
8
+ import {
9
+ Dictionary,
10
+ maxBy,
11
+ pad,
12
+ padEnd,
13
+ padStart,
14
+ truncate,
15
+ TruncateOptions,
16
+ } from "lodash";
17
+ import {
18
+ identity,
19
+ mergeAll,
20
+ OperatorFunction,
21
+ startWith,
22
+ UnaryFunction,
23
+ } from "rxjs";
24
+
25
+ interface TableColumnOptions<U> extends TruncateOptions {
26
+ stringify?: UnaryFunction<U, string>;
27
+ align?: "start" | "end" | "center";
28
+ fill?: string;
29
+ }
30
+
31
+ type TableTemplate<T extends Dictionary<unknown>> = {
32
+ [K in keyof T]: TableColumnOptions<T[K]>;
33
+ };
34
+
35
+ export class Table<T extends Dictionary<unknown>> {
36
+ constructor(
37
+ public rows: T[],
38
+ public template: TableTemplate<T>,
39
+ ) {}
40
+
41
+ toString(): string {
42
+ return this.rows
43
+ .map((row) =>
44
+ [
45
+ "",
46
+ ...keys(this.template).map((key) => {
47
+ const {
48
+ stringify = String,
49
+ align = "start",
50
+ fill,
51
+ length = maxBy(
52
+ this.rows.map(property(key)).map(stringify),
53
+ property("length"),
54
+ )?.length,
55
+ omission,
56
+ separator,
57
+ } = this.template[key];
58
+
59
+ return { start: padEnd, end: padStart, center: pad }[align](
60
+ truncate(stringify(row[key]), { length, omission, separator }),
61
+ length,
62
+ fill,
63
+ );
64
+ }),
65
+ "",
66
+ ].join("│"),
67
+ )
68
+ .join("\n");
69
+ }
70
+
71
+ filter(predicate: (row: T, index: number) => boolean) {
72
+ return new Table(this.rows.filter(predicate), this.template);
73
+ }
74
+ }
75
+
76
+ export const pageIndex = (x: number, y: number) =>
77
+ `${new Intl.NumberFormat("en-US", {
78
+ minimumIntegerDigits: `${y}`.length,
79
+ }).format(x)}/` + y;
80
+
81
+ export const secondsToHms = (seconds: number) =>
82
+ new Date(seconds * 1e3).toISOString().slice(11, 19).replace(/^00:/, "");
83
+
84
+ export const tapPromise = <T>(
85
+ input: Promise<T>,
86
+ on?: Partial<{
87
+ start: UnaryFunction<void, void>;
88
+ finish: UnaryFunction<T, void>;
89
+ error: UnaryFunction<any, void>;
90
+ }>,
91
+ ) =>
92
+ Promise.resolve(on?.start?.call(null)).then(() =>
93
+ input
94
+ .catch((error) => {
95
+ on?.error?.call(null, error);
96
+
97
+ throw error;
98
+ })
99
+ .then((value) => (on?.finish?.call(null, value), value)),
100
+ );
101
+
102
+ export const chainFunctions =
103
+ <T>(seed: () => MaybePromise<T>): OperatorFunction<AsyncEndoFunction<T>, T> =>
104
+ (source) =>
105
+ source.pipe(
106
+ startWith(identity),
107
+ reduce<(() => MaybePromise<T>) | AsyncEndoFunction<T>, Promise<T>>(
108
+ (state, predicate) => state.then(predicate),
109
+ async () => seed(),
110
+ ),
111
+ mergeAll(),
112
+ );
package/src/main.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { nonNullable } from "@cascateer/lib";
2
+ import filenamify from "filenamify";
3
+ import { existsSync } from "fs";
4
+ import { copyFile, mkdir, rm, writeFile } from "fs/promises";
5
+ import { compact, isString } from "lodash";
6
+ import NodeID3 from "node-id3";
7
+ import { oraPromise } from "ora";
8
+ import { resolve } from "path";
9
+ import { SterioService } from "./Sterio.service";
10
+ import { StreamFileTable } from "./tables";
11
+ import { SterioAlbumFull } from "./types";
12
+
13
+ const OUT_URL = resolve(__dirname, "../../..", "out");
14
+ const OUT = process.argv.includes("--out");
15
+
16
+ const run = async () => {
17
+ console.clear();
18
+
19
+ if (OUT && existsSync(OUT_URL)) {
20
+ await rm(OUT_URL, { recursive: true });
21
+ }
22
+
23
+ const albums = new Array<SterioAlbumFull>();
24
+
25
+ for (const sterioAlbum of await new SterioService().albums.accessAll()) {
26
+ if (sterioAlbum.resources?.youtubeMusic == null) {
27
+ continue;
28
+ }
29
+
30
+ albums.push(
31
+ await oraPromise(
32
+ async (spinner) => {
33
+ const album = await new SterioService().getAlbumFull(
34
+ sterioAlbum.id,
35
+ spinner,
36
+ );
37
+
38
+ const albumPath = resolve(
39
+ OUT_URL,
40
+ filenamify([album.artist.name, album.name].join(" - "), {
41
+ replacement: "",
42
+ }),
43
+ );
44
+
45
+ if (OUT) {
46
+ await mkdir(albumPath, { recursive: true });
47
+ }
48
+
49
+ for (const song of album.songs) {
50
+ if (song.excluded) {
51
+ continue;
52
+ }
53
+
54
+ if (OUT) {
55
+ const sourceFile = await new StreamFileTable().getFile(
56
+ nonNullable(song.tags.audioSourceUrl),
57
+ spinner,
58
+ );
59
+
60
+ const targetFile = resolve(
61
+ albumPath,
62
+ filenamify(
63
+ `${compact([song.tags.trackNumber?.split("/")[0], song.tags.title]).join(" ")}${sourceFile.extname}`,
64
+ { replacement: "" },
65
+ ),
66
+ );
67
+
68
+ await copyFile(sourceFile.path, targetFile).then(() =>
69
+ NodeID3.Promise.write(song.tags, targetFile),
70
+ );
71
+ }
72
+ }
73
+
74
+ return album;
75
+ },
76
+ {
77
+ text: `Processing...`,
78
+ prefixText: `[💿 Album ${sterioAlbum.id}]`,
79
+ successText: (album) => `Processed (${album.name}).`,
80
+ },
81
+ ),
82
+ );
83
+ }
84
+
85
+ return oraPromise(
86
+ Promise.resolve(albums).then((albums) =>
87
+ mkdir(OUT_URL, { recursive: true }).then(async () =>
88
+ Promise.resolve(resolve(OUT_URL, "index.html")).then((path) =>
89
+ writeFile(
90
+ path,
91
+ `
92
+ <!doctype html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="utf-8" />
96
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
97
+ <title>STERI📀COLLECTION</title>
98
+ <style>
99
+ body {
100
+ background-color: black;
101
+ }
102
+
103
+ table, tr, td {
104
+ border: 1px solid white;
105
+ }
106
+
107
+ table {
108
+ border-collapse: collapse;
109
+ white-space: break-spaces;
110
+ }
111
+
112
+ tr[data-row-index="0"] td {
113
+ background-color: grey
114
+ }
115
+
116
+ tr[data-row-hidden="true"] {
117
+ text-decoration: line-through;
118
+ }
119
+
120
+ td {
121
+ padding: 0.5em;
122
+ }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <table>
127
+ ${albums
128
+ .map((album) => ({
129
+ index:
130
+ albums.findIndex(({ id }) => id === album.id) % 2,
131
+ rows: album.songs.map((song, hash) => ({
132
+ color: song.patched ? "blue" : "white",
133
+ columns: Object.entries({
134
+ albumArtwork: {
135
+ content: `<img width="60" src="${song.artwork.album?.path}">`,
136
+ hash: song.artwork.album?.checksum,
137
+ },
138
+ songArtwork: {
139
+ content: `<figure><img width="60" src="${song.artwork.song?.path}"><figcaption></figcaption>${song.artwork.song?.checksum.slice(-7)}</figure>`,
140
+ hash: song.artwork.song?.checksum,
141
+ },
142
+ channel: {
143
+ content: song.channel?.title,
144
+ hash: song.channel?.id,
145
+ },
146
+ trackNumber: song.tags.trackNumber,
147
+ title: song.tags.title,
148
+ duration: {
149
+ content: song.duration?.toISOString(),
150
+ hash,
151
+ },
152
+ album: [song.tags.album, album.id].join("\n"),
153
+ year: song.tags.year,
154
+ audioSourceUrl: {
155
+ content: `<a target="_blank" href="${song.tags.audioSourceUrl}">${song.tags.audioSourceUrl != null ? new URL(song.tags.audioSourceUrl).searchParams.get("v") : ""}</a>`,
156
+ hash: song.tags.audioSourceUrl,
157
+ },
158
+ }).flatMap(([key, column]) =>
159
+ column != null
160
+ ? {
161
+ key,
162
+ ...(isString(column)
163
+ ? {
164
+ content: column,
165
+ hash: column,
166
+ }
167
+ : column),
168
+ }
169
+ : [],
170
+ ),
171
+ excluded: song.excluded,
172
+ })),
173
+ }))
174
+ .flatMap((album) =>
175
+ album.rows.map(
176
+ (row, rowIndex, rows) =>
177
+ `<tr data-row-index="${album.index}" data-row-hidden="${row.excluded}" style="color: ${row.color};">${row.columns
178
+ .flatMap(({ key, content, hash }) => {
179
+ if (
180
+ rows
181
+ .map((row) =>
182
+ nonNullable(
183
+ row.columns.find(
184
+ (column) => column.key === key,
185
+ ),
186
+ ),
187
+ )
188
+ .every((column) => column.hash === hash)
189
+ ) {
190
+ if (rowIndex > 0) {
191
+ return [];
192
+ }
193
+
194
+ return {
195
+ rowSpan: rows.length,
196
+ content,
197
+ };
198
+ }
199
+
200
+ return { rowSpan: 1, content };
201
+ })
202
+ .map(
203
+ ({ rowSpan, content }) =>
204
+ `<td rowspan="${rowSpan}">${content}</td>`,
205
+ )
206
+ .join("\n")}</tr>`,
207
+ ),
208
+ )
209
+ .join("\n")}
210
+ </table>
211
+ </body>
212
+ </html>`,
213
+ ).then(() => path),
214
+ ),
215
+ ),
216
+ ),
217
+ {
218
+ text: `Compiling...`,
219
+ prefixText: "[📒 Summary]",
220
+ successText: (path) => `Written to ${path}.`,
221
+ },
222
+ );
223
+ };
224
+
225
+ run();