opensoma 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.
Files changed (146) hide show
  1. package/.claude-plugin/README.md +145 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.github/workflows/release.yml +86 -0
  4. package/.oxfmtrc.json +9 -0
  5. package/.oxlintrc.json +4 -0
  6. package/AGENTS.md +78 -0
  7. package/README.md +249 -0
  8. package/bun.lock +297 -0
  9. package/bunfig.toml +2 -0
  10. package/dist/package.json +56 -0
  11. package/dist/src/cli.d.ts +5 -0
  12. package/dist/src/cli.d.ts.map +1 -0
  13. package/dist/src/cli.js +39 -0
  14. package/dist/src/cli.js.map +1 -0
  15. package/dist/src/client.d.ts +98 -0
  16. package/dist/src/client.d.ts.map +1 -0
  17. package/dist/src/client.js +141 -0
  18. package/dist/src/client.js.map +1 -0
  19. package/dist/src/commands/auth.d.ts +3 -0
  20. package/dist/src/commands/auth.d.ts.map +1 -0
  21. package/dist/src/commands/auth.js +125 -0
  22. package/dist/src/commands/auth.js.map +1 -0
  23. package/dist/src/commands/dashboard.d.ts +3 -0
  24. package/dist/src/commands/dashboard.d.ts.map +1 -0
  25. package/dist/src/commands/dashboard.js +33 -0
  26. package/dist/src/commands/dashboard.js.map +1 -0
  27. package/dist/src/commands/event.d.ts +3 -0
  28. package/dist/src/commands/event.d.ts.map +1 -0
  29. package/dist/src/commands/event.js +58 -0
  30. package/dist/src/commands/event.js.map +1 -0
  31. package/dist/src/commands/helpers.d.ts +3 -0
  32. package/dist/src/commands/helpers.d.ts.map +1 -0
  33. package/dist/src/commands/helpers.js +12 -0
  34. package/dist/src/commands/helpers.js.map +1 -0
  35. package/dist/src/commands/index.d.ts +9 -0
  36. package/dist/src/commands/index.d.ts.map +1 -0
  37. package/dist/src/commands/index.js +9 -0
  38. package/dist/src/commands/index.js.map +1 -0
  39. package/dist/src/commands/member.d.ts +3 -0
  40. package/dist/src/commands/member.d.ts.map +1 -0
  41. package/dist/src/commands/member.js +23 -0
  42. package/dist/src/commands/member.js.map +1 -0
  43. package/dist/src/commands/mentoring.d.ts +3 -0
  44. package/dist/src/commands/mentoring.d.ts.map +1 -0
  45. package/dist/src/commands/mentoring.js +154 -0
  46. package/dist/src/commands/mentoring.js.map +1 -0
  47. package/dist/src/commands/notice.d.ts +3 -0
  48. package/dist/src/commands/notice.d.ts.map +1 -0
  49. package/dist/src/commands/notice.js +42 -0
  50. package/dist/src/commands/notice.js.map +1 -0
  51. package/dist/src/commands/room.d.ts +3 -0
  52. package/dist/src/commands/room.d.ts.map +1 -0
  53. package/dist/src/commands/room.js +79 -0
  54. package/dist/src/commands/room.js.map +1 -0
  55. package/dist/src/commands/team.d.ts +3 -0
  56. package/dist/src/commands/team.d.ts.map +1 -0
  57. package/dist/src/commands/team.js +20 -0
  58. package/dist/src/commands/team.js.map +1 -0
  59. package/dist/src/constants.d.ts +43 -0
  60. package/dist/src/constants.d.ts.map +1 -0
  61. package/dist/src/constants.js +62 -0
  62. package/dist/src/constants.js.map +1 -0
  63. package/dist/src/credential-manager.d.ts +15 -0
  64. package/dist/src/credential-manager.d.ts.map +1 -0
  65. package/dist/src/credential-manager.js +40 -0
  66. package/dist/src/credential-manager.js.map +1 -0
  67. package/dist/src/formatters.d.ts +15 -0
  68. package/dist/src/formatters.d.ts.map +1 -0
  69. package/dist/src/formatters.js +382 -0
  70. package/dist/src/formatters.js.map +1 -0
  71. package/dist/src/http.d.ts +32 -0
  72. package/dist/src/http.d.ts.map +1 -0
  73. package/dist/src/http.js +143 -0
  74. package/dist/src/http.js.map +1 -0
  75. package/dist/src/index.d.ts +7 -0
  76. package/dist/src/index.d.ts.map +1 -0
  77. package/dist/src/index.js +6 -0
  78. package/dist/src/index.js.map +1 -0
  79. package/dist/src/shared/utils/error-handler.d.ts +2 -0
  80. package/dist/src/shared/utils/error-handler.d.ts.map +1 -0
  81. package/dist/src/shared/utils/error-handler.js +7 -0
  82. package/dist/src/shared/utils/error-handler.js.map +1 -0
  83. package/dist/src/shared/utils/mentoring-params.d.ts +15 -0
  84. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -0
  85. package/dist/src/shared/utils/mentoring-params.js +39 -0
  86. package/dist/src/shared/utils/mentoring-params.js.map +1 -0
  87. package/dist/src/shared/utils/output.d.ts +2 -0
  88. package/dist/src/shared/utils/output.d.ts.map +1 -0
  89. package/dist/src/shared/utils/output.js +4 -0
  90. package/dist/src/shared/utils/output.js.map +1 -0
  91. package/dist/src/shared/utils/stderr.d.ts +5 -0
  92. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  93. package/dist/src/shared/utils/stderr.js +19 -0
  94. package/dist/src/shared/utils/stderr.js.map +1 -0
  95. package/dist/src/shared/utils/swmaestro.d.ts +33 -0
  96. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -0
  97. package/dist/src/shared/utils/swmaestro.js +164 -0
  98. package/dist/src/shared/utils/swmaestro.js.map +1 -0
  99. package/dist/src/token-extractor.d.ts +23 -0
  100. package/dist/src/token-extractor.d.ts.map +1 -0
  101. package/dist/src/token-extractor.js +163 -0
  102. package/dist/src/token-extractor.js.map +1 -0
  103. package/dist/src/types.d.ts +176 -0
  104. package/dist/src/types.d.ts.map +1 -0
  105. package/dist/src/types.js +110 -0
  106. package/dist/src/types.js.map +1 -0
  107. package/e2e/.gitkeep +0 -0
  108. package/package.json +56 -0
  109. package/scripts/postbuild.ts +11 -0
  110. package/scripts/prepublish.ts +9 -0
  111. package/scripts/test.ts +82 -0
  112. package/skills/opensoma/SKILL.md +345 -0
  113. package/skills/opensoma/references/common-patterns.md +182 -0
  114. package/skills/opensoma/references/output-format.md +130 -0
  115. package/src/cli.ts +57 -0
  116. package/src/client.test.ts +210 -0
  117. package/src/client.ts +264 -0
  118. package/src/commands/auth.ts +153 -0
  119. package/src/commands/dashboard.ts +39 -0
  120. package/src/commands/event.ts +74 -0
  121. package/src/commands/helpers.ts +12 -0
  122. package/src/commands/index.ts +8 -0
  123. package/src/commands/member.ts +29 -0
  124. package/src/commands/mentoring.ts +209 -0
  125. package/src/commands/notice.ts +56 -0
  126. package/src/commands/room.ts +102 -0
  127. package/src/commands/team.ts +26 -0
  128. package/src/constants.ts +70 -0
  129. package/src/credential-manager.test.ts +66 -0
  130. package/src/credential-manager.ts +52 -0
  131. package/src/formatters.test.ts +382 -0
  132. package/src/formatters.ts +489 -0
  133. package/src/http.test.ts +152 -0
  134. package/src/http.ts +196 -0
  135. package/src/index.ts +6 -0
  136. package/src/shared/utils/error-handler.ts +7 -0
  137. package/src/shared/utils/mentoring-params.test.ts +112 -0
  138. package/src/shared/utils/mentoring-params.ts +57 -0
  139. package/src/shared/utils/output.ts +3 -0
  140. package/src/shared/utils/stderr.ts +23 -0
  141. package/src/shared/utils/swmaestro.ts +218 -0
  142. package/src/token-extractor.test.ts +119 -0
  143. package/src/token-extractor.ts +205 -0
  144. package/src/types.test.ts +172 -0
  145. package/src/types.ts +134 -0
  146. package/tsconfig.json +38 -0
