opensoma 0.3.0 → 0.5.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 (89) 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/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/mentoring.d.ts.map +1 -1
  11. package/dist/src/commands/mentoring.js +26 -20
  12. package/dist/src/commands/mentoring.js.map +1 -1
  13. package/dist/src/commands/room.d.ts.map +1 -1
  14. package/dist/src/commands/room.js +25 -2
  15. package/dist/src/commands/room.js.map +1 -1
  16. package/dist/src/constants.d.ts +52 -9
  17. package/dist/src/constants.d.ts.map +1 -1
  18. package/dist/src/constants.js +65 -9
  19. package/dist/src/constants.js.map +1 -1
  20. package/dist/src/formatters.d.ts.map +1 -1
  21. package/dist/src/formatters.js +79 -39
  22. package/dist/src/formatters.js.map +1 -1
  23. package/dist/src/http.d.ts +3 -0
  24. package/dist/src/http.d.ts.map +1 -1
  25. package/dist/src/http.js +42 -1
  26. package/dist/src/http.js.map +1 -1
  27. package/dist/src/index.d.ts +3 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +2 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/shared/utils/html.d.ts +3 -0
  32. package/dist/src/shared/utils/html.d.ts.map +1 -0
  33. package/dist/src/shared/utils/html.js +12 -0
  34. package/dist/src/shared/utils/html.js.map +1 -0
  35. package/dist/src/shared/utils/swmaestro.d.ts +2 -0
  36. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  37. package/dist/src/shared/utils/swmaestro.js +28 -5
  38. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  39. package/dist/src/shared/utils/toz.d.ts +23 -0
  40. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  41. package/dist/src/shared/utils/toz.js +72 -0
  42. package/dist/src/shared/utils/toz.js.map +1 -0
  43. package/dist/src/token-extractor.d.ts +9 -1
  44. package/dist/src/token-extractor.d.ts.map +1 -1
  45. package/dist/src/token-extractor.js +54 -10
  46. package/dist/src/token-extractor.js.map +1 -1
  47. package/dist/src/toz-formatters.d.ts +9 -0
  48. package/dist/src/toz-formatters.d.ts.map +1 -0
  49. package/dist/src/toz-formatters.js +151 -0
  50. package/dist/src/toz-formatters.js.map +1 -0
  51. package/dist/src/toz-http.d.ts +27 -0
  52. package/dist/src/toz-http.d.ts.map +1 -0
  53. package/dist/src/toz-http.js +154 -0
  54. package/dist/src/toz-http.js.map +1 -0
  55. package/dist/src/types.d.ts +88 -0
  56. package/dist/src/types.d.ts.map +1 -1
  57. package/dist/src/types.js +65 -1
  58. package/dist/src/types.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  61. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  62. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  63. package/src/__fixtures__/toz/toz_duration.json +25 -0
  64. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  65. package/src/__fixtures__/toz/toz_page.html +211 -0
  66. package/src/client.test.ts +63 -16
  67. package/src/client.ts +43 -6
  68. package/src/commands/auth.ts +107 -50
  69. package/src/commands/mentoring.ts +34 -26
  70. package/src/commands/room.ts +31 -3
  71. package/src/constants.ts +74 -9
  72. package/src/formatters.test.ts +6 -2
  73. package/src/formatters.ts +92 -45
  74. package/src/http.test.ts +215 -0
  75. package/src/http.ts +45 -1
  76. package/src/index.ts +3 -0
  77. package/src/shared/utils/html.ts +12 -0
  78. package/src/shared/utils/swmaestro.test.ts +44 -0
  79. package/src/shared/utils/swmaestro.ts +30 -5
  80. package/src/shared/utils/toz.test.ts +133 -0
  81. package/src/shared/utils/toz.ts +100 -0
  82. package/src/token-extractor.test.ts +30 -5
  83. package/src/token-extractor.ts +65 -13
  84. package/src/toz-formatters.test.ts +197 -0
  85. package/src/toz-formatters.ts +211 -0
  86. package/src/toz-http.test.ts +157 -0
  87. package/src/toz-http.ts +188 -0
  88. package/src/types.test.ts +4 -1
  89. package/src/types.ts +81 -1
