opensoma 0.3.0 → 0.4.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 (49) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +3 -3
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +37 -5
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/mentoring.d.ts.map +1 -1
  7. package/dist/src/commands/mentoring.js +26 -20
  8. package/dist/src/commands/mentoring.js.map +1 -1
  9. package/dist/src/commands/room.d.ts.map +1 -1
  10. package/dist/src/commands/room.js +25 -2
  11. package/dist/src/commands/room.js.map +1 -1
  12. package/dist/src/constants.d.ts +12 -9
  13. package/dist/src/constants.d.ts.map +1 -1
  14. package/dist/src/constants.js +23 -9
  15. package/dist/src/constants.js.map +1 -1
  16. package/dist/src/formatters.d.ts.map +1 -1
  17. package/dist/src/formatters.js +37 -23
  18. package/dist/src/formatters.js.map +1 -1
  19. package/dist/src/http.d.ts +3 -0
  20. package/dist/src/http.d.ts.map +1 -1
  21. package/dist/src/http.js +42 -1
  22. package/dist/src/http.js.map +1 -1
  23. package/dist/src/shared/utils/html.d.ts +3 -0
  24. package/dist/src/shared/utils/html.d.ts.map +1 -0
  25. package/dist/src/shared/utils/html.js +12 -0
  26. package/dist/src/shared/utils/html.js.map +1 -0
  27. package/dist/src/shared/utils/swmaestro.d.ts +2 -0
  28. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  29. package/dist/src/shared/utils/swmaestro.js +28 -5
  30. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  31. package/dist/src/types.d.ts +36 -0
  32. package/dist/src/types.d.ts.map +1 -1
  33. package/dist/src/types.js +19 -1
  34. package/dist/src/types.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/client.test.ts +63 -16
  37. package/src/client.ts +43 -6
  38. package/src/commands/mentoring.ts +34 -26
  39. package/src/commands/room.ts +31 -3
  40. package/src/constants.ts +24 -9
  41. package/src/formatters.test.ts +6 -2
  42. package/src/formatters.ts +48 -29
  43. package/src/http.test.ts +215 -0
  44. package/src/http.ts +45 -1
  45. package/src/shared/utils/html.ts +12 -0
  46. package/src/shared/utils/swmaestro.test.ts +44 -0
  47. package/src/shared/utils/swmaestro.ts +30 -5
  48. package/src/types.test.ts +4 -1
  49. package/src/types.ts +23 -1
@@ -72,14 +72,19 @@ describe('SomaClient', () => {
72
72
  })
73
73
 
