diff --git a/packages/@d-zero/google-sheets/src/sheet-table.ts b/packages/@d-zero/google-sheets/src/sheet-table.ts index c2b91a23..41a808cf 100644 --- a/packages/@d-zero/google-sheets/src/sheet-table.ts +++ b/packages/@d-zero/google-sheets/src/sheet-table.ts @@ -9,11 +9,17 @@ import { Sheets } from './sheets/sheets.js'; // Google Sheets epoch (December 30, 1899) const SHEETS_EPOCH = new Date(1899, 11, 30).getTime(); +/** + * Configuration for a single header column in a sheet table. + */ export type HeaderCell = { readonly label: string; readonly conditionalFormatRules?: sheets_v4.Schema$ConditionalFormatRule[]; }; +/** + * Options for creating a {@link SheetTable}. + */ export type SheetTableOptions = { readonly bodyStartRow?: number; readonly frozen?: { @@ -22,16 +28,30 @@ export type SheetTableOptions = { }; }; +/** + * Header configuration that searches for existing header labels in the sheet. + * @template T - Record type whose keys correspond to header labels in the sheet + */ export type SearchTableHeaders = { readonly headerRowNumber?: number; readonly search: readonly (keyof T)[]; }; +/** + * Header configuration that explicitly defines column headers and their labels. + * @template T - Record type whose keys are used as header identifiers + */ export type DefineHeader = { readonly headerRowNumber?: number; readonly define: Record; }; +/** + * Typed table interface for reading from and writing to a Google Sheets worksheet. + * + * Use the static {@link SheetTable.create} factory method to instantiate. + * @template T - Record type representing a single row of data + */ export class SheetTable { readonly #auth: OAuth2Client; readonly #bodyStartRow: number; @@ -67,6 +87,10 @@ export class SheetTable { this.#bodyStartRow = options?.bodyStartRow ?? 2; } + /** + * Appends rows to the sheet. + * @param records - Array of row data keyed by header identifiers + */ async addRecords(records: ReadonlyArray>) { if (!this.#sheet) { throw new Error('Sheet is not created'); @@ -84,6 +108,14 @@ export class SheetTable { ); } + /** + * Retrieves all data rows with header-mapped values and row visibility state. + * + * Each returned record includes: + * - `hiddenByUser` (`boolean`): `true` if the row is manually hidden by a user + * - `hiddenByFilter` (`boolean`): `true` if the row is hidden by a filter view + * @returns Array of records typed as `T & { hiddenByUser: boolean; hiddenByFilter: boolean }` + */ async getData() { if (!this.#sheet) { throw new Error('Sheet is not created'); @@ -112,17 +144,21 @@ export class SheetTable { typeMap.set(firstColIndex + cellType.index, cellType.type); } - // Get all data - const data = await this.#sheet.getValues( - `${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`, - headers.at(-1)?.row ?? 'A', - ); + // Get all data and row metadata in parallel + const [data, rowMetadata] = await Promise.all([ + this.#sheet.getValues( + `${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`, + headers.at(-1)?.row ?? 'A', + ), + this.#sheet.getRowMetadata(this.#bodyStartRow), + ]); if (data == null) { return []; } - const list = data.map((_d) => { + const list = data.map((_d, rowIndex) => { + const meta = rowMetadata[rowIndex]; const _data: Partial = {}; for (const header of headers) { @@ -133,7 +169,11 @@ export class SheetTable { _data[header.key] = convertValue(rawValue, cellType) as T[keyof T]; } - return _data as T; + return { + ..._data, + hiddenByUser: meta?.hiddenByUser ?? false, + hiddenByFilter: meta?.hiddenByFilter ?? false, + } as T & { hiddenByUser: boolean; hiddenByFilter: boolean }; }); return list; @@ -177,6 +217,16 @@ export class SheetTable { } } + /** + * Creates and initializes a new {@link SheetTable} instance. + * @template T - Record type representing a single row of data + * @param sheetUrl - Full URL of the Google Spreadsheet + * @param sheetName - Name of the worksheet tab to operate on + * @param auth - Authenticated OAuth2 client for Google Sheets API access + * @param header - Header configuration (define columns explicitly or search existing ones) + * @param options - Additional table options such as frozen panes and body start row + * @returns Initialized SheetTable instance ready for read/write operations + */ static async create( sheetUrl: string, sheetName: string, @@ -197,10 +247,11 @@ interface Header { } /** - * - * @param sheet - * @param headerRowNumber - * @param keys + * Resolves header keys to their column positions by reading the header row from the sheet. + * @param sheet - Sheet instance to read headers from + * @param headerRowNumber - 1-based row number where headers are located + * @param keys - Header keys to search for in the header row + * @returns Resolved headers sorted by column index, excluding keys not found */ async function getHeaders( sheet: Sheet, @@ -225,9 +276,9 @@ async function getHeaders( } /** - * Convert column number to alphabet column name. - * Example: 5 => "E", 100 => "CV" - * @param col + * Converts a 1-based column number to an alphabetic column name (e.g. 5 → "E", 100 → "CV"). + * @param col - 1-based column number + * @returns Alphabetic column name */ function getClmName(col: number): string { const COUNT_OF_ALPHABET = 26; diff --git a/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts b/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts new file mode 100644 index 00000000..6069b664 --- /dev/null +++ b/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts @@ -0,0 +1,107 @@ +import { describe, test, expect, vi } from 'vitest'; + +import { Sheet } from './sheet.js'; + +/** + * + * @param rowMetadata + */ +function createMockParent(rowMetadata: Record[]) { + return { + getWithGridData: vi.fn().mockResolvedValue({ + data: { + sheets: [ + { + data: [ + { + rowMetadata, + }, + ], + }, + ], + }, + }), + }; +} + +describe('getRowMetadata', () => { + const mockSheet = { + properties: { title: 'TestSheet', sheetId: 0 }, + }; + + test('returns hiddenByUser: true for user-hidden rows', async () => { + const parent = createMockParent([{ hiddenByUser: true }, { hiddenByUser: false }]); + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([ + { hiddenByUser: true, hiddenByFilter: false }, + { hiddenByUser: false, hiddenByFilter: false }, + ]); + }); + + test('returns hiddenByFilter: true for filter-hidden rows', async () => { + const parent = createMockParent([ + { hiddenByFilter: true }, + { hiddenByFilter: false }, + ]); + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([ + { hiddenByUser: false, hiddenByFilter: true }, + { hiddenByUser: false, hiddenByFilter: false }, + ]); + }); + + test('returns both true when hidden by user and filter', async () => { + const parent = createMockParent([{ hiddenByUser: true, hiddenByFilter: true }]); + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([{ hiddenByUser: true, hiddenByFilter: true }]); + }); + + test('returns both false for visible rows', async () => { + const parent = createMockParent([{}]); + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([{ hiddenByUser: false, hiddenByFilter: false }]); + }); + + test('returns empty array when rowMetadata is empty', async () => { + const parent = createMockParent([]); + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([]); + }); + + test('returns empty array when API returns no sheets data', async () => { + const parent = { + getWithGridData: vi.fn().mockResolvedValue({ + data: { sheets: [] }, + }), + }; + const sheet = new Sheet(mockSheet as never, parent as never); + + const result = await sheet.getRowMetadata(2); + + expect(result).toEqual([]); + }); + + test('passes correct range to getWithGridData', async () => { + const parent = createMockParent([]); + const sheet = new Sheet(mockSheet as never, parent as never); + + await sheet.getRowMetadata(3); + + expect(parent.getWithGridData).toHaveBeenCalledWith("'TestSheet'!A3:A"); + }); +}); diff --git a/packages/@d-zero/google-sheets/src/sheets/sheet.ts b/packages/@d-zero/google-sheets/src/sheets/sheet.ts index 29853fcb..a5c56ee1 100644 --- a/packages/@d-zero/google-sheets/src/sheets/sheet.ts +++ b/packages/@d-zero/google-sheets/src/sheets/sheet.ts @@ -168,6 +168,27 @@ export class Sheet { return index; } + /** + * Retrieves row visibility metadata starting from the specified row. + * + * - `hiddenByUser`: The row is manually hidden by a user (right-click → "Hide row") + * - `hiddenByFilter`: The row is hidden by a filter view or filter condition + * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#DimensionProperties + * @param startRow - The 1-based row number to start fetching metadata from + * @returns Array of row visibility objects, one per row from `startRow` onward + */ + async getRowMetadata(startRow: number) { + const res = await this.#parent.getWithGridData( + `'${this.props.title}'!A${startRow}:A`, + ); + const sheet = res.data.sheets?.[0]; + const rowMetadataList = sheet?.data?.[0]?.rowMetadata ?? []; + return rowMetadataList.map((metadata) => ({ + hiddenByUser: metadata.hiddenByUser === true, + hiddenByFilter: metadata.hiddenByFilter === true, + })); + } + async getValues(row: string, col: string) { const res = await this.#parent.get({ range: `'${this.props.title}'!${row}:${col}`,