@@ -1,3 +1,5 @@
1
+ import { createInterface, type Interface as ReadlineInterface } from 'node:readline'
2
+
1
3
  import { Command } from 'commander'
2
4
 
3
5
  import { CredentialManager } from '../credential-manager'
@@ -7,63 +9,99 @@ import { handleError } from '../shared/utils/error-handler'
7
9
  import { formatOutput } from '../shared/utils/output'
8
10
  import type { ExtractedSessionCandidate } from '../token-extractor'
9
11
 
10
- async function promptInput(message: string): Promise<string> {
11
- process.stdout.write(message)
12
- const input = await Bun.stdin.text()
13
- return input.trim()
12
+ function ask(rl: ReadlineInterface, message: string): Promise<string> {
13
+ return new Promise((resolve) => {
14
+ rl.question(message, (answer) => {
15
+ resolve(answer.trim())
16
+ })
17
+ })
14
18
  }
15
19
 
16
- async function promptPassword(message: string): Promise<string> {
20
+ async function promptPasswordTTY(message: string): Promise<string> {
17
21
  process.stdout.write(message)
18
22
 
19
- const originalStdin = process.stdin.isTTY
20
- ? (process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void })
21
- : null
22
-
23
- if (originalStdin?.setRawMode) {
24
- originalStdin.setRawMode(true)
23
+ const stdin = process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void }
24
+ if (stdin.setRawMode) {
25
+ stdin.setRawMode(true)
25
26
  }
27
+ stdin.resume()
26
28
 
27
29
  let password = ''
28
- const decoder = new TextDecoder()
29
30
 
30
31
  try {
31
- for await (const chunk of Bun.stdin.stream()) {
32
- const text = decoder.decode(chunk)
33
- for (const char of text) {
34
- const code = char.charCodeAt(0)
35
- if (code === 13 || code === 10) {
36
- // Enter key
37
- process.stdout.write('\n')
38
- return password
39
- } else if (code === 3) {
40
- // Ctrl+C
41
- process.exit(1)
42
- } else if (code === 127) {
43
- // Backspace
44
- if (password.length > 0) {
45
- password = password.slice(0, -1)
46
- process.stdout.write('\b \b')
32
+ return await new Promise<string>((resolve) => {
33
+ const onData = (chunk: Buffer | string): void => {
34
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
35
+ for (const char of text) {
36
+ const code = char.charCodeAt(0)
37
+ if (code === 13 || code === 10) {
38
+ // Enter key
39
+ stdin.removeListener('data', onData)
40
+ process.stdout.write('\n')
41
+ resolve(password)
42
+ return
43
+ } else if (code === 3) {
44
+ // Ctrl+C
45
+ stdin.removeListener('data', onData)
46
+ process.exit(1)
47
+ } else if (code === 127 || code === 8) {
48
+ // Backspace / Delete
49
+ if (password.length > 0) {
50
+ password = password.slice(0, -1)
51
+ process.stdout.write('\b \b')
52
+ }
53
+ } else if (code >= 32 && code <= 126) {
54
+ // Printable characters
55
+ password += char
56
+ process.stdout.write('*')
47
57
  }
48
- } else if (code >= 32 && code <= 126) {
49
- // Printable characters
50
- password += char
51
- process.stdout.write('*')
52
58
  }
53
59
  }
54
- }
60
+
61
+ stdin.on('data', onData)
62
+ })
55
63
  } finally {
56
- if (originalStdin?.setRawMode) {
57
- originalStdin.setRawMode(false)
64
+ if (stdin.setRawMode) {
65
+ stdin.setRawMode(false)
58
66
  }
67
+ stdin.pause()
59
68
  }
69
+ }
70
+
71
+ async function promptCredentials(
72
+ needUsername: boolean,
73
+ needPassword: boolean,
74
+ ): Promise<{ username?: string; password?: string }> {
75
+ const result: { username?: string; password?: string } = {}
60
76
 
61
- return password
77
+ if (needUsername) {
78
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
79
+ try {
80
+ result.username = await ask(rl, 'Username: ')
81
+ } finally {
82
+ rl.close()
83
+ }
84
+ }
85
+
86
+ if (needPassword) {
87
+ if (process.stdin.isTTY) {
88
+ result.password = await promptPasswordTTY('Password: ')
89
+ } else {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
91
+ try {
92
+ result.password = await ask(rl, 'Password: ')
93
+ } finally {
94
+ rl.close()
95
+ }
96
+ }
97
+ }
98
+
99
+ return result
62
100
  }
63
101
 
64
102
  type LoginOptions = { username?: string; password?: string; pretty?: boolean }
65
103
  type StatusOptions = { pretty?: boolean }
66
- type ExtractOptions = { pretty?: boolean }
104
+ type ExtractOptions = { debug?: boolean; pretty?: boolean }
67
105
  type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
68
106
  type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
69
107
  type StatusValidator = Pick<SomaHttp, 'checkLogin'>
@@ -87,21 +125,34 @@ export async function resolveExtractedCredentials(
87
125
  candidates: ExtractedSessionCandidate[],
88
126
  createValidator: (sessionCookie: string) => ExtractedSessionValidator = (sessionCookie) =>
89
127
  new SomaHttp({ sessionCookie }),
128
+ debug?: (message: string) => void,
90
129
  ): Promise<{ csrfToken: string; sessionCookie: string } | null> {
130
+ debug?.(`Validating ${candidates.length} candidate(s) against server...`)
131
+
91
132
  for (const candidate of candidates) {
92
133
  const http = createValidator(candidate.sessionCookie)
134
+ debug?.(` ${candidate.browser} / ${candidate.profile}: checkLogin...`)
93
135
 
94
136
  try {
95
137
  const valid = Boolean(await http.checkLogin())
96
138
  if (!valid) {
139
+ debug?.(` ${candidate.browser} / ${candidate.profile}: session invalid`)
97
140
  continue
98
141
  }
99
142
 
143
+ debug?.(` ${candidate.browser} / ${candidate.profile}: valid! Extracting CSRF token...`)
144
+ const csrfToken = await http.extractCsrfToken()
145
+ debug?.(` CSRF token obtained (${csrfToken.length} chars)`)
146
+
100
147
  return {
101
148
  sessionCookie: candidate.sessionCookie,
102
- csrfToken: await http.extractCsrfToken(),
149
+ csrfToken,
103
150
  }
104
- } catch {}
151
+ } catch (error) {
152
+ debug?.(
153
+ ` ${candidate.browser} / ${candidate.profile}: error: ${error instanceof Error ? error.message : String(error)}`,
154
+ )
155
+ }
105
156
  }
106
157
 
107
158
  return null
@@ -112,12 +163,9 @@ async function loginAction(options: LoginOptions): Promise<void> {
112
163
  let username = options.username ?? process.env.OPENSOMA_USERNAME
113
164
  let password = options.password ?? process.env.OPENSOMA_PASSWORD
114
165
 
115
- if (!username) {
116
- username = await promptInput('Username: ')
117
- }
118
- if (!password) {
119
- password = await promptPassword('Password: ')
120
- }
166
+ const prompted = await promptCredentials(!username, !password)
167
+ username ??= prompted.username
168
+ password ??= prompted.password
121
169
 
122
170
  if (!username || !password) {
123
171
  throw new Error('Username and password are required')
@@ -264,22 +312,30 @@ async function statusAction(options: StatusOptions): Promise<void> {
264
312
  }
265
313
 
266
314
  async function extractAction(options: ExtractOptions): Promise<void> {
315
+ const log = options.debug ? (message: string) => process.stderr.write(`[extract] ${message}\n`) : undefined
316
+
267
317
  try {
268
318
  const { TokenExtractor } = (await import('../token-extractor')) as {
269
- TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
319
+ TokenExtractor: new (options?: { debug?: boolean }) => {
320
+ extractCandidates: () => Promise<ExtractedSessionCandidate[]>
321
+ }
270
322
  }
271
- const extractor = new TokenExtractor()
323
+ const extractor = new TokenExtractor({ debug: options.debug })
272
324
  const candidates = await extractor.extractCandidates()
273
325
  if (candidates.length === 0) {
274
326
  throw new Error(
275
- 'No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
327
+ 'No SWMaestro session found in any browser. Login to swmaestro.ai or opensoma.dev in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
276
328
  )
277
329
  }
278
330
 
279
- const credentials = await resolveExtractedCredentials(candidates)
331
+ log?.(
332
+ `Extracted ${candidates.length} candidate(s): ${candidates.map((c) => `${c.browser}/${c.profile}`).join(', ')}`,
333
+ )
334
+
335
+ const credentials = await resolveExtractedCredentials(candidates, undefined, log)
280
336
  if (!credentials) {
281
337
  throw new Error(
282
- 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai login in a supported Chromium browser and try again.',
338
+ 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai or opensoma.dev login in a supported Chromium browser and try again.',
283
339
  )
284
340
  }
285
341
 
@@ -319,6 +375,7 @@ export const authCommand = new Command('auth')
319
375
  .addCommand(
320
376
  new Command('extract')
321
377
  .description('Extract browser credentials')
378
+ .option('--debug', 'Show debug output')
322
379
  .option('--pretty', 'Pretty print JSON output')
323
380
  .action(extractAction),
324
381
  )
@@ -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',
@@ -70,3 +85,53 @@ function createTimeSlots(): string[] {
70
85
  function formatTime(hour: number, minute: number): string {
71
86
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
72
87
  }
88
+
89
+ export const TOZ_BASE_URL = 'http://partner.toz.co.kr/partner/reservation/fkii3/swmaestro'
90
+ export const TOZ_PARTNER = 'fkii3'
91
+ export const TOZ_COMPANY = 'swmaestro'
92
+ export const TOZ_MEMBER_COMPANY_ID = '25408'
93
+
94
+ // Static fallback list extracted from booking.htm. Use TozClient.branches() at runtime
95
+ // to fetch the live list — the SW마에스트로 partnership branch set may change.
96
+ export const TOZ_BRANCHES = [
97
+ { id: 27, name: '강남토즈타워점' },
98
+ { id: 145, name: '강남컨퍼런스센터' },
99
+ { id: 19, name: '양재점' },
100
+ { id: 20, name: '건대점' },
101
+ { id: 15, name: '선릉점' },
102
+ { id: 139, name: '마이스 역삼센터' },
103
+ { id: 134, name: '마이스 광화문센터' },
104
+ { id: 30, name: '신촌비즈센터' },
105
+ { id: 149, name: '홍대점' },
106
+ ] as const
107
+
108
+ export const TOZ_PHONE_PREFIXES = ['010', '011', '016', '017', '018', '019'] as const
109
+
110
+ export const TOZ_EMAIL_DOMAINS = [
111
+ 'hanmail.net',
112
+ 'gmail.com',
113
+ 'nate.com',
114
+ 'naver.com',
115
+ 'daum.net',
116
+ 'dreamwiz.com',
117
+ 'yahoo.com',
118
+ 'yahoo.co.kr',
119
+ 'msn.com',
120
+ 'paran.com',
121
+ 'korea.com',
122
+ 'freechal.com',
123
+ 'lycos.co.kr',
124
+ 'msn.co.kr',
125
+ 'empal.com',
126
+ 'hotmail.com',
127
+ ] as const
128
+
129
+ export const TOZ_EMAIL_DOMAIN_CUSTOM = '직접입력'
130
+
131
+ export const TOZ_NEW_MEETING_VALUE = '새모임'
132
+
133
+ export const TOZ_MIN_DURATION_MINUTES = 120
134
+ export const TOZ_MAX_DURATION_MINUTES = 180
135
+ export const TOZ_SESSION_HOLD_SECONDS = 300
136
+
137
+ export const TOZ_MAX_CHECK_TIMES = 6
@@ -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