@moonhr/sheets-client 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 +183 -0
- package/dist/index.d.mts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +167 -0
- package/dist/index.mjs +136 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# @moonhr/sheets-client
|
|
2
|
+
|
|
3
|
+
Google Sheets를 폼 데이터 저장소로 사용할 때 필요한 인증·CRUD 보일러플레이트를 제거해주는 경량 클라이언트입니다.
|
|
4
|
+
서비스 계정 인증, 행 추가/조회/수정, 전화번호 셀 포맷 처리를 포함합니다.
|
|
5
|
+
|
|
6
|
+
## 설치
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @moonhr/sheets-client
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 사전 준비
|
|
13
|
+
|
|
14
|
+
1. [Google Cloud Console](https://console.cloud.google.com/)에서 서비스 계정을 생성하고 JSON 키를 발급합니다.
|
|
15
|
+
2. 해당 서비스 계정 이메일을 대상 스프레드시트에 **편집자** 권한으로 공유합니다.
|
|
16
|
+
3. 환경변수를 설정합니다.
|
|
17
|
+
|
|
18
|
+
```env
|
|
19
|
+
GOOGLE_SERVICE_ACCOUNT_KEY={"client_email":"...","private_key":"..."}
|
|
20
|
+
GOOGLE_SHEETS_SPREADSHEET_ID=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms
|
|
21
|
+
GOOGLE_SHEETS_SHEET_NAME=Sheet1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 기본 사용법
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { createSheetsClient } from '@moonhr/sheets-client'
|
|
28
|
+
|
|
29
|
+
const client = createSheetsClient({
|
|
30
|
+
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,
|
|
31
|
+
spreadsheetId: process.env.GOOGLE_SHEETS_SPREADSHEET_ID,
|
|
32
|
+
sheetName: process.env.GOOGLE_SHEETS_SHEET_NAME,
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
### `appendRow(values, range?)`
|
|
39
|
+
|
|
40
|
+
시트 마지막 행에 데이터를 추가합니다.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
await client.appendRow(['2024-01-01', '홍길동', '회사명', '01012345678'])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `values` | `string[]` | — | 셀 값 배열 |
|
|
49
|
+
| `range` | `string` | `'A:Z'` | 추가 범위 |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### `getRows(range?)`
|
|
54
|
+
|
|
55
|
+
지정 범위의 모든 행을 반환합니다. 각 셀은 trim된 문자열입니다.
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
const rows = await client.getRows('A:E')
|
|
59
|
+
// [['헤더1', '헤더2', ...], ['값1', '값2', ...], ...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
### `findRow(matcher, options?)`
|
|
65
|
+
|
|
66
|
+
조건에 맞는 첫 번째 행을 반환합니다. 없으면 `null`.
|
|
67
|
+
|
|
68
|
+
**컬럼 인덱스로 매칭 (0-based)**
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const result = await client.findRow({ 1: '홍길동', 2: '회사명' })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
양쪽 trim + 소문자 비교를 수행합니다.
|
|
75
|
+
|
|
76
|
+
**커스텀 함수로 매칭**
|
|
77
|
+
|
|
78
|
+
전화번호처럼 숫자 비교가 필요한 경우에 사용합니다.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { digitsOnly } from '@moonhr/sheets-client'
|
|
82
|
+
|
|
83
|
+
const result = await client.findRow((row) => {
|
|
84
|
+
return row[1].trim().toLowerCase() === '홍길동' &&
|
|
85
|
+
digitsOnly(row[3]) === digitsOnly('010-1234-5678')
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**반환값**
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
{
|
|
93
|
+
rowIndex: number // 시트 행 번호 (1-based) — updateRow에 그대로 사용
|
|
94
|
+
values: string[] // 해당 행의 셀 값 배열
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**options**
|
|
99
|
+
|
|
100
|
+
| 옵션 | 타입 | 기본값 | 설명 |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `range` | `string` | `'A:Z'` | 검색 범위 |
|
|
103
|
+
| `skipHeader` | `boolean` | `true` | 첫 번째 행(헤더) 건너뜀 여부 |
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### `updateRow(rowIndex, values, startCol?)`
|
|
108
|
+
|
|
109
|
+
특정 행의 셀을 업데이트합니다.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// findRow가 반환한 rowIndex를 그대로 사용
|
|
113
|
+
await client.updateRow(result.rowIndex, ['홍길동', '새회사', '01099998888'], 'B')
|
|
114
|
+
// → B{rowIndex}:D{rowIndex} 범위를 업데이트
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| 파라미터 | 타입 | 기본값 | 설명 |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `rowIndex` | `number` | — | 시트 행 번호 (2 이상) |
|
|
120
|
+
| `values` | `string[]` | — | 교체할 셀 값 배열 |
|
|
121
|
+
| `startCol` | `string` | `'A'` | 시작 열 문자 |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 유틸리티
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { toTextCell, digitsOnly, stripQuotes } from '@moonhr/sheets-client'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| 함수 | 설명 | 예시 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `toTextCell(value)` | 전화번호를 시트에 텍스트로 강제 저장 (숫자 자동변환 방지) | `'01012345678'` → `"'01012345678"` |
|
|
134
|
+
| `digitsOnly(value)` | 숫자만 추출 (전화번호 비교용) | `'010-1234-5678'` → `'01012345678'` |
|
|
135
|
+
| `stripQuotes(value)` | 환경변수 래핑 따옴표 제거 | `'"Sheet1"'` → `'Sheet1'` |
|
|
136
|
+
|
|
137
|
+
## 실사용 예시 — RSVP 폼
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { createSheetsClient, toTextCell, digitsOnly } from '@moonhr/sheets-client'
|
|
141
|
+
|
|
142
|
+
const client = createSheetsClient({
|
|
143
|
+
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,
|
|
144
|
+
spreadsheetId: process.env.GOOGLE_SHEETS_SPREADSHEET_ID,
|
|
145
|
+
sheetName: process.env.GOOGLE_SHEETS_SHEET_NAME,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// 신청 접수
|
|
149
|
+
await client.appendRow([
|
|
150
|
+
new Date().toISOString(),
|
|
151
|
+
'홍길동',
|
|
152
|
+
'(주)회사',
|
|
153
|
+
toTextCell('01012345678'),
|
|
154
|
+
'hong@example.com',
|
|
155
|
+
'동의',
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
// 신청 조회
|
|
159
|
+
const found = await client.findRow((row) =>
|
|
160
|
+
row[1].trim() === '홍길동' &&
|
|
161
|
+
digitsOnly(row[3]) === '01012345678'
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// 신청 수정
|
|
165
|
+
if (found) {
|
|
166
|
+
await client.updateRow(
|
|
167
|
+
found.rowIndex,
|
|
168
|
+
['홍길동', '새회사', toTextCell('01099998888'), 'new@example.com'],
|
|
169
|
+
'B'
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## 빌드
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm run build # dist/ 생성
|
|
178
|
+
npm run dev # watch 모드
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## 라이선스
|
|
182
|
+
|
|
183
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
interface ServiceAccountKey {
|
|
2
|
+
client_email: string;
|
|
3
|
+
private_key: string;
|
|
4
|
+
}
|
|
5
|
+
interface SheetsClientConfig {
|
|
6
|
+
/** 서비스 계정 JSON (문자열 또는 객체) */
|
|
7
|
+
serviceAccountKey: string | ServiceAccountKey;
|
|
8
|
+
spreadsheetId: string;
|
|
9
|
+
sheetName: string;
|
|
10
|
+
}
|
|
11
|
+
interface FindResult {
|
|
12
|
+
/** 시트의 실제 행 번호 (1-based, 헤더 포함) — updateRow에 그대로 사용 가능 */
|
|
13
|
+
rowIndex: number;
|
|
14
|
+
values: string[];
|
|
15
|
+
}
|
|
16
|
+
type RowMatcher = Record<number, string> | ((row: string[], rowIndex: number) => boolean);
|
|
17
|
+
|
|
18
|
+
declare class SheetsClient {
|
|
19
|
+
private readonly spreadsheetId;
|
|
20
|
+
private readonly sheetName;
|
|
21
|
+
private readonly serviceAccountKey;
|
|
22
|
+
constructor(config: SheetsClientConfig);
|
|
23
|
+
private sheets;
|
|
24
|
+
private range;
|
|
25
|
+
/** 시트 끝에 행 추가 */
|
|
26
|
+
appendRow(values: string[], range?: string): Promise<void>;
|
|
27
|
+
/** 지정 범위의 모든 행 반환 (각 셀은 trim된 문자열) */
|
|
28
|
+
getRows(range?: string): Promise<string[][]>;
|
|
29
|
+
/**
|
|
30
|
+
* 조건에 맞는 첫 번째 행을 반환.
|
|
31
|
+
*
|
|
32
|
+
* matcher가 Record이면 { [컬럼인덱스]: 기대값 } — 양쪽 trim+소문자 비교
|
|
33
|
+
* matcher가 함수이면 (row, rowIndex) => boolean — 커스텀 비교 가능
|
|
34
|
+
*
|
|
35
|
+
* @param skipHeader true이면 첫 번째 행(헤더)을 건너뜀 (기본값: true)
|
|
36
|
+
*/
|
|
37
|
+
findRow(matcher: RowMatcher, options?: {
|
|
38
|
+
range?: string;
|
|
39
|
+
skipHeader?: boolean;
|
|
40
|
+
}): Promise<FindResult | null>;
|
|
41
|
+
/**
|
|
42
|
+
* 특정 행 업데이트.
|
|
43
|
+
*
|
|
44
|
+
* @param rowIndex 시트 행 번호 (1-based, findRow가 반환한 값 그대로 사용)
|
|
45
|
+
* @param values 교체할 셀 값 배열
|
|
46
|
+
* @param startCol 시작 열 문자 (기본값: 'A')
|
|
47
|
+
*/
|
|
48
|
+
updateRow(rowIndex: number, values: string[], startCol?: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
declare function createSheetsClient(config: SheetsClientConfig): SheetsClient;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 전화번호처럼 숫자만 남겨야 하는 값을 시트에 텍스트로 강제 저장.
|
|
54
|
+
* (앞에 ' 붙이면 구글 시트가 숫자 변환을 건너뜀)
|
|
55
|
+
*/
|
|
56
|
+
declare function toTextCell(value: string): string;
|
|
57
|
+
/** 숫자만 추출 (전화번호 비교용) */
|
|
58
|
+
declare function digitsOnly(value: string): string;
|
|
59
|
+
/** 환경변수 래핑 따옴표 제거 */
|
|
60
|
+
declare function stripQuotes(value: string): string;
|
|
61
|
+
|
|
62
|
+
export { type FindResult, type RowMatcher, type ServiceAccountKey, SheetsClient, type SheetsClientConfig, createSheetsClient, digitsOnly, stripQuotes, toTextCell };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
interface ServiceAccountKey {
|
|
2
|
+
client_email: string;
|
|
3
|
+
private_key: string;
|
|
4
|
+
}
|
|
5
|
+
interface SheetsClientConfig {
|
|
6
|
+
/** 서비스 계정 JSON (문자열 또는 객체) */
|
|
7
|
+
serviceAccountKey: string | ServiceAccountKey;
|
|
8
|
+
spreadsheetId: string;
|
|
9
|
+
sheetName: string;
|
|
10
|
+
}
|
|
11
|
+
interface FindResult {
|
|
12
|
+
/** 시트의 실제 행 번호 (1-based, 헤더 포함) — updateRow에 그대로 사용 가능 */
|
|
13
|
+
rowIndex: number;
|
|
14
|
+
values: string[];
|
|
15
|
+
}
|
|
16
|
+
type RowMatcher = Record<number, string> | ((row: string[], rowIndex: number) => boolean);
|
|
17
|
+
|
|
18
|
+
declare class SheetsClient {
|
|
19
|
+
private readonly spreadsheetId;
|
|
20
|
+
private readonly sheetName;
|
|
21
|
+
private readonly serviceAccountKey;
|
|
22
|
+
constructor(config: SheetsClientConfig);
|
|
23
|
+
private sheets;
|
|
24
|
+
private range;
|
|
25
|
+
/** 시트 끝에 행 추가 */
|
|
26
|
+
appendRow(values: string[], range?: string): Promise<void>;
|
|
27
|
+
/** 지정 범위의 모든 행 반환 (각 셀은 trim된 문자열) */
|
|
28
|
+
getRows(range?: string): Promise<string[][]>;
|
|
29
|
+
/**
|
|
30
|
+
* 조건에 맞는 첫 번째 행을 반환.
|
|
31
|
+
*
|
|
32
|
+
* matcher가 Record이면 { [컬럼인덱스]: 기대값 } — 양쪽 trim+소문자 비교
|
|
33
|
+
* matcher가 함수이면 (row, rowIndex) => boolean — 커스텀 비교 가능
|
|
34
|
+
*
|
|
35
|
+
* @param skipHeader true이면 첫 번째 행(헤더)을 건너뜀 (기본값: true)
|
|
36
|
+
*/
|
|
37
|
+
findRow(matcher: RowMatcher, options?: {
|
|
38
|
+
range?: string;
|
|
39
|
+
skipHeader?: boolean;
|
|
40
|
+
}): Promise<FindResult | null>;
|
|
41
|
+
/**
|
|
42
|
+
* 특정 행 업데이트.
|
|
43
|
+
*
|
|
44
|
+
* @param rowIndex 시트 행 번호 (1-based, findRow가 반환한 값 그대로 사용)
|
|
45
|
+
* @param values 교체할 셀 값 배열
|
|
46
|
+
* @param startCol 시작 열 문자 (기본값: 'A')
|
|
47
|
+
*/
|
|
48
|
+
updateRow(rowIndex: number, values: string[], startCol?: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
declare function createSheetsClient(config: SheetsClientConfig): SheetsClient;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 전화번호처럼 숫자만 남겨야 하는 값을 시트에 텍스트로 강제 저장.
|
|
54
|
+
* (앞에 ' 붙이면 구글 시트가 숫자 변환을 건너뜀)
|
|
55
|
+
*/
|
|
56
|
+
declare function toTextCell(value: string): string;
|
|
57
|
+
/** 숫자만 추출 (전화번호 비교용) */
|
|
58
|
+
declare function digitsOnly(value: string): string;
|
|
59
|
+
/** 환경변수 래핑 따옴표 제거 */
|
|
60
|
+
declare function stripQuotes(value: string): string;
|
|
61
|
+
|
|
62
|
+
export { type FindResult, type RowMatcher, type ServiceAccountKey, SheetsClient, type SheetsClientConfig, createSheetsClient, digitsOnly, stripQuotes, toTextCell };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
SheetsClient: () => SheetsClient,
|
|
24
|
+
createSheetsClient: () => createSheetsClient,
|
|
25
|
+
digitsOnly: () => digitsOnly,
|
|
26
|
+
stripQuotes: () => stripQuotes,
|
|
27
|
+
toTextCell: () => toTextCell
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/client.ts
|
|
32
|
+
var import_googleapis = require("googleapis");
|
|
33
|
+
|
|
34
|
+
// src/utils.ts
|
|
35
|
+
function toTextCell(value) {
|
|
36
|
+
const digits = value.replace(/\D/g, "");
|
|
37
|
+
if (!digits) return "";
|
|
38
|
+
return `'${digits}`;
|
|
39
|
+
}
|
|
40
|
+
function digitsOnly(value) {
|
|
41
|
+
return value.replace(/\D/g, "");
|
|
42
|
+
}
|
|
43
|
+
function stripQuotes(value) {
|
|
44
|
+
const t = value.trim();
|
|
45
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'"))
|
|
46
|
+
return t.slice(1, -1);
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/client.ts
|
|
51
|
+
var SCOPE = "https://www.googleapis.com/auth/spreadsheets";
|
|
52
|
+
function parseKey(key) {
|
|
53
|
+
if (typeof key !== "string") return key;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(key);
|
|
56
|
+
if (!parsed.client_email || !parsed.private_key) throw new Error();
|
|
57
|
+
return parsed;
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error("serviceAccountKey JSON \uD615\uC2DD\uC774 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function colLetter(index) {
|
|
63
|
+
let col = "";
|
|
64
|
+
let n = index + 1;
|
|
65
|
+
while (n > 0) {
|
|
66
|
+
col = String.fromCharCode((n - 1) % 26 + 65) + col;
|
|
67
|
+
n = Math.floor((n - 1) / 26);
|
|
68
|
+
}
|
|
69
|
+
return col;
|
|
70
|
+
}
|
|
71
|
+
var SheetsClient = class {
|
|
72
|
+
constructor(config) {
|
|
73
|
+
this.spreadsheetId = config.spreadsheetId;
|
|
74
|
+
this.sheetName = stripQuotes(config.sheetName);
|
|
75
|
+
this.serviceAccountKey = config.serviceAccountKey;
|
|
76
|
+
}
|
|
77
|
+
async sheets() {
|
|
78
|
+
const sa = parseKey(this.serviceAccountKey);
|
|
79
|
+
const auth = new import_googleapis.google.auth.JWT({
|
|
80
|
+
email: sa.client_email,
|
|
81
|
+
key: sa.private_key,
|
|
82
|
+
scopes: [SCOPE]
|
|
83
|
+
});
|
|
84
|
+
return import_googleapis.google.sheets({ version: "v4", auth });
|
|
85
|
+
}
|
|
86
|
+
range(r) {
|
|
87
|
+
return `${this.sheetName}!${r}`;
|
|
88
|
+
}
|
|
89
|
+
/** 시트 끝에 행 추가 */
|
|
90
|
+
async appendRow(values, range = "A:Z") {
|
|
91
|
+
const api = await this.sheets();
|
|
92
|
+
await api.spreadsheets.values.append({
|
|
93
|
+
spreadsheetId: this.spreadsheetId,
|
|
94
|
+
range: this.range(range),
|
|
95
|
+
valueInputOption: "USER_ENTERED",
|
|
96
|
+
requestBody: { values: [values] }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** 지정 범위의 모든 행 반환 (각 셀은 trim된 문자열) */
|
|
100
|
+
async getRows(range = "A:Z") {
|
|
101
|
+
const api = await this.sheets();
|
|
102
|
+
const res = await api.spreadsheets.values.get({
|
|
103
|
+
spreadsheetId: this.spreadsheetId,
|
|
104
|
+
range: this.range(range)
|
|
105
|
+
});
|
|
106
|
+
return (res.data.values ?? []).map(
|
|
107
|
+
(row) => row.map((cell) => String(cell ?? "").trim())
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 조건에 맞는 첫 번째 행을 반환.
|
|
112
|
+
*
|
|
113
|
+
* matcher가 Record이면 { [컬럼인덱스]: 기대값 } — 양쪽 trim+소문자 비교
|
|
114
|
+
* matcher가 함수이면 (row, rowIndex) => boolean — 커스텀 비교 가능
|
|
115
|
+
*
|
|
116
|
+
* @param skipHeader true이면 첫 번째 행(헤더)을 건너뜀 (기본값: true)
|
|
117
|
+
*/
|
|
118
|
+
async findRow(matcher, options = {}) {
|
|
119
|
+
const { range = "A:Z", skipHeader = true } = options;
|
|
120
|
+
const rows = await this.getRows(range);
|
|
121
|
+
const start = skipHeader ? 1 : 0;
|
|
122
|
+
const matchFn = typeof matcher === "function" ? matcher : (row) => Object.entries(matcher).every(([col, expected]) => {
|
|
123
|
+
const cell = String(row[Number(col)] ?? "").trim().toLowerCase();
|
|
124
|
+
return cell === expected.trim().toLowerCase();
|
|
125
|
+
});
|
|
126
|
+
for (let i = start; i < rows.length; i++) {
|
|
127
|
+
const row = rows[i];
|
|
128
|
+
if (!row) continue;
|
|
129
|
+
if (matchFn(row, i + 1)) {
|
|
130
|
+
return { rowIndex: i + 1, values: row };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 특정 행 업데이트.
|
|
137
|
+
*
|
|
138
|
+
* @param rowIndex 시트 행 번호 (1-based, findRow가 반환한 값 그대로 사용)
|
|
139
|
+
* @param values 교체할 셀 값 배열
|
|
140
|
+
* @param startCol 시작 열 문자 (기본값: 'A')
|
|
141
|
+
*/
|
|
142
|
+
async updateRow(rowIndex, values, startCol = "A") {
|
|
143
|
+
if (!Number.isInteger(rowIndex) || rowIndex < 2) {
|
|
144
|
+
throw new Error("rowIndex\uB294 2 \uC774\uC0C1\uC758 \uC815\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
145
|
+
}
|
|
146
|
+
const api = await this.sheets();
|
|
147
|
+
const startColIndex = startCol.toUpperCase().charCodeAt(0) - 65;
|
|
148
|
+
const endCol = colLetter(startColIndex + values.length - 1);
|
|
149
|
+
await api.spreadsheets.values.update({
|
|
150
|
+
spreadsheetId: this.spreadsheetId,
|
|
151
|
+
range: this.range(`${startCol}${rowIndex}:${endCol}${rowIndex}`),
|
|
152
|
+
valueInputOption: "USER_ENTERED",
|
|
153
|
+
requestBody: { values: [values] }
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function createSheetsClient(config) {
|
|
158
|
+
return new SheetsClient(config);
|
|
159
|
+
}
|
|
160
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
161
|
+
0 && (module.exports = {
|
|
162
|
+
SheetsClient,
|
|
163
|
+
createSheetsClient,
|
|
164
|
+
digitsOnly,
|
|
165
|
+
stripQuotes,
|
|
166
|
+
toTextCell
|
|
167
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { google } from "googleapis";
|
|
3
|
+
|
|
4
|
+
// src/utils.ts
|
|
5
|
+
function toTextCell(value) {
|
|
6
|
+
const digits = value.replace(/\D/g, "");
|
|
7
|
+
if (!digits) return "";
|
|
8
|
+
return `'${digits}`;
|
|
9
|
+
}
|
|
10
|
+
function digitsOnly(value) {
|
|
11
|
+
return value.replace(/\D/g, "");
|
|
12
|
+
}
|
|
13
|
+
function stripQuotes(value) {
|
|
14
|
+
const t = value.trim();
|
|
15
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'"))
|
|
16
|
+
return t.slice(1, -1);
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/client.ts
|
|
21
|
+
var SCOPE = "https://www.googleapis.com/auth/spreadsheets";
|
|
22
|
+
function parseKey(key) {
|
|
23
|
+
if (typeof key !== "string") return key;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(key);
|
|
26
|
+
if (!parsed.client_email || !parsed.private_key) throw new Error();
|
|
27
|
+
return parsed;
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error("serviceAccountKey JSON \uD615\uC2DD\uC774 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function colLetter(index) {
|
|
33
|
+
let col = "";
|
|
34
|
+
let n = index + 1;
|
|
35
|
+
while (n > 0) {
|
|
36
|
+
col = String.fromCharCode((n - 1) % 26 + 65) + col;
|
|
37
|
+
n = Math.floor((n - 1) / 26);
|
|
38
|
+
}
|
|
39
|
+
return col;
|
|
40
|
+
}
|
|
41
|
+
var SheetsClient = class {
|
|
42
|
+
constructor(config) {
|
|
43
|
+
this.spreadsheetId = config.spreadsheetId;
|
|
44
|
+
this.sheetName = stripQuotes(config.sheetName);
|
|
45
|
+
this.serviceAccountKey = config.serviceAccountKey;
|
|
46
|
+
}
|
|
47
|
+
async sheets() {
|
|
48
|
+
const sa = parseKey(this.serviceAccountKey);
|
|
49
|
+
const auth = new google.auth.JWT({
|
|
50
|
+
email: sa.client_email,
|
|
51
|
+
key: sa.private_key,
|
|
52
|
+
scopes: [SCOPE]
|
|
53
|
+
});
|
|
54
|
+
return google.sheets({ version: "v4", auth });
|
|
55
|
+
}
|
|
56
|
+
range(r) {
|
|
57
|
+
return `${this.sheetName}!${r}`;
|
|
58
|
+
}
|
|
59
|
+
/** 시트 끝에 행 추가 */
|
|
60
|
+
async appendRow(values, range = "A:Z") {
|
|
61
|
+
const api = await this.sheets();
|
|
62
|
+
await api.spreadsheets.values.append({
|
|
63
|
+
spreadsheetId: this.spreadsheetId,
|
|
64
|
+
range: this.range(range),
|
|
65
|
+
valueInputOption: "USER_ENTERED",
|
|
66
|
+
requestBody: { values: [values] }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** 지정 범위의 모든 행 반환 (각 셀은 trim된 문자열) */
|
|
70
|
+
async getRows(range = "A:Z") {
|
|
71
|
+
const api = await this.sheets();
|
|
72
|
+
const res = await api.spreadsheets.values.get({
|
|
73
|
+
spreadsheetId: this.spreadsheetId,
|
|
74
|
+
range: this.range(range)
|
|
75
|
+
});
|
|
76
|
+
return (res.data.values ?? []).map(
|
|
77
|
+
(row) => row.map((cell) => String(cell ?? "").trim())
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 조건에 맞는 첫 번째 행을 반환.
|
|
82
|
+
*
|
|
83
|
+
* matcher가 Record이면 { [컬럼인덱스]: 기대값 } — 양쪽 trim+소문자 비교
|
|
84
|
+
* matcher가 함수이면 (row, rowIndex) => boolean — 커스텀 비교 가능
|
|
85
|
+
*
|
|
86
|
+
* @param skipHeader true이면 첫 번째 행(헤더)을 건너뜀 (기본값: true)
|
|
87
|
+
*/
|
|
88
|
+
async findRow(matcher, options = {}) {
|
|
89
|
+
const { range = "A:Z", skipHeader = true } = options;
|
|
90
|
+
const rows = await this.getRows(range);
|
|
91
|
+
const start = skipHeader ? 1 : 0;
|
|
92
|
+
const matchFn = typeof matcher === "function" ? matcher : (row) => Object.entries(matcher).every(([col, expected]) => {
|
|
93
|
+
const cell = String(row[Number(col)] ?? "").trim().toLowerCase();
|
|
94
|
+
return cell === expected.trim().toLowerCase();
|
|
95
|
+
});
|
|
96
|
+
for (let i = start; i < rows.length; i++) {
|
|
97
|
+
const row = rows[i];
|
|
98
|
+
if (!row) continue;
|
|
99
|
+
if (matchFn(row, i + 1)) {
|
|
100
|
+
return { rowIndex: i + 1, values: row };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 특정 행 업데이트.
|
|
107
|
+
*
|
|
108
|
+
* @param rowIndex 시트 행 번호 (1-based, findRow가 반환한 값 그대로 사용)
|
|
109
|
+
* @param values 교체할 셀 값 배열
|
|
110
|
+
* @param startCol 시작 열 문자 (기본값: 'A')
|
|
111
|
+
*/
|
|
112
|
+
async updateRow(rowIndex, values, startCol = "A") {
|
|
113
|
+
if (!Number.isInteger(rowIndex) || rowIndex < 2) {
|
|
114
|
+
throw new Error("rowIndex\uB294 2 \uC774\uC0C1\uC758 \uC815\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
115
|
+
}
|
|
116
|
+
const api = await this.sheets();
|
|
117
|
+
const startColIndex = startCol.toUpperCase().charCodeAt(0) - 65;
|
|
118
|
+
const endCol = colLetter(startColIndex + values.length - 1);
|
|
119
|
+
await api.spreadsheets.values.update({
|
|
120
|
+
spreadsheetId: this.spreadsheetId,
|
|
121
|
+
range: this.range(`${startCol}${rowIndex}:${endCol}${rowIndex}`),
|
|
122
|
+
valueInputOption: "USER_ENTERED",
|
|
123
|
+
requestBody: { values: [values] }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
function createSheetsClient(config) {
|
|
128
|
+
return new SheetsClient(config);
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
SheetsClient,
|
|
132
|
+
createSheetsClient,
|
|
133
|
+
digitsOnly,
|
|
134
|
+
stripQuotes,
|
|
135
|
+
toTextCell
|
|
136
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moonhr/sheets-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google Sheets client for form-based data collection",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"dev": "tsup --watch"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"googleapis": "^148.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.0.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": ["google-sheets", "spreadsheet", "form"],
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|