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.
- package/.claude-plugin/README.md +145 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.github/workflows/release.yml +86 -0
- package/.oxfmtrc.json +9 -0
- package/.oxlintrc.json +4 -0
- package/AGENTS.md +78 -0
- package/README.md +249 -0
- package/bun.lock +297 -0
- package/bunfig.toml +2 -0
- package/dist/package.json +56 -0
- package/dist/src/cli.d.ts +5 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/client.d.ts +98 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +141 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/commands/auth.d.ts +3 -0
- package/dist/src/commands/auth.d.ts.map +1 -0
- package/dist/src/commands/auth.js +125 -0
- package/dist/src/commands/auth.js.map +1 -0
- package/dist/src/commands/dashboard.d.ts +3 -0
- package/dist/src/commands/dashboard.d.ts.map +1 -0
- package/dist/src/commands/dashboard.js +33 -0
- package/dist/src/commands/dashboard.js.map +1 -0
- package/dist/src/commands/event.d.ts +3 -0
- package/dist/src/commands/event.d.ts.map +1 -0
- package/dist/src/commands/event.js +58 -0
- package/dist/src/commands/event.js.map +1 -0
- package/dist/src/commands/helpers.d.ts +3 -0
- package/dist/src/commands/helpers.d.ts.map +1 -0
- package/dist/src/commands/helpers.js +12 -0
- package/dist/src/commands/helpers.js.map +1 -0
- package/dist/src/commands/index.d.ts +9 -0
- package/dist/src/commands/index.d.ts.map +1 -0
- package/dist/src/commands/index.js +9 -0
- package/dist/src/commands/index.js.map +1 -0
- package/dist/src/commands/member.d.ts +3 -0
- package/dist/src/commands/member.d.ts.map +1 -0
- package/dist/src/commands/member.js +23 -0
- package/dist/src/commands/member.js.map +1 -0
- package/dist/src/commands/mentoring.d.ts +3 -0
- package/dist/src/commands/mentoring.d.ts.map +1 -0
- package/dist/src/commands/mentoring.js +154 -0
- package/dist/src/commands/mentoring.js.map +1 -0
- package/dist/src/commands/notice.d.ts +3 -0
- package/dist/src/commands/notice.d.ts.map +1 -0
- package/dist/src/commands/notice.js +42 -0
- package/dist/src/commands/notice.js.map +1 -0
- package/dist/src/commands/room.d.ts +3 -0
- package/dist/src/commands/room.d.ts.map +1 -0
- package/dist/src/commands/room.js +79 -0
- package/dist/src/commands/room.js.map +1 -0
- package/dist/src/commands/team.d.ts +3 -0
- package/dist/src/commands/team.d.ts.map +1 -0
- package/dist/src/commands/team.js +20 -0
- package/dist/src/commands/team.js.map +1 -0
- package/dist/src/constants.d.ts +43 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +62 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/credential-manager.d.ts +15 -0
- package/dist/src/credential-manager.d.ts.map +1 -0
- package/dist/src/credential-manager.js +40 -0
- package/dist/src/credential-manager.js.map +1 -0
- package/dist/src/formatters.d.ts +15 -0
- package/dist/src/formatters.d.ts.map +1 -0
- package/dist/src/formatters.js +382 -0
- package/dist/src/formatters.js.map +1 -0
- package/dist/src/http.d.ts +32 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +143 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/shared/utils/error-handler.d.ts +2 -0
- package/dist/src/shared/utils/error-handler.d.ts.map +1 -0
- package/dist/src/shared/utils/error-handler.js +7 -0
- package/dist/src/shared/utils/error-handler.js.map +1 -0
- package/dist/src/shared/utils/mentoring-params.d.ts +15 -0
- package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -0
- package/dist/src/shared/utils/mentoring-params.js +39 -0
- package/dist/src/shared/utils/mentoring-params.js.map +1 -0
- package/dist/src/shared/utils/output.d.ts +2 -0
- package/dist/src/shared/utils/output.d.ts.map +1 -0
- package/dist/src/shared/utils/output.js +4 -0
- package/dist/src/shared/utils/output.js.map +1 -0
- package/dist/src/shared/utils/stderr.d.ts +5 -0
- package/dist/src/shared/utils/stderr.d.ts.map +1 -0
- package/dist/src/shared/utils/stderr.js +19 -0
- package/dist/src/shared/utils/stderr.js.map +1 -0
- package/dist/src/shared/utils/swmaestro.d.ts +33 -0
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -0
- package/dist/src/shared/utils/swmaestro.js +164 -0
- package/dist/src/shared/utils/swmaestro.js.map +1 -0
- package/dist/src/token-extractor.d.ts +23 -0
- package/dist/src/token-extractor.d.ts.map +1 -0
- package/dist/src/token-extractor.js +163 -0
- package/dist/src/token-extractor.js.map +1 -0
- package/dist/src/types.d.ts +176 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +110 -0
- package/dist/src/types.js.map +1 -0
- package/e2e/.gitkeep +0 -0
- package/package.json +56 -0
- package/scripts/postbuild.ts +11 -0
- package/scripts/prepublish.ts +9 -0
- package/scripts/test.ts +82 -0
- package/skills/opensoma/SKILL.md +345 -0
- package/skills/opensoma/references/common-patterns.md +182 -0
- package/skills/opensoma/references/output-format.md +130 -0
- package/src/cli.ts +57 -0
- package/src/client.test.ts +210 -0
- package/src/client.ts +264 -0
- package/src/commands/auth.ts +153 -0
- package/src/commands/dashboard.ts +39 -0
- package/src/commands/event.ts +74 -0
- package/src/commands/helpers.ts +12 -0
- package/src/commands/index.ts +8 -0
- package/src/commands/member.ts +29 -0
- package/src/commands/mentoring.ts +209 -0
- package/src/commands/notice.ts +56 -0
- package/src/commands/room.ts +102 -0
- package/src/commands/team.ts +26 -0
- package/src/constants.ts +70 -0
- package/src/credential-manager.test.ts +66 -0
- package/src/credential-manager.ts +52 -0
- package/src/formatters.test.ts +382 -0
- package/src/formatters.ts +489 -0
- package/src/http.test.ts +152 -0
- package/src/http.ts +196 -0
- package/src/index.ts +6 -0
- package/src/shared/utils/error-handler.ts +7 -0
- package/src/shared/utils/mentoring-params.test.ts +112 -0
- package/src/shared/utils/mentoring-params.ts +57 -0
- package/src/shared/utils/output.ts +3 -0
- package/src/shared/utils/stderr.ts +23 -0
- package/src/shared/utils/swmaestro.ts +218 -0
- package/src/token-extractor.test.ts +119 -0
- package/src/token-extractor.ts +205 -0
- package/src/types.test.ts +172 -0
- package/src/types.ts +134 -0
- 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,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,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
|
+
}
|