package/src/http.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { BASE_URL, MENU_NO } from '@/constants'
2
+ import { parseCsrfToken } from '@/formatters'
3
+
4
+ interface RequestOptions {
5
+ sessionCookie?: string
6
+ csrfToken?: string
7
+ }
8
+
9
+ interface CheckLoginResponse {
10
+ resultCode?: string
11
+ userVO?: { userId?: string; userNm?: string; userSn?: number }
12
+ }
13
+
14
+ export interface UserIdentity {
15
+ userId: string
16
+ userNm: string
17
+ }
18
+
19
+ interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
20
+ getSetCookie?: () => string[]
21
+ }
22
+
23
+ export class SomaHttp {
24
+ private cookies = new Map<string, string>()
25
+ private csrfToken: string | null
26
+
27
+ constructor(options?: RequestOptions) {
28
+ this.csrfToken = options?.csrfToken ?? null
29
+
30
+ if (options?.sessionCookie) {
31
+ this.setCookie(
32
+ options.sessionCookie.includes('=') ? options.sessionCookie : `JSESSIONID=${options.sessionCookie}`,
33
+ )
34
+ }
35
+ }
36
+
37
+ async get(path: string, params?: Record<string, string>): Promise<string> {
38
+ const response = await fetch(this.buildUrl(path, params), {
39
+ method: 'GET',
40
+ headers: this.buildHeaders(),
41
+ })
42
+
43
+ this.updateFromResponse(response)
44
+ return response.text()
45
+ }
46
+
47
+ async post(path: string, body: Record<string, string>): Promise<string> {
48
+ const formBody = new URLSearchParams(this.buildBody(body))
49
+ const response = await fetch(this.buildUrl(path), {
50
+ method: 'POST',
51
+ headers: {
52
+ ...this.buildHeaders(),
53
+ 'Content-Type': 'application/x-www-form-urlencoded',
54
+ },
55
+ body: formBody.toString(),
56
+ })
57
+
58
+ this.updateFromResponse(response)
59
+ return response.text()
60
+ }
61
+
62
+ async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
63
+ const formBody = new URLSearchParams(this.buildBody(body))
64
+ const response = await fetch(this.buildUrl(path), {
65
+ method: 'POST',
66
+ headers: {
67
+ ...this.buildHeaders(),
68
+ Accept: 'application/json',
69
+ 'Content-Type': 'application/x-www-form-urlencoded',
70
+ },
71
+ body: formBody.toString(),
72
+ })
73
+
74
+ this.updateFromResponse(response)
75
+ return (await response.json()) as T
76
+ }
77
+
78
+ async login(username: string, password: string): Promise<void> {
79
+ const csrfToken = await this.extractCsrfToken()
80
+
81
+ await this.post('/member/user/toLogin.do', {
82
+ username,
83
+ password,
84
+ csrfToken,
85
+ loginFlag: '',
86
+ menuNo: MENU_NO.LOGIN,
87
+ })
88
+ }
89
+
90
+ async checkLogin(): Promise<UserIdentity | null> {
91
+ const response = await fetch(this.buildUrl('/member/user/checkLogin.json'), {
92
+ method: 'GET',
93
+ headers: {
94
+ ...this.buildHeaders(),
95
+ Accept: 'application/json',
96
+ },
97
+ })
98
+
99
+ this.updateFromResponse(response)
100
+ const json = (await response.json()) as CheckLoginResponse
101
+ const userId = json.userVO?.userId
102
+ if (!userId) return null
103
+ return { userId, userNm: json.userVO?.userNm ?? '' }
104
+ }
105
+
106
+ async logout(): Promise<void> {
107
+ await this.get('/member/user/logout.do')
108
+ }
109
+
110
+ async extractCsrfToken(): Promise<string> {
111
+ const html = await this.get('/member/user/forLogin.do', { menuNo: MENU_NO.LOGIN })
112
+ const token = parseCsrfToken(html)
113
+ this.csrfToken = token
114
+ return token
115
+ }
116
+
117
+ getCookies(): Record<string, string> {
118
+ return Object.fromEntries(this.cookies)
119
+ }
120
+
121
+ getSessionCookie(): string | undefined {
122
+ return this.cookies.get('JSESSIONID')
123
+ }
124
+
125
+ getCsrfToken(): string | null {
126
+ return this.csrfToken
127
+ }
128
+
129
+ private buildUrl(path: string, params?: Record<string, string>): string {
130
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path
131
+ const url = new URL(normalizedPath, `${BASE_URL}/`)
132
+
133
+ if (params) {
134
+ for (const [key, value] of Object.entries(params)) {
135
+ url.searchParams.set(key, value)
136
+ }
137
+ }
138
+
139
+ return url.toString()
140
+ }
141
+
142
+ private buildHeaders(): Record<string, string> {
143
+ const cookieHeader = this.serializeCookies()
144
+ return cookieHeader ? { cookie: cookieHeader } : {}
145
+ }
146
+
147
+ private buildBody(body: Record<string, string>): Record<string, string> {
148
+ if (this.csrfToken && !('csrfToken' in body)) {
149
+ return { ...body, csrfToken: this.csrfToken }
150
+ }
151
+
152
+ return body
153
+ }
154
+
155
+ private updateFromResponse(response: Response): void {
156
+ const setCookies = this.readSetCookies(response.headers)
157
+
158
+ for (const cookie of setCookies) {
159
+ this.setCookie(cookie)
160
+ }
161
+ }
162
+
163
+ private readSetCookies(headers: Headers): string[] {
164
+ const enhancedHeaders = headers as HeadersWithCookieHelpers
165
+
166
+ if (typeof enhancedHeaders.getSetCookie === 'function') {
167
+ return enhancedHeaders.getSetCookie()
168
+ }
169
+
170
+ const singleHeader = headers.get('set-cookie')
171
+ return singleHeader ? [singleHeader] : []
172
+ }
173
+
174
+ private setCookie(cookieHeader: string): void {
175
+ const cookiePair = cookieHeader.split(';')[0]?.trim()
176
+ if (!cookiePair) {
177
+ return
178
+ }
179
+
180
+ const separatorIndex = cookiePair.indexOf('=')
181
+ if (separatorIndex === -1) {
182
+ return
183
+ }
184
+
185
+ const name = cookiePair.slice(0, separatorIndex).trim()
186
+ const value = cookiePair.slice(separatorIndex + 1).trim()
187
+
188
+ if (name) {
189
+ this.cookies.set(name, value)
190
+ }
191
+ }
192
+
193
+ private serializeCookies(): string {
194
+ return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
195
+ }
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { SomaClient } from './client'
2
+ export type { SomaClientOptions } from './client'
3
+ export { SomaHttp } from './http'
4
+ export { CredentialManager } from './credential-manager'
5
+ export * from './types'
6
+ export * from './constants'
@@ -0,0 +1,7 @@
1
+ import { error } from './stderr'
2
+
3
+ export function handleError(err: unknown): void {
4
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
5
+ error(JSON.stringify({ error: message }))
6
+ process.exit(1)
7
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { MENU_NO, REPORT_CD } from '@/constants'
4
+
5
+ import { buildMentoringListParams, parseSearchQuery } from './mentoring-params'
6
+
7
+ describe('parseSearchQuery', () => {
8
+ test('plain text defaults to title', () => {
9
+ expect(parseSearchQuery('OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
10
+ })
11
+
12
+ test('title: prefix', () => {
13
+ expect(parseSearchQuery('title:OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
14
+ })
15
+
16
+ test('author: prefix', () => {
17
+ expect(parseSearchQuery('author:전수열')).toEqual({ field: 'author', value: '전수열' })
18
+ })
19
+
20
+ test('author:@me sets me flag', () => {
21
+ expect(parseSearchQuery('author:@me')).toEqual({ field: 'author', value: '@me', me: true })
22
+ })
23
+
24
+ test('content: prefix', () => {
25
+ expect(parseSearchQuery('content:하네스')).toEqual({ field: 'content', value: '하네스' })
26
+ })
27
+
28
+ test('unknown prefix treated as plain title search', () => {
29
+ expect(parseSearchQuery('foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
30
+ })
31
+
32
+ test('value with colons preserves everything after first colon', () => {
33
+ expect(parseSearchQuery('title:foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
34
+ })
35
+ })
36
+
37
+ describe('buildMentoringListParams', () => {
38
+ test('no options returns only menuNo', () => {
39
+ expect(buildMentoringListParams()).toEqual({ menuNo: MENU_NO.MENTORING })
40
+ })
41
+
42
+ test('status maps open to A, closed to C', () => {
43
+ expect(buildMentoringListParams({ status: 'open' })).toEqual({ menuNo: MENU_NO.MENTORING, searchStatMentolec: 'A' })
44
+ expect(buildMentoringListParams({ status: 'closed' })).toEqual({
45
+ menuNo: MENU_NO.MENTORING,
46
+ searchStatMentolec: 'C',
47
+ })
48
+ })
49
+
50
+ test('type maps public and lecture to report codes', () => {
51
+ expect(buildMentoringListParams({ type: 'public' })).toEqual({
52
+ menuNo: MENU_NO.MENTORING,
53
+ searchGubunMentolec: REPORT_CD.PUBLIC_MENTORING,
54
+ })
55
+ expect(buildMentoringListParams({ type: 'lecture' })).toEqual({
56
+ menuNo: MENU_NO.MENTORING,
57
+ searchGubunMentolec: REPORT_CD.MENTOR_LECTURE,
58
+ })
59
+ })
60
+
61
+ test('title search sets searchCnd=1', () => {
62
+ expect(buildMentoringListParams({ search: { field: 'title', value: 'OpenCode' } })).toEqual({
63
+ menuNo: MENU_NO.MENTORING,
64
+ searchCnd: '1',
65
+ searchWrd: 'OpenCode',
66
+ })
67
+ })
68
+
69
+ test('author search sets searchCnd=2', () => {
70
+ expect(buildMentoringListParams({ search: { field: 'author', value: '전수열' } })).toEqual({
71
+ menuNo: MENU_NO.MENTORING,
72
+ searchCnd: '2',
73
+ searchWrd: '전수열',
74
+ })
75
+ })
76
+
77
+ test('author:@me with user sets searchId and userNm', () => {
78
+ expect(
79
+ buildMentoringListParams({
80
+ search: { field: 'author', value: '@me', me: true },
81
+ user: { userId: 'neo@example.com', userNm: '전수열' },
82
+ }),
83
+ ).toEqual({
84
+ menuNo: MENU_NO.MENTORING,
85
+ searchCnd: '2',
86
+ searchId: 'neo@example.com',
87
+ searchWrd: '전수열',
88
+ })
89
+ })
90
+
91
+ test('search composes with status and type', () => {
92
+ expect(
93
+ buildMentoringListParams({
94
+ status: 'open',
95
+ type: 'lecture',
96
+ search: { field: 'author', value: '@me', me: true },
97
+ user: { userId: 'neo@example.com', userNm: '전수열' },
98
+ }),
99
+ ).toEqual({
100
+ menuNo: MENU_NO.MENTORING,
101
+ searchStatMentolec: 'A',
102
+ searchGubunMentolec: REPORT_CD.MENTOR_LECTURE,
103
+ searchCnd: '2',
104
+ searchId: 'neo@example.com',
105
+ searchWrd: '전수열',
106
+ })
107
+ })
108
+
109
+ test('page sets pageIndex', () => {
110
+ expect(buildMentoringListParams({ page: 3 })).toEqual({ menuNo: MENU_NO.MENTORING, pageIndex: '3' })
111
+ })
112
+ })
@@ -0,0 +1,57 @@
1
+ import { MENU_NO, REPORT_CD } from '@/constants'
2
+ import type { UserIdentity } from '@/http'
3
+
4
+ const STATUS_MAP: Record<string, string> = { open: 'A', closed: 'C' }
5
+ const TYPE_MAP: Record<string, string> = { public: REPORT_CD.PUBLIC_MENTORING, lecture: REPORT_CD.MENTOR_LECTURE }
6
+ const SEARCH_FIELD_MAP: Record<string, string> = { title: '1', author: '2', content: '3' }
7
+
8
+ export interface MentoringSearchQuery {
9
+ field: 'title' | 'author' | 'content'
10
+ value: string
11
+ me?: boolean
12
+ }
13
+
14
+ export function parseSearchQuery(raw: string): MentoringSearchQuery {
15
+ const colonIndex = raw.indexOf(':')
16
+ if (colonIndex === -1) {
17
+ return { field: 'title', value: raw }
18
+ }
19
+
20
+ const field = raw.slice(0, colonIndex) as MentoringSearchQuery['field']
21
+ if (!(field in SEARCH_FIELD_MAP)) {
22
+ return { field: 'title', value: raw }
23
+ }
24
+
25
+ const value = raw.slice(colonIndex + 1)
26
+ const me = field === 'author' && value === '@me'
27
+ return me ? { field, value, me } : { field, value }
28
+ }
29
+
30
+ export function buildMentoringListParams(options?: {
31
+ status?: string
32
+ type?: string
33
+ page?: string | number
34
+ search?: MentoringSearchQuery
35
+ user?: UserIdentity
36
+ }): Record<string, string> {
37
+ const params: Record<string, string> = { menuNo: MENU_NO.MENTORING }
38
+
39
+ if (options?.status) {
40
+ params.searchStatMentolec = STATUS_MAP[options.status] ?? options.status
41
+ }
42
+
43
+ if (options?.search) {
44
+ params.searchCnd = SEARCH_FIELD_MAP[options.search.field]
45
+ if (options.search.me && options.user) {
46
+ params.searchId = options.user.userId
47
+ params.searchWrd = options.user.userNm
48
+ } else {
49
+ params.searchWrd = options.search.value
50
+ }
51
+ }
52
+
53
+ if (options?.type) params.searchGubunMentolec = TYPE_MAP[options.type] ?? options.type
54
+ if (options?.page) params.pageIndex = String(options.page)
55
+
56
+ return params
57
+ }
@@ -0,0 +1,3 @@
1
+ export function formatOutput(data: unknown, pretty?: boolean): string {
2
+ return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data)
3
+ }
@@ -0,0 +1,23 @@
1
+ const isTTY = process.stderr.isTTY ?? false
2
+
3
+ function colorize(code: number, text: string): string {
4
+ return isTTY ? `\x1b[${code}m${text}\x1b[0m` : text
5
+ }
6
+
7
+ export function info(message: string): void {
8
+ process.stderr.write(`${colorize(36, message)}\n`)
9
+ }
10
+
11
+ export function warn(message: string): void {
12
+ process.stderr.write(`${colorize(33, message)}\n`)
13
+ }
14
+
15
+ export function error(message: string): void {
16
+ process.stderr.write(`${colorize(31, message)}\n`)
17
+ }
18
+
19
+ export function debug(message: string): void {
20
+ if (process.env.DEBUG) {
21
+ process.stderr.write(`${colorize(90, message)}\n`)
22
+ }
23
+ }
@@ -0,0 +1,218 @@
1
+ import { parse } from 'node-html-parser'
2
+
3
+ import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '@/constants'
4
+ import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '@/types'
5
+
6
+ export function toReportCd(type: 'public' | 'lecture'): string {
7
+ return type === 'lecture' ? REPORT_CD.MENTOR_LECTURE : REPORT_CD.PUBLIC_MENTORING
8
+ }
9
+
10
+ export function buildMentoringPayload(params: {
11
+ title: string
12
+ type: 'public' | 'lecture'
13
+ date: string
14
+ startTime: string
15
+ endTime: string
16
+ venue: string
17
+ maxAttendees?: number
18
+ regStart?: string
19
+ regEnd?: string
20
+ content?: string
21
+ }): Record<string, string> {
22
+ return {
23
+ menuNo: MENU_NO.MENTORING,
24
+ reportCd: toReportCd(params.type),
25
+ qustnrSj: params.title,
26
+ bgnde: params.regStart ?? params.date,
27
+ endde: params.regEnd ?? params.date,
28
+ applyCnt: String(params.maxAttendees ?? (params.type === 'lecture' ? 6 : 1)),
29
+ eventDt: params.date,
30
+ eventStime: params.startTime,
31
+ eventEtime: params.endTime,
32
+ place: params.venue,
33
+ qestnarCn: params.content ?? '',
34
+ atchFileId: '',
35
+ fileFieldNm_1: '',
36
+ stateCd: 'QST020',
37
+ openAt: 'Y',
38
+ qustnrAt: 'Y',
39
+ qustnrSn: '',
40
+ pageQueryString: '',
41
+ }
42
+ }
43
+
44
+ export function buildDeleteMentoringPayload(id: number): Record<string, string> {
45
+ return {
46
+ menuNo: MENU_NO.MENTORING,
47
+ qustnrSn: String(id),
48
+ pageQueryString: '',
49
+ }
50
+ }
51
+
52
+ export function buildApplicationPayload(id: number): Record<string, string> {
53
+ return {
54
+ menuNo: MENU_NO.EVENT,
55
+ qustnrSn: String(id),
56
+ applyGb: 'C',
57
+ stepHeader: '0',
58
+ }
59
+ }
60
+
61
+ export function buildCancelApplicationPayload(params: { applySn: number; qustnrSn: number }): Record<string, string> {
62
+ return {
63
+ menuNo: MENU_NO.APPLICATION_HISTORY,
64
+ applySn: String(params.applySn),
65
+ qustnrSn: String(params.qustnrSn),
66
+ }
67
+ }
68
+
69
+ export function resolveRoomId(room: string | number): number {
70
+ if (typeof room === 'number') {
71
+ return room
72
+ }
73
+
74
+ const normalized = room.trim().toUpperCase()
75
+ const mapped = ROOM_IDS[normalized]
76
+ if (mapped) {
77
+ return mapped
78
+ }
79
+
80
+ const numeric = Number.parseInt(room, 10)
81
+ if (Number.isNaN(numeric)) {
82
+ throw new Error(`Unknown room: ${room}`)
83
+ }
84
+
85
+ return numeric
86
+ }
87
+
88
+ export function validateReservationSlots(slots: string[]): void {
89
+ if (slots.length === 0) {
90
+ throw new Error('At least one time slot is required')
91
+ }
92
+
93
+ if (slots.length > 8) {
94
+ throw new Error('You can reserve up to 8 consecutive slots')
95
+ }
96
+
97
+ const indices = slots.map((slot) => {
98
+ const index = TIME_SLOTS.indexOf(slot)
99
+ if (index === -1) {
100
+ throw new Error(`Invalid time slot: ${slot}`)
101
+ }
102
+ return index
103
+ })
104
+
105
+ for (let index = 1; index < indices.length; index += 1) {
106
+ if (indices[index] !== indices[index - 1] + 1) {
107
+ throw new Error('Time slots must be consecutive')
108
+ }
109
+ }
110
+ }
111
+
112
+ export function buildRoomReservationPayload(params: {
113
+ roomId: number
114
+ date: string
115
+ slots: string[]
116
+ title: string
117
+ attendees?: number
118
+ notes?: string
119
+ }): Record<string, string> {
120
+ validateReservationSlots(params.slots)
121
+
122
+ const firstSlot = params.slots[0]
123
+ const lastSlot = params.slots[params.slots.length - 1]
124
+ const endSlot = TIME_SLOTS[TIME_SLOTS.indexOf(lastSlot) + 1]
125
+
126
+ if (!endSlot) {
127
+ throw new Error('Reservation end time is out of range')
128
+ }
129
+
130
+ const payload: Record<string, string> = {
131
+ menuNo: MENU_NO.ROOM,
132
+ itemId: String(params.roomId),
133
+ rentBgnde: `${params.date} ${firstSlot}:00`,
134
+ rentEndde: `${params.date} ${endSlot}:00`,
135
+ title: params.title,
136
+ rentDt: params.date,
137
+ rentNum: String(params.attendees ?? 1),
138
+ infoCn: params.notes ?? '',
139
+ rentId: '',
140
+ pageQueryString: '',
141
+ }
142
+
143
+ params.slots.forEach((slot, index) => {
144
+ payload[`time[${index}]`] = slot
145
+ payload[`chkData_${index + 1}`] = `${params.date}|${slot}|${params.roomId}`
146
+ })
147
+
148
+ return payload
149
+ }
150
+
151
+ export function parseApplicationHistory(html: string): ApplicationHistoryItem[] {
152
+ const root = parse(html)
153
+ const rows =
154
+ root.querySelectorAll('table tbody tr').length > 0
155
+ ? root.querySelectorAll('table tbody tr')
156
+ : root.querySelectorAll('table tr').slice(1)
157
+
158
+ return rows
159
+ .map((row) => row.querySelectorAll('td'))
160
+ .filter((cells) => cells.length >= 4)
161
+ .map((cells) =>
162
+ ApplicationHistoryItemSchema.parse({
163
+ id: extractNumber(cleanText(cells[0]?.text)),
164
+ title: cleanText(cells[1]?.text),
165
+ appliedAt: cleanText(cells[2]?.text),
166
+ status: cleanText(cells[3]?.text),
167
+ }),
168
+ )
169
+ }
170
+
171
+ export function parseEventDetail(html: string): Record<string, unknown> {
172
+ const root = parse(html)
173
+ const labels = extractLabelMap(root)
174
+ const contentNode =
175
+ root.querySelector('[data-content]') ??
176
+ root.querySelector('.board-view-content') ??
177
+ root.querySelector('.view-content') ??
178
+ root.querySelector('.content-body')
179
+
180
+ return {
181
+ id: extractNumber(
182
+ labels.NO ?? labels['번호'] ?? root.querySelector('[name="bbsId"]')?.getAttribute('value') ?? '0',
183
+ ),
184
+ title: labels['제목'] ?? cleanText(root.querySelector('h1, h2, .title')?.text),
185
+ content: contentNode?.innerHTML.trim() ?? '',
186
+ fields: labels,
187
+ }
188
+ }
189
+
190
+ function extractLabelMap(root: ReturnType<typeof parse>): Record<string, string> {
191
+ const map: Record<string, string> = {}
192
+
193
+ for (const row of root.querySelectorAll('tr')) {
194
+ const headers = row.querySelectorAll('th')
195
+ const values = row.querySelectorAll('td')
196
+
197
+ if (headers.length === 1 && values.length === 1) {
198
+ map[cleanText(headers[0]?.text).replace(/:$/, '')] = cleanText(values[0]?.text)
199
+ }
200
+
201
+ if (headers.length > 1 && headers.length === values.length) {
202
+ headers.forEach((header, index) => {
203
+ map[cleanText(header.text).replace(/:$/, '')] = cleanText(values[index]?.text)
204
+ })
205
+ }
206
+ }
207
+
208
+ return map
209
+ }
210
+
211
+ function cleanText(value: string | null | undefined): string {
212
+ return (value ?? '').replace(/\s+/g, ' ').replace(/\[|\]/g, '').trim()
213
+ }
214
+
215
+ function extractNumber(value: string): number {
216
+ const match = value.match(/\d+/)
217
+ return match ? Number.parseInt(match[0], 10) : 0
218
+ }