careermate 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -17
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -3
- package/apps/web/DESIGN_GUIDE.md +2 -2
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/icon-180.png +0 -0
- package/apps/web/public/index.html +14 -3
- package/apps/web/public/lib.js +135 -23
- package/apps/web/public/pages/applications.js +18 -9
- package/apps/web/public/pages/documents.js +66 -50
- package/apps/web/public/pages/home.js +12 -11
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +39 -36
- package/apps/web/public/pages/profile.js +66 -86
- package/apps/web/public/pages/settings.js +21 -14
- package/apps/web/public/styles.css +105 -38
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +9 -22
- package/apps/web/src/routes.ts +19 -14
- package/apps/web/src/server.ts +34 -29
- package/apps/web/src/settings.ts +35 -17
- package/bin/careermate.mjs +8 -5
- package/dist/doctor.mjs +78908 -0
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
- package/dist/install-page/favicon.svg +1 -0
- package/dist/install-page/icon-180.png +0 -0
- package/dist/install-page/icon-512.png +0 -0
- package/dist/install-page/index.html +328 -225
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/llms-install.txt +202 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/shots/applications-dark.png +0 -0
- package/dist/install-page/shots/applications-light.png +0 -0
- package/dist/install-page/shots/documents-dark.png +0 -0
- package/dist/install-page/shots/documents-light.png +0 -0
- package/dist/install-page/shots/home-dark.png +0 -0
- package/dist/install-page/shots/home-light.png +0 -0
- package/dist/install-page/shots/interview-dark.png +0 -0
- package/dist/install-page/shots/interview-light.png +0 -0
- package/dist/install-page/shots/jobs-dark.png +0 -0
- package/dist/install-page/shots/jobs-light.png +0 -0
- package/dist/install-page/shots/settings-dark.png +0 -0
- package/dist/install-page/shots/settings-light.png +0 -0
- package/dist/install-page/start.html +323 -0
- package/dist/install-page/style.css +128 -4
- package/dist/mcp.mjs +74225 -2007
- package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +14 -3
- package/dist/public/lib.js +135 -23
- package/dist/public/pages/applications.js +18 -9
- package/dist/public/pages/documents.js +66 -50
- package/dist/public/pages/home.js +12 -11
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +39 -36
- package/dist/public/pages/profile.js +66 -86
- package/dist/public/pages/settings.js +21 -14
- package/dist/public/styles.css +105 -38
- package/dist/web.mjs +39950 -369
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +6 -6
- package/docs/MCP_TOOLS.md +68 -9
- package/docs/ROADMAP.md +11 -10
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
- package/docs/START_WORKFLOW.md +19 -7
- package/docs/SUPPORTED_AI_APPS.md +85 -33
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +8 -6
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +8 -3
- package/packages/core/src/services.ts +42 -1
- package/packages/db/src/connection.ts +30 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +109 -63
- package/packages/db/src/schema.ts +118 -0
- package/packages/exporters/src/html.ts +9 -12
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +269 -65
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +521 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/enums.ts +39 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +24 -3
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +46 -8
- package/scripts/build-mcpb.mjs +60 -4
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +86 -0
- package/scripts/init.ts +32 -3
- package/scripts/seed.ts +5 -5
- package/scripts/shots-seed.ts +192 -0
- package/scripts/shots.ts +89 -0
- package/tsconfig.json +3 -1
- package/dist/careermate.mcpb +0 -0
- package/dist/mcp-smoke.err +0 -3
- package/dist/mcpb-stage/README.md +0 -219
- package/dist/mcpb-stage/dist/install-page/index.html +0 -434
- package/dist/mcpb-stage/dist/install-page/style.css +0 -407
- package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
- package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
- package/dist/mcpb-stage/dist/public/app.js +0 -118
- package/dist/mcpb-stage/dist/public/index.html +0 -41
- package/dist/mcpb-stage/dist/public/lib.js +0 -282
- package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
- package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
- package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
- package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
- package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
- package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
- package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
- package/dist/mcpb-stage/dist/public/styles.css +0 -420
- package/dist/mcpb-stage/manifest.json +0 -40
- package/docs/INSTALL.md +0 -148
- package/docs/INSTALL_AND_USAGE.md +0 -99
- package/install-page/index.html +0 -474
- package/install-page/style.css +0 -391
- package/install-page/vercel.json +0 -20
- /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/parsers/documents — extract readable text from binary document
|
|
3
|
+
* files that an AI client cannot read on its own.
|
|
4
|
+
*
|
|
5
|
+
* Handled here (all pure-JS, no external binaries — bundles into dist and ships
|
|
6
|
+
* with the normal npm install, zero extra setup):
|
|
7
|
+
* - `.docx` → mammoth
|
|
8
|
+
* - `.hwp` → hwp.js (HWP 5.x)
|
|
9
|
+
* - `.hwpx` → unzip + OWPML text nodes
|
|
10
|
+
* - `.pdf` → pdfjs-dist (text-based PDFs; scanned/image PDFs have no text layer)
|
|
11
|
+
* - `.txt/.md/...` → read as UTF-8
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT handled:
|
|
14
|
+
* - Images → no OCR in pure JS that's worth bundling; defer to the client's
|
|
15
|
+
* native image reading (Claude reads images directly).
|
|
16
|
+
* - Legacy `.doc/.ppt/.xls`, `.odt`, Apple iWork → no reliable pure-JS reader;
|
|
17
|
+
* we ask the user to re-save as PDF/DOCX.
|
|
18
|
+
*
|
|
19
|
+
* `extractDocument` never throws: a bad/unsupported file comes back as
|
|
20
|
+
* `unsupported: true` with a friendly Korean `warnings` message, so the MCP tool
|
|
21
|
+
* can relay it instead of crashing.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import { unzipSync, strFromU8 } from 'fflate';
|
|
27
|
+
import mammoth from 'mammoth';
|
|
28
|
+
import hwpjs from 'hwp.js';
|
|
29
|
+
import { BUNDLED } from '@careermate/shared';
|
|
30
|
+
import { decodeEntities } from './html.ts';
|
|
31
|
+
|
|
32
|
+
// hwp.js ships as CJS ({ Viewer, parse }); tolerate either interop shape.
|
|
33
|
+
const parseHwp: ((input: unknown, opts?: unknown) => HwpDoc) | undefined =
|
|
34
|
+
(hwpjs as any)?.parse ?? (hwpjs as any)?.default?.parse;
|
|
35
|
+
|
|
36
|
+
/** Logical source format we detected/handled. */
|
|
37
|
+
export type DocumentFormat =
|
|
38
|
+
| 'docx'
|
|
39
|
+
| 'hwp'
|
|
40
|
+
| 'hwpx'
|
|
41
|
+
| 'pdf'
|
|
42
|
+
| 'image'
|
|
43
|
+
| 'text'
|
|
44
|
+
| 'markdown'
|
|
45
|
+
| 'unsupported';
|
|
46
|
+
|
|
47
|
+
export interface ExtractDocumentResult {
|
|
48
|
+
/** Extracted plain text (empty when unsupported). */
|
|
49
|
+
text: string;
|
|
50
|
+
format: DocumentFormat;
|
|
51
|
+
/** Human-facing notes/caveats in Korean (always present, may be empty). */
|
|
52
|
+
warnings: string[];
|
|
53
|
+
/** Bytes read from disk (0 when not read). */
|
|
54
|
+
bytes: number;
|
|
55
|
+
/** True when the file could not be turned into text here. */
|
|
56
|
+
unsupported: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* True only for *operational* failures the caller should surface as an error
|
|
59
|
+
* (file not found, path is a directory, too large, unreadable). Soft cases —
|
|
60
|
+
* images, scanned PDFs, legacy/unparseable formats — are `unsupported: true`
|
|
61
|
+
* but `errored: false`: they carry actionable guidance, not a failure.
|
|
62
|
+
*/
|
|
63
|
+
errored: boolean;
|
|
64
|
+
/** Absolute path we resolved the input to (for messaging). */
|
|
65
|
+
path: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Hard ceiling so a giant/mis-typed file can't blow up memory. */
|
|
69
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Refuse to read obviously-sensitive files (private keys, credentials, secrets).
|
|
73
|
+
* read_document runs with the user's full filesystem privileges, and a malicious
|
|
74
|
+
* job posting could prompt-inject the user's agent into reading e.g. ~/.ssh/id_rsa
|
|
75
|
+
* "to finish the analysis". None of these are ever a résumé/career document, so
|
|
76
|
+
* declining them costs nothing and closes a real exfiltration amplifier.
|
|
77
|
+
* (`.key` is intentionally absent — it's Apple Keynote, handled as legacy-binary.)
|
|
78
|
+
*/
|
|
79
|
+
const SENSITIVE_PATH_RE =
|
|
80
|
+
/(^|[/\\])\.(ssh|aws|gnupg)([/\\]|$)|(^|[/\\])id_(rsa|dsa|ecdsa|ed25519)(\.[\w]+)?$|\.(pem|ppk)$|(^|[/\\])\.env(\.[\w-]+)?$|(^|[/\\])\.(npmrc|netrc|git-credentials)$/i;
|
|
81
|
+
|
|
82
|
+
// Bound the work a single (possibly hostile) file can trigger. The MAX_BYTES cap
|
|
83
|
+
// only limits the *compressed* input; a crafted archive can still expand far
|
|
84
|
+
// beyond that, and a PDF can declare an enormous page count — either can hang or
|
|
85
|
+
// OOM the MCP process. These caps keep extraction best-effort and bounded.
|
|
86
|
+
const MAX_HWPX_ENTRY_BYTES = 40 * 1024 * 1024; // per decompressed body section
|
|
87
|
+
const MAX_HWPX_TOTAL_BYTES = 80 * 1024 * 1024; // sum across decompressed sections
|
|
88
|
+
const MAX_PDF_PAGES = 300; // extract at most this many pages
|
|
89
|
+
|
|
90
|
+
const DOCX_RE = /\.docx$/i;
|
|
91
|
+
const HWP_RE = /\.hwp$/i;
|
|
92
|
+
const HWPX_RE = /\.hwpx$/i;
|
|
93
|
+
const PDF_RE = /\.pdf$/i;
|
|
94
|
+
const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|svg)$/i;
|
|
95
|
+
const TEXT_RE = /\.(txt|text|log|csv|tsv|json|ya?ml|xml)$/i;
|
|
96
|
+
const MD_RE = /\.(md|markdown|mdown|mkd)$/i;
|
|
97
|
+
const LEGACY_BINARY_RE = /\.(docm?|dot[xm]?|pptx?|ppt|xlsx?|xls|odt|ods|odp|rtf|pages|key|numbers)$/i;
|
|
98
|
+
|
|
99
|
+
/** Resolve `~` and relative paths to an absolute path. */
|
|
100
|
+
export function resolveDocumentPath(input: string, baseDir?: string): string {
|
|
101
|
+
let p = (input ?? '').trim().replace(/^["']|["']$/g, '');
|
|
102
|
+
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1));
|
|
103
|
+
if (!path.isAbsolute(p)) p = path.resolve(baseDir ?? process.cwd(), p);
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read a document file from disk and extract its text. Never throws.
|
|
109
|
+
*
|
|
110
|
+
* @param input file path (absolute, relative, or `~/…`)
|
|
111
|
+
* @param baseDir base for relative paths (default: process.cwd())
|
|
112
|
+
*/
|
|
113
|
+
export async function extractDocument(input: string, baseDir?: string): Promise<ExtractDocumentResult> {
|
|
114
|
+
const filePath = resolveDocumentPath(input, baseDir);
|
|
115
|
+
const base = (fmt: DocumentFormat, over: Partial<ExtractDocumentResult> = {}): ExtractDocumentResult => ({
|
|
116
|
+
text: '',
|
|
117
|
+
format: fmt,
|
|
118
|
+
warnings: [],
|
|
119
|
+
bytes: 0,
|
|
120
|
+
unsupported: false,
|
|
121
|
+
errored: false,
|
|
122
|
+
path: filePath,
|
|
123
|
+
...over,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Decline secrets/credentials before touching the file (see SENSITIVE_PATH_RE).
|
|
127
|
+
if (SENSITIVE_PATH_RE.test(filePath)) {
|
|
128
|
+
return base('unsupported', {
|
|
129
|
+
warnings: [
|
|
130
|
+
'보안상 민감할 수 있는 파일(개인 키·자격증명 등)은 읽지 않습니다. 이력서·경력기술서·자기소개서 같은 문서 파일을 지정해 주세요.',
|
|
131
|
+
],
|
|
132
|
+
unsupported: true,
|
|
133
|
+
errored: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let stat: fs.Stats;
|
|
138
|
+
try {
|
|
139
|
+
stat = fs.statSync(filePath);
|
|
140
|
+
} catch {
|
|
141
|
+
return base('unsupported', { warnings: [`파일을 찾을 수 없습니다: ${filePath}`], unsupported: true, errored: true });
|
|
142
|
+
}
|
|
143
|
+
if (stat.isDirectory())
|
|
144
|
+
return base('unsupported', { warnings: [`경로가 폴더입니다(파일이 아님): ${filePath}`], unsupported: true, errored: true });
|
|
145
|
+
if (stat.size > MAX_BYTES)
|
|
146
|
+
return base('unsupported', {
|
|
147
|
+
bytes: stat.size,
|
|
148
|
+
warnings: [`파일이 너무 큽니다(${(stat.size / 1048576).toFixed(1)}MB, 최대 ${MAX_BYTES / 1048576}MB).`],
|
|
149
|
+
unsupported: true,
|
|
150
|
+
errored: true,
|
|
151
|
+
});
|
|
152
|
+
// Zero-byte files: a clean, format-agnostic message instead of a silent empty
|
|
153
|
+
// "success" (text files) or a leaked library error ("The PDF file is empty…").
|
|
154
|
+
if (stat.size === 0)
|
|
155
|
+
return base('unsupported', {
|
|
156
|
+
bytes: 0,
|
|
157
|
+
warnings: ['파일이 비어 있습니다(0바이트). 내용이 있는 파일을 지정해 주세요.'],
|
|
158
|
+
unsupported: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const name = path.basename(filePath);
|
|
162
|
+
const bytes = stat.size;
|
|
163
|
+
|
|
164
|
+
// Images: no pure-JS OCR worth bundling — let the client read them natively.
|
|
165
|
+
if (IMAGE_RE.test(name)) {
|
|
166
|
+
return base('image', {
|
|
167
|
+
bytes,
|
|
168
|
+
warnings: [
|
|
169
|
+
'이미지는 이 도구로 글자를 추출하지 않습니다. AI 클라이언트가 이미지를 직접 볼 수 있으면 그렇게 읽고, 아니면 내용을 텍스트로 붙여넣어 주세요.',
|
|
170
|
+
],
|
|
171
|
+
unsupported: true,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Plain text / markdown. Decode by sniffing the encoding (BOM → UTF-8 →
|
|
176
|
+
// CP949): Korean résumés saved by Notepad/Excel are often EUC-KR/CP949, not
|
|
177
|
+
// UTF-8, and reading those as UTF-8 yields mojibake.
|
|
178
|
+
if (TEXT_RE.test(name) || MD_RE.test(name)) {
|
|
179
|
+
let textBuf: Buffer;
|
|
180
|
+
try {
|
|
181
|
+
textBuf = fs.readFileSync(filePath);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return base('unsupported', { bytes, warnings: [`텍스트 파일을 읽지 못했습니다: ${errMsg(e)}`], unsupported: true, errored: true });
|
|
184
|
+
}
|
|
185
|
+
const decoded = decodeText(textBuf).trim();
|
|
186
|
+
if (!decoded)
|
|
187
|
+
return base(MD_RE.test(name) ? 'markdown' : 'text', {
|
|
188
|
+
bytes,
|
|
189
|
+
warnings: ['파일에 읽을 텍스트가 없습니다(빈 파일). 내용이 있는 파일을 지정해 주세요.'],
|
|
190
|
+
unsupported: true,
|
|
191
|
+
});
|
|
192
|
+
return base(MD_RE.test(name) ? 'markdown' : 'text', { text: decoded, bytes });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read bytes once for binary handlers.
|
|
196
|
+
let buf: Buffer;
|
|
197
|
+
try {
|
|
198
|
+
buf = fs.readFileSync(filePath);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return base('unsupported', { bytes, warnings: [`파일을 읽지 못했습니다: ${errMsg(e)}`], unsupported: true, errored: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (DOCX_RE.test(name)) return finishDocx(buf, base);
|
|
204
|
+
if (HWPX_RE.test(name)) return finishHwpx(buf, base);
|
|
205
|
+
if (HWP_RE.test(name)) return finishHwp(buf, base);
|
|
206
|
+
if (PDF_RE.test(name)) return finishPdf(buf, base);
|
|
207
|
+
|
|
208
|
+
// Legacy binary office formats with no reliable pure-JS reader.
|
|
209
|
+
if (LEGACY_BINARY_RE.test(name)) {
|
|
210
|
+
return base('unsupported', {
|
|
211
|
+
bytes,
|
|
212
|
+
warnings: [
|
|
213
|
+
`${path.extname(name) || '이 형식'}은 자동 추출을 지원하지 않습니다. PDF 또는 .docx/.hwp/.hwpx로 다시 저장하거나, 내용을 텍스트로 복사해 붙여넣어 주세요.`,
|
|
214
|
+
],
|
|
215
|
+
unsupported: true,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Unknown extension: try to decode as text (BOM → UTF-8 → CP949), bail if it looks binary.
|
|
220
|
+
const asText = decodeText(buf);
|
|
221
|
+
if (looksBinary(asText)) {
|
|
222
|
+
return base('unsupported', {
|
|
223
|
+
bytes,
|
|
224
|
+
warnings: ['지원하지 않는 형식이거나 바이너리 파일로 보입니다. PDF/.docx/.hwp/.hwpx로 저장하거나 텍스트로 붙여넣어 주세요.'],
|
|
225
|
+
unsupported: true,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const trimmed = asText.trim();
|
|
229
|
+
if (!trimmed)
|
|
230
|
+
return base('unsupported', {
|
|
231
|
+
bytes,
|
|
232
|
+
warnings: ['파일에 읽을 텍스트가 없습니다(빈 파일). 내용이 있는 파일을 지정해 주세요.'],
|
|
233
|
+
unsupported: true,
|
|
234
|
+
});
|
|
235
|
+
return base('text', { text: trimmed, bytes });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type BaseFn = (fmt: DocumentFormat, over?: Partial<ExtractDocumentResult>) => ExtractDocumentResult;
|
|
239
|
+
|
|
240
|
+
/* ------------------------------------------------------------------- docx */
|
|
241
|
+
|
|
242
|
+
async function finishDocx(buf: Buffer, base: BaseFn): Promise<ExtractDocumentResult> {
|
|
243
|
+
try {
|
|
244
|
+
const { value, messages } = await mammoth.extractRawText({ buffer: buf });
|
|
245
|
+
const text = (value ?? '').trim();
|
|
246
|
+
if (!text)
|
|
247
|
+
return base('docx', { bytes: buf.length, warnings: ['DOCX에서 텍스트를 찾지 못했습니다(이미지 위주 문서일 수 있음).'], unsupported: true });
|
|
248
|
+
const warnings = messages?.length ? ['일부 서식/이미지는 무시하고 본문 텍스트만 추출했습니다.'] : [];
|
|
249
|
+
return base('docx', { text, bytes: buf.length, warnings });
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return base('unsupported', {
|
|
252
|
+
bytes: buf.length,
|
|
253
|
+
warnings: [`DOCX를 읽지 못했습니다(${errMsg(e)}). 구버전 .doc이면 .docx 또는 PDF로 다시 저장해 주세요.`],
|
|
254
|
+
unsupported: true,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* -------------------------------------------------------------------- hwp */
|
|
260
|
+
|
|
261
|
+
interface HwpChar {
|
|
262
|
+
type: number;
|
|
263
|
+
value: number | string;
|
|
264
|
+
}
|
|
265
|
+
interface HwpParagraph {
|
|
266
|
+
content?: HwpChar[];
|
|
267
|
+
}
|
|
268
|
+
interface HwpSection {
|
|
269
|
+
content?: HwpParagraph[];
|
|
270
|
+
}
|
|
271
|
+
interface HwpDoc {
|
|
272
|
+
sections?: HwpSection[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function finishHwp(buf: Buffer, base: BaseFn): ExtractDocumentResult {
|
|
276
|
+
if (typeof parseHwp !== 'function')
|
|
277
|
+
return base('unsupported', { bytes: buf.length, warnings: ['HWP 파서를 불러오지 못했습니다.'], unsupported: true });
|
|
278
|
+
try {
|
|
279
|
+
const doc = parseHwp(buf, { type: 'buffer' });
|
|
280
|
+
const text = hwpDocToText(doc);
|
|
281
|
+
if (!text.trim())
|
|
282
|
+
return base('hwp', {
|
|
283
|
+
bytes: buf.length,
|
|
284
|
+
warnings: ['HWP에서 본문 텍스트를 찾지 못했습니다(이미지 위주이거나 보호된 문서일 수 있음). PDF로 저장해 다시 시도하거나 텍스트로 붙여넣어 주세요.'],
|
|
285
|
+
unsupported: true,
|
|
286
|
+
});
|
|
287
|
+
return base('hwp', {
|
|
288
|
+
text,
|
|
289
|
+
bytes: buf.length,
|
|
290
|
+
warnings: ['HWP는 형식 특성상 표·머리말 등 일부 서식이 단순 텍스트로 펼쳐질 수 있습니다. 핵심 내용 위주로 활용하세요.'],
|
|
291
|
+
});
|
|
292
|
+
} catch (e) {
|
|
293
|
+
return base('unsupported', {
|
|
294
|
+
bytes: buf.length,
|
|
295
|
+
warnings: [`HWP를 읽지 못했습니다(${errMsg(e)}). 구버전·암호화·배포용 문서일 수 있습니다. PDF로 저장하거나 텍스트로 붙여넣어 주세요.`],
|
|
296
|
+
unsupported: true,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** sections → paragraphs → chars; CharType 0 (Char) carries the text. */
|
|
302
|
+
function hwpDocToText(doc: HwpDoc): string {
|
|
303
|
+
const lines: string[] = [];
|
|
304
|
+
for (const section of doc.sections ?? []) {
|
|
305
|
+
for (const para of section.content ?? []) {
|
|
306
|
+
let line = '';
|
|
307
|
+
for (const ch of para.content ?? []) {
|
|
308
|
+
if (typeof ch.value === 'string') line += ch.value;
|
|
309
|
+
else if (ch.type === 0 && typeof ch.value === 'number' && isPrintableCodePoint(ch.value))
|
|
310
|
+
// fromCodePoint (not fromCharCode) so supplementary-plane chars (emoji,
|
|
311
|
+
// rare CJK) survive instead of being truncated to their low 16 bits.
|
|
312
|
+
line += String.fromCodePoint(ch.value);
|
|
313
|
+
}
|
|
314
|
+
lines.push(line.trimEnd());
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return collapseBlankRuns(lines).join('\n').trim();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ------------------------------------------------------------------ hwpx */
|
|
321
|
+
|
|
322
|
+
function finishHwpx(buf: Buffer, base: BaseFn): ExtractDocumentResult {
|
|
323
|
+
try {
|
|
324
|
+
// Only decompress the body section XML — skipping embedded images/fonts/binData
|
|
325
|
+
// both speeds extraction and bounds a zip-bomb's expansion. Reject sections that
|
|
326
|
+
// declare (or together sum to) an absurd uncompressed size before inflating.
|
|
327
|
+
let total = 0;
|
|
328
|
+
const files = unzipSync(new Uint8Array(buf), {
|
|
329
|
+
filter: (f) => {
|
|
330
|
+
if (!/(^|\/)Contents\/section\d+\.xml$/i.test(f.name)) return false;
|
|
331
|
+
if (f.originalSize > MAX_HWPX_ENTRY_BYTES) throw new Error('HWPX 본문 항목이 비정상적으로 큽니다.');
|
|
332
|
+
total += f.originalSize;
|
|
333
|
+
if (total > MAX_HWPX_TOTAL_BYTES) throw new Error('HWPX 압축 해제 크기가 한도를 초과했습니다.');
|
|
334
|
+
return true;
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const sections = Object.keys(files).sort();
|
|
338
|
+
if (sections.length === 0)
|
|
339
|
+
return base('unsupported', { bytes: buf.length, warnings: ['HWPX 구조에서 본문(Contents/section*.xml)을 찾지 못했습니다.'], unsupported: true });
|
|
340
|
+
const lines: string[] = [];
|
|
341
|
+
for (const name of sections) {
|
|
342
|
+
const xml = strFromU8(files[name]);
|
|
343
|
+
for (const pm of xml.split(/<hp:p[\s>]/).slice(1)) {
|
|
344
|
+
const para = pm.split('</hp:p>')[0];
|
|
345
|
+
const runs = [...para.matchAll(/<hp:t[^>]*>([\s\S]*?)<\/hp:t>/g)].map((m) => decodeEntities(m[1]));
|
|
346
|
+
lines.push(runs.join('').trimEnd());
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const text = collapseBlankRuns(lines).join('\n').trim();
|
|
350
|
+
if (!text)
|
|
351
|
+
return base('hwpx', { bytes: buf.length, warnings: ['HWPX에서 본문 텍스트를 찾지 못했습니다. PDF로 저장하거나 텍스트로 붙여넣어 주세요.'], unsupported: true });
|
|
352
|
+
return base('hwpx', { text, bytes: buf.length });
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return base('unsupported', {
|
|
355
|
+
bytes: buf.length,
|
|
356
|
+
warnings: [`HWPX를 읽지 못했습니다(${errMsg(e)}). 파일이 손상되었거나 형식이 다를 수 있습니다.`],
|
|
357
|
+
unsupported: true,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ------------------------------------------------------------------- pdf */
|
|
363
|
+
|
|
364
|
+
async function finishPdf(buf: Buffer, base: BaseFn): Promise<ExtractDocumentResult> {
|
|
365
|
+
try {
|
|
366
|
+
// Lazy import: pdfjs is heavy; only load it when a PDF actually arrives.
|
|
367
|
+
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
368
|
+
// In the esbuild bundle, pdfjs's main is inlined into dist/*.mjs, so on load
|
|
369
|
+
// it auto-derives its worker path as `dist/pdf.worker.mjs` (next to the
|
|
370
|
+
// bundle) — which doesn't exist. We ship `dist/pdf.worker.min.mjs` instead
|
|
371
|
+
// (see scripts/build-dist.mjs), so override workerSrc to point at it. Must
|
|
372
|
+
// be unconditional: pdfjs has already self-set workerSrc by now. Under
|
|
373
|
+
// tsx/source pdfjs resolves its worker from node_modules, so leave it.
|
|
374
|
+
if (BUNDLED) {
|
|
375
|
+
try {
|
|
376
|
+
pdfjs.GlobalWorkerOptions.workerSrc = new URL('./pdf.worker.min.mjs', import.meta.url).href;
|
|
377
|
+
} catch {
|
|
378
|
+
/* keep pdfjs's own resolution */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const data = new Uint8Array(buf);
|
|
382
|
+
const task = pdfjs.getDocument({
|
|
383
|
+
data,
|
|
384
|
+
// Keep stdout clean (MCP protocol channel) and avoid optional asset fetches.
|
|
385
|
+
verbosity: 0,
|
|
386
|
+
useSystemFonts: false,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const pages: string[] = [];
|
|
390
|
+
let pageWarnings: string[] = [];
|
|
391
|
+
try {
|
|
392
|
+
const doc = await task.promise;
|
|
393
|
+
const limit = Math.min(doc.numPages, MAX_PDF_PAGES);
|
|
394
|
+
if (doc.numPages > MAX_PDF_PAGES)
|
|
395
|
+
pageWarnings = [
|
|
396
|
+
`페이지가 많아 앞 ${MAX_PDF_PAGES}쪽만 추출했습니다(총 ${doc.numPages}쪽). 나머지가 필요하면 해당 부분만 따로 저장하거나 텍스트로 붙여넣어 주세요.`,
|
|
397
|
+
];
|
|
398
|
+
for (let i = 1; i <= limit; i++) {
|
|
399
|
+
const page = await doc.getPage(i);
|
|
400
|
+
const tc = await page.getTextContent();
|
|
401
|
+
let line = '';
|
|
402
|
+
for (const item of tc.items as Array<{ str?: string; hasEOL?: boolean }>) {
|
|
403
|
+
line += item.str ?? '';
|
|
404
|
+
if (item.hasEOL) line += '\n';
|
|
405
|
+
}
|
|
406
|
+
pages.push(line.replace(/[ \t]+\n/g, '\n').trim());
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
// Always tear down the loading task (worker/transport + document), even if
|
|
410
|
+
// a page throws mid-parse — otherwise repeated PDF reads leak pdfjs resources.
|
|
411
|
+
// Best-effort: a teardown failure must not discard an already-successful extraction.
|
|
412
|
+
try {
|
|
413
|
+
await task.destroy();
|
|
414
|
+
} catch {
|
|
415
|
+
/* ignore teardown errors */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const text = collapseBlankRuns(pages.join('\n\n').split('\n')).join('\n').trim();
|
|
419
|
+
|
|
420
|
+
if (!text)
|
|
421
|
+
return base('pdf', {
|
|
422
|
+
bytes: buf.length,
|
|
423
|
+
warnings: [
|
|
424
|
+
'이 PDF에는 추출 가능한 텍스트 레이어가 없습니다(스캔본/이미지 PDF로 보임). 이미지를 직접 볼 수 있는 AI 클라이언트라면 PDF를 직접 열어 읽고, 아니면 내용을 텍스트로 붙여넣어 주세요.',
|
|
425
|
+
],
|
|
426
|
+
unsupported: true,
|
|
427
|
+
});
|
|
428
|
+
return base('pdf', { text, bytes: buf.length, warnings: pageWarnings });
|
|
429
|
+
} catch (e) {
|
|
430
|
+
return base('unsupported', {
|
|
431
|
+
bytes: buf.length,
|
|
432
|
+
warnings: [`PDF를 읽지 못했습니다(${errMsg(e)}). 암호화되었거나 손상된 PDF일 수 있습니다. 텍스트로 붙여넣어 주세요.`],
|
|
433
|
+
unsupported: true,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ---------------------------------------------------------------- helpers */
|
|
439
|
+
|
|
440
|
+
function collapseBlankRuns(lines: string[]): string[] {
|
|
441
|
+
const out: string[] = [];
|
|
442
|
+
let blanks = 0;
|
|
443
|
+
for (const l of lines) {
|
|
444
|
+
if (l.trim() === '') {
|
|
445
|
+
blanks++;
|
|
446
|
+
if (blanks <= 1) out.push('');
|
|
447
|
+
} else {
|
|
448
|
+
blanks = 0;
|
|
449
|
+
out.push(l);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
while (out.length && out[0] === '') out.shift();
|
|
453
|
+
while (out.length && out[out.length - 1] === '') out.pop();
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Keep printable code points; preserve tab. Drop other C0/C1 controls. Guard fromCodePoint's valid range. */
|
|
458
|
+
function isPrintableCodePoint(cp: number): boolean {
|
|
459
|
+
return cp === 9 || (cp >= 32 && cp <= 0x10ffff);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 완성형 한글 음절 수에서 대체문자(U+FFFD, 디코드 실패 신호)를 페널티로 뺀 점수. 인코딩 선택용. */
|
|
463
|
+
function koreanScore(s: string): number {
|
|
464
|
+
let n = 0;
|
|
465
|
+
for (let i = 0; i < s.length; i++) {
|
|
466
|
+
const c = s.charCodeAt(i);
|
|
467
|
+
if (c >= 0xac00 && c <= 0xd7a3) n++; // 완성형 한글 음절
|
|
468
|
+
else if (c === 0xfffd) n -= 2; // 깨진 디코드일수록 낮은 점수
|
|
469
|
+
}
|
|
470
|
+
return n;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Decode a text buffer, sniffing the encoding so Korean files aren't mangled:
|
|
475
|
+
* 1) honor a UTF-8 / UTF-16 BOM if present;
|
|
476
|
+
* 2) else try strict UTF-8 (the common, correct case — return as-is on success);
|
|
477
|
+
* 3) else (invalid UTF-8) decide between lenient UTF-8 and CP949/EUC-KR by which
|
|
478
|
+
* yields more Korean: a real CP949 résumé wins as CP949, while a mostly-UTF-8
|
|
479
|
+
* file with one stray byte wins as lenient UTF-8 (so it isn't turned to mojibake).
|
|
480
|
+
* Fixes the common "Notepad/Excel saved as ANSI(CP949)" case without breaking valid UTF-8.
|
|
481
|
+
*/
|
|
482
|
+
function decodeText(buf: Buffer): string {
|
|
483
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf)
|
|
484
|
+
return new TextDecoder('utf-8').decode(buf.subarray(3));
|
|
485
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
|
486
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
|
487
|
+
try {
|
|
488
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
489
|
+
} catch {
|
|
490
|
+
const lenient = new TextDecoder('utf-8').decode(buf); // U+FFFD 허용
|
|
491
|
+
let cp949: string | null = null;
|
|
492
|
+
try {
|
|
493
|
+
cp949 = new TextDecoder('euc-kr').decode(buf); // Node maps euc-kr → windows-949 (CP949 superset)
|
|
494
|
+
} catch {
|
|
495
|
+
return lenient; // euc-kr 미지원 빌드: lenient UTF-8로
|
|
496
|
+
}
|
|
497
|
+
return koreanScore(cp949) > koreanScore(lenient) ? cp949 : lenient; // 동점이면 UTF-8 우선
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function looksBinary(s: string): boolean {
|
|
502
|
+
if (!s) return false;
|
|
503
|
+
const sample = s.slice(0, 2000);
|
|
504
|
+
let control = 0;
|
|
505
|
+
for (let i = 0; i < sample.length; i++) {
|
|
506
|
+
const c = sample.charCodeAt(i);
|
|
507
|
+
if (c === 0) return true;
|
|
508
|
+
if (c < 9 || (c > 13 && c < 32)) control++;
|
|
509
|
+
}
|
|
510
|
+
return control / sample.length > 0.1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Library error → short, user-safe string. Strips URLs and library jargon links
|
|
515
|
+
* (e.g. mammoth/jszip's "…see https://stuk.github.io/jszip/…") and bounds length
|
|
516
|
+
* so the friendly Korean wrapper around it stays readable for non-developers.
|
|
517
|
+
*/
|
|
518
|
+
function errMsg(e: unknown): string {
|
|
519
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
520
|
+
return raw.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
521
|
+
}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* @careermate/parsers — extract clean text from pasted/uploaded content and
|
|
3
3
|
* normalize raw job postings.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* already
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Two layers:
|
|
6
|
+
* - `extractText` / `cleanJobPosting` (this file): pure, total helpers over text
|
|
7
|
+
* that is already in hand (pasted/HTML/markdown). Never throw.
|
|
8
|
+
* - `extractDocument` (./documents): reads a file from disk and extracts text
|
|
9
|
+
* from binary office formats (.docx/.hwp/.hwpx) that AI clients can't read
|
|
10
|
+
* themselves. PDF/images are deferred to the client's native file reading.
|
|
10
11
|
*/
|
|
11
12
|
import { stripHtml } from './html.ts';
|
|
12
13
|
import { extractTechKeywords } from './keywords.ts';
|
|
@@ -14,6 +15,8 @@ import { extractTechKeywords } from './keywords.ts';
|
|
|
14
15
|
export { stripHtml, decodeEntities } from './html.ts';
|
|
15
16
|
export { extractTechKeywords, TECH_TERMS } from './keywords.ts';
|
|
16
17
|
export type { TechTerm } from './keywords.ts';
|
|
18
|
+
export { extractDocument, resolveDocumentPath } from './documents.ts';
|
|
19
|
+
export type { ExtractDocumentResult, DocumentFormat } from './documents.ts';
|
|
17
20
|
|
|
18
21
|
/* ----------------------------------------------------------------- extractText */
|
|
19
22
|
|
|
@@ -17,14 +17,14 @@ export const ONBOARDING_PROMPT = `이제 CareerMate에 연결되었습니다.
|
|
|
17
17
|
- 이름, 연락처, 한 줄 소개(headline), 자기소개 요약(summary), 희망 직무(desired_roles), 희망 근무 조건(desired_conditions)을 정리합니다.
|
|
18
18
|
- 글쓰기 선호도 함께 물어보세요: 선호 문체(preferred_tone, 예: "담백하고 구체적")와 강조하고 싶은 핵심 포인트(emphasis_points). 이 정보는 이후 자기소개서 작성에 그대로 활용됩니다.
|
|
19
19
|
- 정리한 내용을 \`save_profile\`로 저장하세요. 파일에서 읽어온 정보는 source를 'upload', 사용자가 말로 알려준 정보는 'manual'로 표시하세요.
|
|
20
|
-
- 경력·프로젝트·스킬 정보가 있으면
|
|
20
|
+
- 경력·프로젝트·스킬 정보가 있으면 \`add_experience\`·\`add_project\`·\`add_skill\`로 각각 저장하세요(경력 achievements는 가능하면 정량 지표 포함). 이 항목들은 온보딩 완성도(has_experience/has_skills)에 반영됩니다.
|
|
21
21
|
- 확실하지 않은 정보는 지어내지 말고 사용자에게 확인하세요.
|
|
22
22
|
|
|
23
23
|
# 3단계 — 이력서 등록
|
|
24
24
|
- 업로드한 이력서/경력기술서/포트폴리오 본문을 \`add_resume\`로 저장하세요. kind(resume/career_description/portfolio/other)와 title을 적절히 지정하고, 대표 문서는 is_primary를 true로 설정하세요. source는 'upload'를 사용하세요.
|
|
25
25
|
|
|
26
26
|
# 4단계 — 자기소개서 등록 (있는 경우)
|
|
27
|
-
- 기존 자기소개서가 있으면 \`
|
|
27
|
+
- 기존 자기소개서가 있으면 \`save_cover_letter_version\`으로 저장하세요. 제목(title)과 본문(content)을 넣으면 v1로 저장되고, 특정 공고용이면 job_id를 연결하세요.
|
|
28
28
|
- 없으면 "나중에 공고를 분석한 뒤 맞춤 자기소개서를 써드릴 수 있어요"라고 안내만 하세요.
|
|
29
29
|
|
|
30
30
|
# 5단계 — 대시보드 열기 & 다음 단계
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time constants injected by esbuild's `define` in the dist bundle
|
|
3
|
+
* (scripts/build-dist.mjs): `__APP_VERSION__` = package.json version, and
|
|
4
|
+
* `__BUNDLED__` = true inside the bundle. Under tsx/source runs they're
|
|
5
|
+
* undefined, so the `typeof` guards fall back to dev defaults.
|
|
6
|
+
*
|
|
7
|
+
* Single source of truth so this idiom isn't copy-pasted across every
|
|
8
|
+
* entrypoint (mcp/web/doctor) and package.
|
|
9
|
+
*
|
|
10
|
+
* MUST stay dependency-free and side-effect-free: @careermate/shared re-exports
|
|
11
|
+
* it, so every shared consumer evaluates this module at import time. The
|
|
12
|
+
* injected tokens are only ever read inside `typeof` guards (never bare), so
|
|
13
|
+
* evaluation can't throw even where the define isn't applied.
|
|
14
|
+
*/
|
|
15
|
+
declare const __APP_VERSION__: string | undefined;
|
|
16
|
+
declare const __BUNDLED__: boolean | undefined;
|
|
17
|
+
|
|
18
|
+
/** App version: package.json version in the bundle, `0.0.0-dev` under source/tsx. */
|
|
19
|
+
export const APP_VERSION: string = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev';
|
|
20
|
+
|
|
21
|
+
/** True only when running from the esbuild dist bundle (vs tsx source). */
|
|
22
|
+
export const BUNDLED: boolean = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
|
|
@@ -51,6 +51,45 @@ export const INTERVIEW_UNLOCK_STATUSES: ApplicationStatus[] = [
|
|
|
51
51
|
'final_passed',
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Allowed application-status transitions (server-enforced lifecycle).
|
|
56
|
+
*
|
|
57
|
+
* Design note: CareerMate lets the user's AI record where an application *really*
|
|
58
|
+
* is, so `draft` (the initial, untracked state) may jump to ANY real-world stage.
|
|
59
|
+
* Once an application is being tracked, it may move forward, be corrected
|
|
60
|
+
* backward among active stages, be parked (`on_hold`) or closed (`rejected`) from
|
|
61
|
+
* anywhere, and be reopened/resumed from those terminal states. The one move we
|
|
62
|
+
* forbid is regressing an *active* application back to `draft` ("untracked"),
|
|
63
|
+
* which would silently drop its lifecycle history. The hard spec invariant
|
|
64
|
+
* (interview prep requires `document_passed`+) is enforced separately at the
|
|
65
|
+
* service layer via {@link INTERVIEW_UNLOCK_STATUSES}.
|
|
66
|
+
*/
|
|
67
|
+
export const ALLOWED_STATUS_TRANSITIONS: Record<ApplicationStatus, ApplicationStatus[]> = {
|
|
68
|
+
draft: ['planned', 'applied', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
|
|
69
|
+
planned: ['applied', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
|
|
70
|
+
applied: ['planned', 'document_passed', 'interview', 'final_passed', 'rejected', 'on_hold'],
|
|
71
|
+
document_passed: ['planned', 'applied', 'interview', 'final_passed', 'rejected', 'on_hold'],
|
|
72
|
+
interview: ['planned', 'applied', 'document_passed', 'final_passed', 'rejected', 'on_hold'],
|
|
73
|
+
final_passed: ['document_passed', 'interview', 'rejected', 'on_hold'],
|
|
74
|
+
rejected: ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold'],
|
|
75
|
+
on_hold: ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'rejected'],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Whether moving from `from` to `to` is a permitted status transition (same→same is always allowed). */
|
|
79
|
+
export function canTransitionStatus(from: ApplicationStatus, to: ApplicationStatus): boolean {
|
|
80
|
+
if (from === to) return true;
|
|
81
|
+
return ALLOWED_STATUS_TRANSITIONS[from]?.includes(to) ?? false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Throw a clear, user-facing (Korean) error when a status transition is not allowed. */
|
|
85
|
+
export function assertStatusTransition(from: ApplicationStatus, to: ApplicationStatus): void {
|
|
86
|
+
if (!canTransitionStatus(from, to)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`'${APPLICATION_STATUS_LABELS[from]}'에서 '${APPLICATION_STATUS_LABELS[to]}'(으)로는 상태를 바꿀 수 없습니다.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
54
93
|
/** Stored document kinds (resumes and career descriptions). Cover letters live in their own tables. */
|
|
55
94
|
export const DOCUMENT_KINDS = ['resume', 'career_description', 'portfolio', 'other'] as const;
|
|
56
95
|
export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
|