74
74
  test('mutating operations post expected payloads', async () => {
75
+ const mentoringDetailHtml =
76
+ '<div class="group"><strong class="t">모집 명</strong><div class="c">[자유 멘토링] 기존 멘토링</div></div><div class="group"><strong class="t">접수 기간</strong><div class="c">2026.03.01 ~ 2026.03.15</div></div><div class="group"><strong class="t">강의날짜</strong><div class="c"><span>2026.03.20 10:00시 ~ 12:00시</span></div></div><div class="group"><strong class="t">장소</strong><div class="c">온라인(Webex)</div></div><div class="group"><strong class="t">모집인원</strong><div class="c">5명</div></div><div class="group"><strong class="t">작성자</strong><div class="c">전수열</div></div><div class="group"><strong class="t">등록일</strong><div class="c">2026.03.01</div></div><div class="cont"><p>기존 내용</p></div>'
75
77
  const client = new SomaClient()
76
- const calls: Array<{ path: string; data: Record<string, string> }> = []
78
+ const calls: Array<{ method: string; path: string; data: Record<string, string> }> = []
79
+ const captor = (method: string) => async (path: string, data: Record<string, string>) => {
80
+ calls.push({ method, path, data })
81
+ return ''
82
+ }
77
83
  Reflect.set(client, 'http', {
78
84
  checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
79
- post: async (path: string, data: Record<string, string>) => {
80
- calls.push({ path, data })
81
- return ''
82
- },
85
+ get: async () => mentoringDetailHtml,
86
+ post: captor('post'),
87
+ postForm: captor('postForm'),
83
88
  })
84
89
 
85
90
  await client.mentoring.create({
@@ -110,14 +115,14 @@ describe('SomaClient', () => {
110
115
  })
111
116
  await client.event.apply(11)
112
117
 
113
- expect(calls.map((call) => call.path)).toEqual([
114
- '/mypage/mentoLec/insert.do',
115
- '/mypage/mentoLec/update.do',
116
- '/mypage/mentoLec/delete.do',
117
- '/application/application/application.do',
118
- '/mypage/userAnswer/cancel.do',
119
- '/mypage/itemRent/insert.do',
120
- '/application/application/application.do',
118
+ expect(calls.map((call) => `${call.method}:${call.path}`)).toEqual([
119
+ 'postForm:/mypage/mentoLec/insert.do',
120
+ 'postForm:/mypage/mentoLec/update.do',
121
+ 'post:/mypage/mentoLec/delete.do',
122
+ 'post:/application/application/application.do',
123
+ 'post:/mypage/userAnswer/cancel.do',
124
+ 'post:/mypage/itemRent/insert.do',
125
+ 'post:/application/application/application.do',
121
126
  ])
122
127
  expect(calls[0]?.data).toMatchObject({
123
128
  menuNo: MENU_NO.MENTORING,
@@ -161,6 +166,38 @@ describe('SomaClient', () => {
161
166
  })
162
167
  })
163
168
 
169
+ test('mentoring.update merges partial params with existing data', async () => {
170
+ const mentoringDetailHtml =
171
+ '<div class="group"><strong class="t">모집 명</strong><div class="c">[멘토 특강] 웹 성능 특강</div></div><div class="group"><strong class="t">접수 기간</strong><div class="c">2026.04.01 ~ 2026.04.10</div></div><div class="group"><strong class="t">강의날짜</strong><div class="c"><span>2026.04.11 14:00시 ~ 15:30시</span></div></div><div class="group"><strong class="t">장소</strong><div class="c">온라인(Webex)</div></div><div class="group"><strong class="t">모집인원</strong><div class="c">20명</div></div><div class="group"><strong class="t">작성자</strong><div class="c">전수열</div></div><div class="group"><strong class="t">등록일</strong><div class="c">2026.04.01</div></div><div class="cont"><p>세션 본문</p></div>'
172
+ const client = new SomaClient()
173
+ const postFormCalls: Array<{ path: string; data: Record<string, string> }> = []
174
+ Reflect.set(client, 'http', {
175
+ checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
176
+ get: async () => mentoringDetailHtml,
177
+ postForm: async (path: string, data: Record<string, string>) => {
178
+ postFormCalls.push({ path, data })
179
+ return ''
180
+ },
181
+ })
182
+
183
+ await client.mentoring.update(9572, { title: '변경된 제목' })
184
+
185
+ expect(postFormCalls).toHaveLength(1)
186
+ expect(postFormCalls[0]?.data).toMatchObject({
187
+ qustnrSj: '변경된 제목',
188
+ qustnrSn: '9572',
189
+ reportCd: 'MRC020',
190
+ eventDt: '2026-04-11',
191
+ eventStime: '14:00',
192
+ eventEtime: '15:30',
193
+ place: '온라인(Webex)',
194
+ applyCnt: '20',
195
+ bgnde: '2026-04-01',
196
+ endde: '2026-04-10',
197
+ qestnarCn: '<p>세션 본문</p>',
198
+ })
199
+ })
200
+
164
201
  test('room, dashboard, notice, team, member, event, and history routes use expected endpoints', async () => {
165
202
  const client = new SomaClient()
166
203
  const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
@@ -197,13 +234,13 @@ describe('SomaClient', () => {
197
234
  post: async (path: string, data: Record<string, string>) => {
198
235
  calls.push({ method: 'post', path, data })
199
236
  if (path === '/mypage/officeMng/rentTime.do') {
200
- return '<div class="time-grid"><span>09:00</span></div>'
237
+ return '<span class="ck-st2 disabled" data-hour="09" data-minute="00"><input type="checkbox" disabled="disabled"><label title="아침 회의&lt;br&gt;예약자 : 김오픈">오전 09:00</label></span>'
201
238
  }
202
239
  return '<ul class="bbs-reserve"><li class="item"><a href="javascript:void(0);" onclick="location.href=\'/sw/mypage/officeMng/view.do?itemId=17\';"><div class="cont"><h4 class="tit">스페이스 A1</h4><ul class="txt bul-dot grey"><li>이용기간 : 2026-04-01 ~ 2026-12-31</li><li><p>설명 : 4인</p></li><li class="time-list"><div class="time-grid"><span>09:00</span></div></li></ul></div></a></li></ul>'
203
240
  },
204
241
  })
205
242
 
206
- const roomList = await client.room.list({ date: '2026-04-01', room: 'A1' })
243
+ const roomList = await client.room.list({ date: '2026-04-01', room: 'A1', includeReservations: true })
207
244
  const roomSlots = await client.room.available(17, '2026-04-01')
208
245
  const dashboard = await client.dashboard.get()
209
246
  const noticeList = await client.notice.list({ page: 2 })
@@ -215,7 +252,12 @@ describe('SomaClient', () => {
215
252
  const history = await client.mentoring.history({ page: 4 })
216
253
 
217
254
  expect(roomList[0]?.itemId).toBe(17)
218
- expect(roomSlots).toEqual([{ time: '09:00', available: true }])
255
+ expect(roomList[0]?.timeSlots).toEqual([
256
+ { time: '09:00', available: false, reservation: { title: '아침 회의', bookedBy: '김오픈' } },
257
+ ])
258
+ expect(roomSlots).toEqual([
259
+ { time: '09:00', available: false, reservation: { title: '아침 회의', bookedBy: '김오픈' } },
260
+ ])
219
261
  expect(dashboard.name).toBe('전수열')
220
262
  expect(dashboard.mentoringSessions).toEqual([
221
263
  {
@@ -249,6 +291,11 @@ describe('SomaClient', () => {
249
291
 
250
292
  const dashboardCallIndex = calls.findIndex((c) => c.path === '/mypage/myMain/dashboard.do')
251
293
  expect(dashboardCallIndex).toBeGreaterThanOrEqual(0)
294
+ expect(calls).toContainEqual({
295
+ method: 'post',
296
+ path: '/mypage/officeMng/rentTime.do',
297
+ data: { viewType: 'CONTBODY', itemId: '17', rentDt: '2026-04-01' },
298
+ })
252
299
  const mentoringListCall = calls.find((c) => c.path === '/mypage/mentoLec/list.do')
253
300
  expect(mentoringListCall?.data).toEqual({
254
301
  menuNo: MENU_NO.MENTORING,
package/src/client.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  buildUpdateMentoringPayload,
17
17
  parseEventDetail,
18
18
  resolveRoomId,
19
+ toMentoringType,
19
20
  toRegionCode,
20
21
  toReportTypeCd,
21
22
  } from './shared/utils/swmaestro'
@@ -27,6 +28,7 @@ import type {
27
28
  MemberInfo,
28
29
  MentoringDetail,
29
30
  MentoringListItem,
31
+ MentoringUpdateOptions,
30
32
  NoticeDetail,
31
33
  NoticeListItem,
32
34
  Pagination,
@@ -71,7 +73,7 @@ export class SomaClient {
71
73
  regEnd?: string
72
74
  content?: string
73
75
  }): Promise<void>
74
- update(id: number, params: Parameters<typeof buildMentoringPayload>[0]): Promise<void>
76
+ update(id: number, params: MentoringUpdateOptions): Promise<void>
75
77
  delete(id: number): Promise<void>
76
78
  apply(id: number): Promise<void>
77
79
  cancel(params: { applySn: number; qustnrSn: number }): Promise<void>
@@ -79,7 +81,7 @@ export class SomaClient {
79
81
  }
80
82
 
81
83
  readonly room: {
82
- list(options?: { date?: string; room?: string }): Promise<RoomCard[]>
84
+ list(options?: { date?: string; room?: string; includeReservations?: boolean }): Promise<RoomCard[]>
83
85
  available(roomId: number, date: string): Promise<RoomCard['timeSlots']>
84
86
  reserve(params: {
85
87
  roomId: number
@@ -176,14 +178,27 @@ export class SomaClient {
176
178
  },
177
179
  create: async (params) => {
178
180
  await this.requireAuth()
179
- const html = await this.http.post('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
181
+ const html = await this.http.postForm('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
180
182
  if (this.containsErrorIndicator(html)) {
181
183
  throw new Error(this.extractErrorMessage(html) || '멘토링 등록에 실패했습니다.')
182
184
  }
183
185
  },
184
186
  update: async (id, params) => {
185
187
  await this.requireAuth()
186
- const html = await this.http.post('/mypage/mentoLec/update.do', buildUpdateMentoringPayload(id, params))
188
+ const existing = await this.mentoring.get(id)
189
+ const merged = buildUpdateMentoringPayload(id, {
190
+ title: params.title ?? existing.title,
191
+ type: params.type ?? toMentoringType(existing.type),
192
+ date: params.date ?? existing.sessionDate,
193
+ startTime: params.startTime ?? existing.sessionTime.start,
194
+ endTime: params.endTime ?? existing.sessionTime.end,
195
+ venue: params.venue ?? existing.venue,
196
+ maxAttendees: params.maxAttendees ?? existing.attendees.max,
197
+ regStart: params.regStart ?? existing.registrationPeriod.start,
198
+ regEnd: params.regEnd ?? existing.registrationPeriod.end,
199
+ content: params.content ?? existing.content,
200
+ })
201
+ const html = await this.http.postForm('/mypage/mentoLec/update.do', merged)
187
202
  if (this.containsErrorIndicator(html)) {
188
203
  throw new Error(this.extractErrorMessage(html) || '멘토링 수정에 실패했습니다.')
189
204
  }
@@ -216,13 +231,35 @@ export class SomaClient {
216
231
  this.room = {
217
232
  list: async (options) => {
218
233
  await this.requireAuth()
219
- return formatters.parseRoomList(
234
+ const date = options?.date ?? new Date().toISOString().slice(0, 10)
235
+ const rooms = formatters.parseRoomList(
220
236
  await this.http.post('/mypage/officeMng/list.do', {
221
237
  menuNo: MENU_NO.ROOM,
222
- sdate: options?.date ?? new Date().toISOString().slice(0, 10),
238
+ sdate: date,
223
239
  searchItemId: options?.room ? String(resolveRoomId(options.room)) : '',
224
240
  }),
225
241
  )
242
+
243
+ if (!options?.includeReservations) return rooms
244
+
245
+ return Promise.all(
246
+ rooms.map(async (room) => {
247
+ try {
248
+ const html = await this.http.post('/mypage/officeMng/rentTime.do', {
249
+ viewType: 'CONTBODY',
250
+ itemId: String(room.itemId),
251
+ rentDt: date,
252
+ })
253
+
254
+ return {
255
+ ...room,
256
+ timeSlots: formatters.parseRoomSlots(html),
257
+ }
258
+ } catch {
259
+ return room
260
+ }
261
+ }),
262
+ )
226
263
  },
227
264
  available: async (roomId, date) => {
228
265
  await this.requireAuth()
@@ -11,6 +11,7 @@ import {
11
11
  buildDeleteMentoringPayload,
12
12
  buildMentoringPayload,
13
13
  buildUpdateMentoringPayload,
14
+ toMentoringType,
14
15
  } from '../shared/utils/swmaestro'
15
16
  import { getHttpOrExit } from './helpers'
16
17
 
@@ -36,12 +37,12 @@ type CreateOptions = {
36
37
  pretty?: boolean
37
38
  }
38
39
  type UpdateOptions = {
39
- title: string
40
- type: 'public' | 'lecture'
41
- date: string
42
- start: string
43
- end: string
44
- venue: string
40
+ title?: string
41
+ type?: 'public' | 'lecture'
42
+ date?: string
43
+ start?: string
44
+ end?: string
45
+ venue?: string
45
46
  maxAttendees?: string
46
47
  regStart?: string
47
48
  regEnd?: string
@@ -96,7 +97,7 @@ async function getAction(id: string, options: GetOptions): Promise<void> {
96
97
  async function createAction(options: CreateOptions): Promise<void> {
97
98
  try {
98
99
  const http = await getHttpOrExit()
99
- await http.post(
100
+ await http.postForm(
100
101
  '/mypage/mentoLec/insert.do',
101
102
  buildMentoringPayload({
102
103
  title: options.title,
@@ -120,19 +121,26 @@ async function createAction(options: CreateOptions): Promise<void> {
120
121
  async function updateAction(id: string, options: UpdateOptions): Promise<void> {
121
122
  try {
122
123
  const http = await getHttpOrExit()
123
- await http.post(
124
+ const numId = Number.parseInt(id, 10)
125
+ const html = await http.get('/mypage/mentoLec/view.do', {
126
+ menuNo: MENU_NO.MENTORING,
127
+ qustnrSn: id,
128
+ })
129
+ const existing = formatters.parseMentoringDetail(html, numId)
130
+
131
+ await http.postForm(
124
132
  '/mypage/mentoLec/update.do',
125
- buildUpdateMentoringPayload(Number.parseInt(id, 10), {
126
- title: options.title,
127
- type: options.type,
128
- date: options.date,
129
- startTime: options.start,
130
- endTime: options.end,
131
- venue: options.venue,
132
- maxAttendees: options.maxAttendees ? Number.parseInt(options.maxAttendees, 10) : undefined,
133
- regStart: options.regStart,
134
- regEnd: options.regEnd,
135
- content: options.content,
133
+ buildUpdateMentoringPayload(numId, {
134
+ title: options.title ?? existing.title,
135
+ type: options.type ?? toMentoringType(existing.type),
136
+ date: options.date ?? existing.sessionDate,
137
+ startTime: options.start ?? existing.sessionTime.start,
138
+ endTime: options.end ?? existing.sessionTime.end,
139
+ venue: options.venue ?? existing.venue,
140
+ maxAttendees: options.maxAttendees ? Number.parseInt(options.maxAttendees, 10) : existing.attendees.max,
141
+ regStart: options.regStart ?? existing.registrationPeriod.start,
142
+ regEnd: options.regEnd ?? existing.registrationPeriod.end,
143
+ content: options.content ?? existing.content,
136
144
  }),
137
145
  )
138
146
  console.log(formatOutput({ ok: true }, options.pretty))
@@ -235,14 +243,14 @@ export const mentoringCommand = new Command('mentoring')
235
243
  )
236
244
  .addCommand(
237
245
  new Command('update')
238
- .description('Update a mentoring session')
246
+ .description('Update a mentoring session (partial update - only specified fields are changed)')
239
247
  .argument('<id>')
240
- .requiredOption('--title <title>', 'Title')
241
- .requiredOption('--type <type>', 'Mentoring type (public|lecture)')
242
- .requiredOption('--date <date>', 'Session date')
243
- .requiredOption('--start <time>', 'Start time')
244
- .requiredOption('--end <time>', 'End time')
245
- .requiredOption('--venue <venue>', 'Venue')
248
+ .option('--title <title>', 'Title')
249
+ .option('--type <type>', 'Mentoring type (public|lecture)')
250
+ .option('--date <date>', 'Session date')
251
+ .option('--start <time>', 'Start time')
252
+ .option('--end <time>', 'End time')
253
+ .option('--venue <venue>', 'Venue')
246
254
  .option('--max-attendees <count>', 'Maximum attendees')
247
255
  .option('--reg-start <date>', 'Registration start date')
248
256
  .option('--reg-end <date>', 'Registration end date')
@@ -6,7 +6,7 @@ import { formatOutput } from '../shared/utils/output'
6
6
  import { buildRoomReservationPayload, resolveRoomId } from '../shared/utils/swmaestro'
7
7
  import { getHttpOrExit } from './helpers'
8
8
 
9
- type ListOptions = { date?: string; room?: string; pretty?: boolean }
9
+ type ListOptions = { date?: string; room?: string; reservations?: boolean; pretty?: boolean }
10
10
  type AvailableOptions = { date: string; pretty?: boolean }
11
11
  type ReserveOptions = {
12
12
  room: string
@@ -21,12 +21,39 @@ type ReserveOptions = {
21
21
  async function listAction(options: ListOptions): Promise<void> {
22
22
  try {
23
23
  const http = await getHttpOrExit()
24
+ const date = options.date ?? new Date().toISOString().slice(0, 10)
24
25
  const html = await http.post('/mypage/officeMng/list.do', {
25
26
  menuNo: '200058',
26
- sdate: options.date ?? new Date().toISOString().slice(0, 10),
27
+ sdate: date,
27
28
  searchItemId: options.room ? String(resolveRoomId(options.room)) : '',
28
29
  })
29
- console.log(formatOutput(formatters.parseRoomList(html), options.pretty))
30
+ const rooms = formatters.parseRoomList(html)
31
+
32
+ if (!options.reservations) {
33
+ console.log(formatOutput(rooms, options.pretty))
34
+ return
35
+ }
36
+
37
+ const enrichedRooms = await Promise.all(
38
+ rooms.map(async (room) => {
39
+ try {
40
+ const detailHtml = await http.post('/mypage/officeMng/rentTime.do', {
41
+ viewType: 'CONTBODY',
42
+ itemId: String(room.itemId),
43
+ rentDt: date,
44
+ })
45
+
46
+ return {
47
+ ...room,
48
+ timeSlots: formatters.parseRoomSlots(detailHtml),
49
+ }
50
+ } catch {
51
+ return room
52
+ }
53
+ }),
54
+ )
55
+
56
+ console.log(formatOutput(enrichedRooms, options.pretty))
30
57
  } catch (error) {
31
58
  handleError(error)
32
59
  }
@@ -76,6 +103,7 @@ export const roomCommand = new Command('room')
76
103
  .description('List rooms')
77
104
  .option('--date <date>', 'Reservation date')
78
105
  .option('--room <room>', 'Room filter')
106
+ .option('--reservations', 'Include reservation info in time slots')
79
107
  .option('--pretty', 'Pretty print JSON output')
80
108
  .action(listAction),
81
109
  )
package/src/constants.ts CHANGED
@@ -26,15 +26,15 @@ export const ROOM_IDS: Record<string, number> = {
26
26
  }
27
27
 
28
28
  export const VENUES = {
29
- TOZ_GWANGHWAMUN: '광화문점',
30
- TOZ_YANGJAE: '양재점',
31
- TOZ_GANGNAM_CONFERENCE_CENTER: '강남컨퍼런스센터점',
32
- TOZ_KONKUK: '건대점',
33
- TOZ_GANGNAM_TOWER: '강남역토즈타워점',
34
- TOZ_SEOLLEUNG: '선릉점',
35
- TOZ_YEOKSAM: '역삼점',
36
- TOZ_HONGDAE: '홍대점',
37
- TOZ_SINCHON_BUSINESS_CENTER: '신촌비즈니스센터점',
29
+ TOZ_GWANGHWAMUN: '토즈-광화문점',
30
+ TOZ_YANGJAE: '토즈-양재점',
31
+ TOZ_GANGNAM_CONFERENCE_CENTER: '토즈-강남컨퍼런스센터점',
32
+ TOZ_KONKUK: '토즈-건대점',
33
+ TOZ_GANGNAM_TOWER: '토즈-강남역토즈타워점',
34
+ TOZ_SEOLLEUNG: '토즈-선릉점',
35
+ TOZ_YEOKSAM: '토즈-역삼점',
36
+ TOZ_HONGDAE: '토즈-홍대점',
37
+ TOZ_SINCHON_BUSINESS_CENTER: '토즈-신촌비즈니스센터점',
38
38
  ONLINE_WEBEX: '온라인(Webex)',
39
39
  SPACE_A1: '스페이스 A1',
40
40
  SPACE_A2: '스페이스 A2',
@@ -47,8 +47,23 @@ export const VENUES = {
47
47
  SPACE_M1: '스페이스 M1',
48
48
  SPACE_M2: '스페이스 M2',
49
49
  SPACE_S: '스페이스 S',
50
+ EXPERT_LOUNGE: '(엑스퍼트) 연수센터_라운지',
51
+ EXPERT_CAFE: '(엑스퍼트) 외부_카페',
50
52
  } as const
51
53
 
54
+ export const VENUE_ALIASES: Record<string, string> = {
55
+ 광화문점: '토즈-광화문점',
56
+ 양재점: '토즈-양재점',
57
+ 강남컨퍼런스센터점: '토즈-강남컨퍼런스센터점',
58
+ 건대점: '토즈-건대점',
59
+ 강남역토즈타워점: '토즈-강남역토즈타워점',
60
+ 선릉점: '토즈-선릉점',
61
+ 역삼점: '토즈-역삼점',
62
+ 홍대점: '토즈-홍대점',
63
+ 신촌비즈니스센터점: '연수센터-7',
64
+ '토즈-신촌비즈니스센터점': '연수센터-7',
65
+ }
66
+
52
67
  export const REPORT_CD = {
53
68
  PUBLIC_MENTORING: 'MRC010',
54
69
  MENTOR_LECTURE: 'MRC020',
@@ -214,14 +214,18 @@ describe('formatters', () => {
214
214
  <input type="hidden" name="chkData_1" value="09:00" />
215
215
  <span class="ck-st2 disabled" data-hour="12" data-minute="00">
216
216
  <input type="checkbox" name="time" id="time1_7" value="7" disabled="disabled">
217
- <label for="time1_7">PM 12:00</label>
217
+ <label for="time1_7" title="점심 회의&lt;br&gt;예약자 : 김오픈">PM 12:00</label>
218
218
  </span>
219
219
  <input type="hidden" name="chkData_7" value="12:00" />
220
220
  `
221
221
 
222
222
  expect(parseRoomSlots(html)).toEqual([
223
223
  { time: '09:00', available: true },
224
- { time: '12:00', available: false },
224
+ {
225
+ time: '12:00',
226
+ available: false,
227
+ reservation: { title: '점심 회의', bookedBy: '김오픈' },
228
+ },
225
229
  ])
226
230
  })
227
231
 
package/src/formatters.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { type HTMLElement, parse } from 'node-html-parser'
2
2
 
3
+ import { decodeHtmlEntities } from './shared/utils/html'
3
4
  import {
4
- ApplicationHistoryItemSchema,
5
5
  type ApplicationHistoryItem,
6
- ApprovalListItemSchema,
6
+ ApplicationHistoryItemSchema,
7
7
  type ApprovalListItem,
8
- DashboardSchema,
9
- EventListItemSchema,
8
+ ApprovalListItemSchema,
10
9
  type Dashboard,
10
+ DashboardSchema,
11
11
  type EventListItem,
12
+ EventListItemSchema,
12
13
  type MemberInfo,
13
14
  MemberInfoSchema,
14
15
  type MentoringDetail,
@@ -98,7 +99,7 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
98
99
  status: extractStatus(labels.상태 || rawTitle),
99
100
  author: labels['작성자'] || '',
100
101
  createdAt: labels['등록일'] || '',
101
- content: contentNode?.innerHTML.trim() ?? '',
102
+ content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
102
103
  venue: labels['장소'] || '',
103
104
  applicants,
104
105
  })
@@ -131,20 +132,7 @@ export function parseRoomSlots(html: string): RoomCard['timeSlots'] {
131
132
  return parseTimeSlotsFromRoot(root)
132
133
  }
133
134
 
134
- return slots
135
- .map((slot) => {
136
- const hour = slot.getAttribute('data-hour') ?? ''
137
- const minute = slot.getAttribute('data-minute') ?? ''
138
- const checkbox = slot.querySelector('input[type="checkbox"]')
139
- const className = slot.getAttribute('class') ?? ''
140
- const time = hour && minute ? `${hour}:${minute}` : normalizeTime(cleanText(slot.querySelector('label') ?? slot))
141
-
142
- return {
143
- time,
144
- available: !checkbox?.hasAttribute('disabled') && !className.includes('disabled'),
145
- }
146
- })
147
- .filter((slot) => Boolean(slot.time))
135
+ return slots.map(parseRoomTimeSlot).filter((slot) => Boolean(slot.time))
148
136
  }
149
137
 
150
138
  export function parseDashboard(html: string): Dashboard {
@@ -202,7 +190,7 @@ export function parseNoticeDetail(html: string, id = 0): NoticeDetail {
202
190
  title: cleanText(top?.querySelector('.tit')) || labels['제목'] || cleanText(root.querySelector('h1, h2, .title')),
203
191
  author,
204
192
  createdAt,
205
- content: contentNode?.innerHTML.trim() ?? '',
193
+ content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
206
194
  })
207
195
  }
208
196
 
@@ -464,15 +452,46 @@ function parseTimeSlotsFromRoot(root: HTMLElement): RoomCard['timeSlots'] {
464
452
  ? grid.querySelectorAll('span')
465
453
  : root.querySelectorAll('.time-grid span, [class*="time-slot"], .slot')
466
454
 
467
- return spans
468
- .map((slot) => ({
469
- time: cleanText(slot),
470
- available:
471
- !(slot.getAttribute('class') ?? '').includes('not-reserve') &&
472
- !(slot.getAttribute('class') ?? '').includes('booked') &&
473
- !(slot.getAttribute('class') ?? '').includes('disabled'),
474
- }))
475
- .filter((slot) => Boolean(slot.time))
455
+ return spans.map(parseRoomTimeSlot).filter((slot) => Boolean(slot.time))
456
+ }
457
+
458
+ function parseRoomTimeSlot(slot: HTMLElement): RoomCard['timeSlots'][number] {
459
+ const label = slot.querySelector('label')
460
+ const hour = slot.getAttribute('data-hour') ?? ''
461
+ const minute = slot.getAttribute('data-minute') ?? ''
462
+ const checkbox = slot.querySelector('input[type="checkbox"]')
463
+ const className = slot.getAttribute('class') ?? ''
464
+ const available =
465
+ !checkbox?.hasAttribute('disabled') &&
466
+ !className.includes('not-reserve') &&
467
+ !className.includes('booked') &&
468
+ !className.includes('disabled')
469
+ const time = hour && minute ? `${hour}:${minute}` : normalizeTime(cleanText(label ?? slot))
470
+ const reservation = !available ? extractReservation(label) : undefined
471
+
472
+ return {
473
+ time,
474
+ available,
475
+ ...(reservation ? { reservation } : {}),
476
+ }
477
+ }
478
+
479
+ function extractReservation(label: HTMLElement | null): { title: string; bookedBy: string } | undefined {
480
+ const rawTitle = label?.getAttribute('title')
481
+ if (!rawTitle) {
482
+ return undefined
483
+ }
484
+
485
+ const [title = '', bookedByLine = ''] = decodeHtmlEntities(rawTitle)
486
+ .split(/<br\s*\/?>/i)
487
+ .map((part) => part.trim())
488
+ const bookedBy = bookedByLine.replace(/^예약자\s*:\s*/, '').trim()
489
+
490
+ if (!title || !bookedBy) {
491
+ return undefined
492
+ }
493
+
494
+ return { title, bookedBy }
476
495
  }
477
496
 
478
497
  function findDashboardValue(items: HTMLElement[], label: string): string {