@kaydash9999/banker-plugins 0.27.0 → 0.28.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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +17 -5
- package/codex/manifest.json +3 -0
- package/package.json +1 -1
- package/skills/video-gen/SKILL.md +141 -0
- package/skills/video-gen/assets/example.json +36 -0
- package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
- package/skills/video-gen/scripts/vg-deck.mjs +72 -0
- package/skills/video-gen/scripts/vg-draw.mjs +231 -0
- package/skills/video-gen/scripts/vg-engine.mjs +74 -0
- package/skills/video-gen/scripts/vg-fx.mjs +106 -0
- package/skills/video-gen/scripts/vg-model.mjs +236 -0
- package/skills/video-gen/scripts/vg-render.mjs +151 -0
- package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
- package/skills/video-gen/scripts/video-gen.mjs +212 -0
- package/skills/video-gen/vendor/README.md +25 -0
- package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
- package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
- package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
- package/skills/wbs-gen/SKILL.md +127 -0
- package/skills/wbs-gen/assets/example.json +58 -0
- package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
- package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
- package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
- package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
- package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
- package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
- package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
- package/skills/wide-pptx/SKILL.md +121 -0
- package/skills/wide-pptx/assets/example.json +63 -0
- package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
- package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
- package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
- package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
- package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
- package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
- package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
- package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
- package/skills/wide-pptx/vendor/README.md +12 -0
- package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
- package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wbs-gen
|
|
3
|
+
description: "WBS(작업 분해 구조)를 간트 차트 한 장으로 만들어 XLSX 와 HTML 로 냄. 막대는 셀 채움(조건부 서식 없음), 영업일 열만, 주 단위 머리글, 파트별 색, 마름모 마일스톤, 빨간 게이트 선, 범례와 메모. 공휴일은 인터넷에 닿으면 Google 공개 달력에서 받아 확인을 받고, 망분리 환경이면 사용자에게 'M/D, M/D' 로 받음. 순수 Node 도우미라 설치와 내려받기 없이 오프라인에서 동작. 'wbs-gen'/'WBS 간트'/'간트 차트 만들어'/'일정표 xlsx'/'WBS 일정표' 시 사용."
|
|
4
|
+
argument-hint: "[--xlsx] [--html] [--dir <폴더>] [--plan <plan.json>]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# wbs-gen: WBS 간트 차트(XLSX, HTML)
|
|
8
|
+
|
|
9
|
+
WBS 와 일정을 받아 간트 차트 한 장을 XLSX 와 HTML 로 만든다. 답변은 한글.
|
|
10
|
+
모양은 엑셀로 직접 그린 한 장짜리 간트와 같다: 1행 제목, 2~3행 머리글(8열 + 주와 날짜), 틀 고정 8열 3행, A3 가로 한 장 너비 인쇄.
|
|
11
|
+
|
|
12
|
+
## 원칙
|
|
13
|
+
|
|
14
|
+
- **설치와 내려받기가 없다.** 도우미는 Node 표준 모듈만 쓴다(XLSX 도 직접 씀). 인터넷을 쓰는 곳은 `holidays` 하나이고, 닿지 않으면 사용자에게 묻는다.
|
|
15
|
+
- **막대는 셀 채움이다.** 조건부 서식과 수식을 쓰지 않아 어느 뷰어에서나 같게 보인다. 일정이 바뀌면 계획 파일을 고치고 다시 만든다.
|
|
16
|
+
- **영업일은 도우미가 센다.** 토, 일과 공휴일을 뺀 날만 날짜 열이 되고, 일수 칸도 그 수다. 사람이 적은 일수를 옮기지 않는다.
|
|
17
|
+
- **WBS 와 일정은 나누어 적는다.** WBS 번호와 작업 이름은 산출물이고, 시작, 종료, 선행은 일정이다. 제약사항 같은 메모 행은 WBS 번호 체계 밖 묶음(예: `C`, `P`)으로 둔다.
|
|
18
|
+
- 글꼴은 한글이 Noto Sans KR, 그 밖(영문, 숫자, 기호)이 Noto Sans 다. XLSX 는 한 칸 안에서 글자 종류마다 글꼴을 나눠 적고, HTML 은 `"Noto Sans","Noto Sans KR"` 순서로 지정한다. 글꼴 파일은 넣지 않으므로 보는 PC 에 두 글꼴이 있어야 그대로 보인다.
|
|
19
|
+
- 있는 파일을 덮어쓰지 않는다. 같은 이름이 있으면 묻고, 허락을 받은 뒤에만 `--force` 를 준다.
|
|
20
|
+
- 계획 파일과 출력에 비밀값을 넣지 않는다.
|
|
21
|
+
|
|
22
|
+
## 런타임
|
|
23
|
+
|
|
24
|
+
| 항목 | Claude Code | Codex |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 스킬 디렉터리 `<S>` | 스킬 맨 위 "Base directory for this skill:" 경로 | 이 SKILL.md 의 폴더. npm 설치는 `~/.codex/skills/banker-wbs-gen/`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/wbs-gen/`. 절대경로로 씀 |
|
|
27
|
+
| 부르기 | `/banker:wbs-gen` | `$banker-wbs-gen`, 플러그인 `$banker:wbs-gen` |
|
|
28
|
+
| 확인 질문 | `AskUserQuestion` | `request_user_input`. 없으면 번호 붙인 평문 선택지 |
|
|
29
|
+
| 샌드박스 | 없음 | `holidays` 는 네트워크를 쓰므로 `sandbox_permissions: "require_escalated"` 로 승인을 청함. 승인이 없거나 거절되면 망분리와 같이 사용자에게 공휴일을 받음. 나머지 명령은 작업 폴더 안에서만 읽고 써서 승인이 필요 없음 |
|
|
30
|
+
| 셸 | bash | Windows 는 PowerShell. 도우미는 Node 라 두 셸에서 같다 |
|
|
31
|
+
|
|
32
|
+
물을 수 없는 실행(`codex exec`, 다른 스킬의 연쇄)에서는 공휴일을 묻지 못한다. `holidays` 가 3 으로 끝나면 공휴일 없이 만들고 보고에 `공휴일 미확인` 을 적는다.
|
|
33
|
+
|
|
34
|
+
## 인자
|
|
35
|
+
|
|
36
|
+
| 인자 | 뜻 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `--xlsx`, `--html` | 낼 형식. 둘 다 없으면 둘 다 냄 |
|
|
39
|
+
| `--dir <폴더>` | 출력 폴더. 없으면 지금 작업 폴더. 도우미의 `--out <파일>` 과 다름 |
|
|
40
|
+
| `--plan <plan.json>` | 이미 있는 계획 파일을 씀. 없으면 대화와 자료로 새로 씀 |
|
|
41
|
+
|
|
42
|
+
## 도우미
|
|
43
|
+
|
|
44
|
+
`G="<S>/scripts/wbs-gen.mjs"`. 머리 주석이 전체 사용법이다.
|
|
45
|
+
|
|
46
|
+
| 명령 | 하는 일 | 종료 코드 |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `node "$G" example` | 예시 계획(JSON)을 냄. 계획 파일의 틀로 씀 | 0 |
|
|
49
|
+
| `node "$G" holidays --from <YYYY-MM-DD> --to <YYYY-MM-DD>` | 기간 안 한국 공휴일을 `MM/DD 이름` 줄과 마지막 줄 `HOLIDAYS MM/DD, ...` 로 냄. 기간이 두 해에 걸치면 `YYYY-MM-DD` 로 냄. 기념일(어버이날 등)은 빼고 공휴일만 | 0, 인자나 `--ics` 파일 오류 2, 닿지 못하거나 공휴일을 읽지 못하면 3 |
|
|
50
|
+
| `node "$G" check --plan <plan.json>` | 계획을 검사하고 `OK 영업일 N ...` 한 줄을 냄 | 0, 계획 오류 1 |
|
|
51
|
+
| `node "$G" build --plan <plan.json> --out <파일.xlsx> --out <파일.html>` | 파일을 만듦. 쓰기 전에 모든 `--out` 을 검사하고, 하나라도 이미 있으면 `--force` 없이는 아무것도 쓰지 않고 2 로 끝남 | 0, 계획 오류 1, 사용법과 파일 오류 2 |
|
|
52
|
+
|
|
53
|
+
- `--holidays "8/17, 10/5"` 는 계획의 `holidays` 를 그 값으로 바꾼다. `check`, `build` 둘 다 받는다.
|
|
54
|
+
- `holidays --ics <파일>` 은 저장해 둔 달력 파일(.ics)을 읽는다. 사내망에서 다른 경로로 받은 달력을 쓸 때다. `--url <https 주소>` 는 같은 꼴의 다른 달력 주소, `--timeout <초>` 는 1~60 정수(기본 10)다.
|
|
55
|
+
|
|
56
|
+
## 계획 파일(plan.json)
|
|
57
|
+
|
|
58
|
+
`node "$G" example` 이 낸 틀을 고쳐 쓴다. 날짜는 `YYYY-MM-DD` 나 `M/D` 이고, `M/D` 는 프로젝트 기간 안의 해로 읽는다(12/20~01/15 이면 `1/5` 는 다음 해).
|
|
59
|
+
기간이 1년을 넘어 같은 `M/D` 가 두 해에 들면 오류다. 그때는 `YYYY-MM-DD` 로 적는다. 기간은 5년까지이고, 그보다 길면 단계나 해로 나누어 만든다.
|
|
60
|
+
|
|
61
|
+
| 칸 | 뜻 |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `title`, `subtitle` | 1행 제목과 메모. 제목 줄에 기간, 영업일 수가 함께 들어감 |
|
|
64
|
+
| `start`, `end` | 기간. `YYYY-MM-DD` |
|
|
65
|
+
| `holidays` | 공휴일. `"8/17, 10/5"` 같은 글이나 배열. `"없음"` 은 없음 |
|
|
66
|
+
| `gates` | `[{ "date", "label" }]`. 그 날짜 열 왼쪽에 빨간 수직선 |
|
|
67
|
+
| `parts` | `{ "<id>": { "name", "light", "dark", "legend" } }`. `light` 는 묶음 행 바탕, `dark` 는 막대. 색은 6자리 16진수. `legend: false` 면 범례에서 뺌 |
|
|
68
|
+
| `bars` | 우선순위별 막대 색. 예: `{ "선택": "FAD7A0", "준비": "D5DBDB", "마무리": "34495E" }`. 파트 색보다 앞섬 |
|
|
69
|
+
| `rows` | 행 목록. 아래 표 |
|
|
70
|
+
| `legend` | 범례에 더할 항목 `[{ "color", "label", "dotted" }]`. 파트 색, ◆, 게이트 선은 자동으로 들어감 |
|
|
71
|
+
| `notes` | 아래 메모 `[{ "heading", "lines" }]`. 예: `[우선순위]`, `[미확정]`, `[가정·변경]` |
|
|
72
|
+
| `sheet` | 시트 이름. 없으면 `WBS 간트`. Excel 이 막는 글자(`[]:*?/\`), 앞뒤 `'`, `History` 는 바꿈 |
|
|
73
|
+
| `milestoneLegend`, `milestoneLabel` | 범례의 ◆ 항목. `false` 면 뺌, 이름 기본값은 `마일스톤` |
|
|
74
|
+
|
|
75
|
+
`rows` 의 `kind`
|
|
76
|
+
|
|
77
|
+
| kind | 쓰는 칸 | 그림 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `group` | `wbs`, `task`, `owner`, `part` | 행 전체를 파트의 연한 색으로 칠함 |
|
|
80
|
+
| `task` | `wbs`, `task`, `owner`, `priority`, `pending`, `start`, `end`, `part`, `bar` | 시작부터 종료까지 막대. 일수 칸은 그 사이 영업일 수 |
|
|
81
|
+
| `constraint` | `task` 칸들과 `date`(또는 `start`, `end`) | 종료 날짜에 ◆ 와 점선 테두리. 개발이 아닌 외부 선행 |
|
|
82
|
+
| `milestone` | `wbs`, `task`, `date` | 그 날짜에 ◆ |
|
|
83
|
+
|
|
84
|
+
시작, 종료, 마일스톤, 게이트 날짜가 주말이나 공휴일이면 오류다. 기간 중간의 공휴일은 그냥 열에서 빠진다.
|
|
85
|
+
|
|
86
|
+
## 절차
|
|
87
|
+
|
|
88
|
+
### 1. 자료 모으기
|
|
89
|
+
- 사용자가 준 WBS(표, 문서, 기존 엑셀, 말로 한 설명)를 읽는다. 기존 xlsx 는 읽기만 하고 고치지 않는다.
|
|
90
|
+
- 아래가 없으면 한 번에 묻는다: 기간(시작, 종료), 작업 목록과 담당, 각 작업의 시작과 종료, 마일스톤, 게이트. 파트 구분과 색은 묻지 않고 예시 색을 쓴다.
|
|
91
|
+
- 날짜가 정해지지 않은 작업은 `pending` 칸에 기다리는 것을 적고, 가정한 날짜는 `notes` 의 `[가정·변경]` 에 적는다.
|
|
92
|
+
|
|
93
|
+
### 2. 공휴일
|
|
94
|
+
1. `node "$G" holidays --from <start> --to <end>` 를 돈다.
|
|
95
|
+
2. 종료 코드가 0 이면 나온 목록을 보여 주고 확인 질문으로 묻는다: 이대로, 더하거나 빼기(그다음 `M/D, M/D` 를 받음).
|
|
96
|
+
3. 종료 코드가 3 이면(인터넷에 닿지 못함, 망분리, 프록시, Codex 승인 거절) 사용자에게 기간 안 공휴일을 `M/D, M/D` 로 받는다. 없으면 `없음`.
|
|
97
|
+
4. 확정한 값을 계획의 `holidays` 에 적는다. 회사 휴무일처럼 공휴일이 아닌 쉬는 날도 여기에 더한다.
|
|
98
|
+
|
|
99
|
+
### 3. 계획 파일 쓰기
|
|
100
|
+
- 출력 폴더에 `<이름>-wbs.json` 으로 쓴다. 이름은 제목에서 짧게 만든다. 같은 이름이 있으면 묻는다.
|
|
101
|
+
- `node "$G" check --plan <파일>` 이 0 이 될 때까지 고친다. 오류 줄은 어느 행의 무슨 칸인지 알려 준다. 경고(기간 밖 공휴일)는 보고에만 적는다.
|
|
102
|
+
|
|
103
|
+
### 4. 만들기
|
|
104
|
+
- `node "$G" build --plan <파일> --out <이름>-wbs.xlsx --out <이름>-wbs.html` 을 돈다. 형식 인자를 받았으면 그 형식만 준다.
|
|
105
|
+
- 종료 코드 2 에 `--force` 안내가 나오면 덮어쓸지 묻고, 허락을 받은 뒤에만 `--force` 를 더해 다시 돈다.
|
|
106
|
+
|
|
107
|
+
### 5. 확인
|
|
108
|
+
- `build` 출력의 `OK 영업일 N (시작 ~ 끝), 주 W, 행 R, 작업 T, ◆ M, 게이트 G` 를 계획과 대조한다. 영업일 수는 기간과 공휴일로 맞는지, 작업 수와 ◆ 수가 계획 행 수와 맞는지 본다.
|
|
109
|
+
- 브라우저를 쓸 수 있으면 HTML 을 열어 한 번 본다. 쓸 수 없으면 보았다고 적지 않는다.
|
|
110
|
+
|
|
111
|
+
## 보고
|
|
112
|
+
|
|
113
|
+
- 만든 파일의 절대경로
|
|
114
|
+
- 기간, 영업일 수, 뺀 공휴일(`MM/DD 이름`, 사용자가 입력했으면 입력값)
|
|
115
|
+
- 행 수, 작업 수, 마일스톤 수, 게이트
|
|
116
|
+
- 가정한 날짜와 확인하지 못한 것(`공휴일 미확인`, 브라우저로 보지 않음 등)
|
|
117
|
+
- 다음 행동이 있으면 마지막 줄에. 예: "일정이 바뀌면 `<이름>-wbs.json` 을 고치고 다시 부르십시오."
|
|
118
|
+
|
|
119
|
+
## 함정
|
|
120
|
+
|
|
121
|
+
- `holidays` 가 0 으로 끝나도 목록을 그대로 믿지 않는다. 임시공휴일이 늦게 들어갈 수 있으니 늘 사용자 확인을 받는다.
|
|
122
|
+
- 토요일 공휴일(예: 08/15)도 목록에 나온다. 원래 날짜 열에 없으므로 그대로 두어도 된다.
|
|
123
|
+
- 막대를 셀 채움으로 그렸으므로 엑셀에서 날짜 칸만 고치면 막대가 따라가지 않는다. 일정은 계획 파일에서 고친다.
|
|
124
|
+
- 보는 PC 에 Noto Sans KR, Noto Sans 가 없으면 다른 글꼴로 보인다. 글꼴 없이 한글을 그리지 못하는 뷰어(글꼴이 없는 macOS 의 LibreOffice 등)는 한글을 비워 둘 수 있으나 파일에는 글이 들어 있다.
|
|
125
|
+
- ◆ 는 Noto Sans 에 없어서 Noto Sans KR 로 적는다.
|
|
126
|
+
- 주 번호(W1, W2)는 달력 주를 센다. 영업일이 하나도 없는 주는 열이 없고 번호만 건너뛴다.
|
|
127
|
+
- 게이트가 주 중간이면 병합된 주 머리글 칸에는 빨간 선이 보이지 않는다. 날짜 머리글과 본문에는 보인다.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "샘플 대시보드 2차 개발 WBS 간트",
|
|
3
|
+
"subtitle": "범위 미정, 요건과 UI 확정(08/03) 뒤 단계적 상세화",
|
|
4
|
+
"start": "2026-07-22",
|
|
5
|
+
"end": "2026-08-28",
|
|
6
|
+
"holidays": "8/17",
|
|
7
|
+
"sheet": "WBS 간트",
|
|
8
|
+
"gates": [{ "date": "08/03", "label": "요건·UI 확정 게이트" }],
|
|
9
|
+
"parts": {
|
|
10
|
+
"plan": { "name": "기획", "light": "D6EAF8", "dark": "5DADE2" },
|
|
11
|
+
"data": { "name": "데이터 리서치", "light": "D4EFDF", "dark": "27AE60" },
|
|
12
|
+
"eng": { "name": "데이터 엔지니어링", "light": "D4E6F1", "dark": "2E86C1" },
|
|
13
|
+
"be": { "name": "백엔드", "light": "FAE5D3", "dark": "E67E22" },
|
|
14
|
+
"fe": { "name": "프론트엔드", "light": "FADBD8", "dark": "E74C3C" },
|
|
15
|
+
"ai": { "name": "AI 에이전트", "light": "E8DAEF", "dark": "9B59B6" },
|
|
16
|
+
"ops": { "name": "공통", "light": "D6DBDF", "dark": "34495E", "legend": false }
|
|
17
|
+
},
|
|
18
|
+
"bars": { "선택": "FAD7A0", "준비": "D5DBDB", "마무리": "34495E" },
|
|
19
|
+
"rows": [
|
|
20
|
+
{ "kind": "group", "wbs": "C", "task": "제약사항", "owner": "타 부서", "part": "ops" },
|
|
21
|
+
{ "kind": "constraint", "wbs": "C-1", "task": "기획 담당자 확정", "owner": "기획부", "priority": "선행", "pending": "미해소 시 요건 확정 불가", "date": "07/24" },
|
|
22
|
+
{ "kind": "constraint", "wbs": "C-2", "task": "UI/UX 디자인 확정", "owner": "디자인팀", "priority": "선행", "pending": "프론트 재디자인 선행", "date": "08/03" },
|
|
23
|
+
{ "kind": "constraint", "wbs": "C-3", "task": "데이터 원천 확보", "owner": "영업부", "priority": "선행", "pending": "실적 지표 개발 선행", "date": "08/07" },
|
|
24
|
+
{ "kind": "group", "wbs": "P", "task": "병행 트랙", "owner": "전 파트", "part": "ops" },
|
|
25
|
+
{ "kind": "task", "wbs": "P-1", "task": "기준선 대비 변경 추적", "owner": "전 파트", "priority": "준비", "pending": "요건 비종속", "start": "07/22", "end": "08/03" },
|
|
26
|
+
{ "kind": "task", "wbs": "P-2", "task": "시험 도구 준비", "owner": "전 파트", "priority": "준비", "pending": "요건 비종속", "start": "07/28", "end": "08/07" },
|
|
27
|
+
{ "kind": "group", "wbs": "1", "task": "기획", "owner": "기획부", "part": "plan" },
|
|
28
|
+
{ "kind": "task", "wbs": "1.1", "task": "요건·UI 확정", "owner": "홍길동", "priority": "필수", "pending": "화면 항목과 진입점 결정", "start": "07/27", "end": "08/03", "part": "plan" },
|
|
29
|
+
{ "kind": "group", "wbs": "2", "task": "데이터 리서치", "owner": "데이터팀", "part": "data" },
|
|
30
|
+
{ "kind": "task", "wbs": "2.1", "task": "항목별 데이터 추출 매핑", "owner": "김철수", "priority": "확정예정", "pending": "항목 확정", "start": "08/04", "end": "08/07", "part": "data" },
|
|
31
|
+
{ "kind": "task", "wbs": "2.2", "task": "계열사 종합 수치", "owner": "김철수", "priority": "선택", "pending": "계열사 마트 제공", "start": "08/10", "end": "08/14", "part": "data" },
|
|
32
|
+
{ "kind": "group", "wbs": "3", "task": "데이터 엔지니어링", "owner": "엔지니어링팀", "part": "eng" },
|
|
33
|
+
{ "kind": "task", "wbs": "3.1", "task": "마트와 파이프라인 변경", "owner": "이영희", "priority": "확정예정", "pending": "데이터 원천 확인", "start": "08/10", "end": "08/14", "part": "eng" },
|
|
34
|
+
{ "kind": "group", "wbs": "4", "task": "백엔드", "owner": "백엔드팀", "part": "be" },
|
|
35
|
+
{ "kind": "task", "wbs": "4.1", "task": "API 리팩터링", "owner": "박민수", "priority": "확정예정", "pending": "화면 항목 확정", "start": "08/04", "end": "08/14", "part": "be" },
|
|
36
|
+
{ "kind": "group", "wbs": "5", "task": "프론트엔드", "owner": "프론트팀", "part": "fe" },
|
|
37
|
+
{ "kind": "task", "wbs": "5.1", "task": "대시보드 웹 개발", "owner": "최지우", "priority": "확정예정", "pending": "항목, 진입점, UI 확정", "start": "08/04", "end": "08/14", "part": "fe" },
|
|
38
|
+
{ "kind": "group", "wbs": "6", "task": "AI 에이전트", "owner": "솔루션팀", "part": "ai" },
|
|
39
|
+
{ "kind": "task", "wbs": "6.1", "task": "에이전트 변경 반영", "owner": "정하늘", "priority": "확정예정", "pending": "항목 확정", "start": "08/06", "end": "08/14", "part": "ai" },
|
|
40
|
+
{ "kind": "group", "wbs": "7", "task": "배포", "owner": "전 파트", "part": "ops" },
|
|
41
|
+
{ "kind": "task", "wbs": "7.1", "task": "통합 점검", "owner": "전 파트", "priority": "마무리", "pending": "-", "start": "08/18", "end": "08/21" },
|
|
42
|
+
{ "kind": "task", "wbs": "7.2", "task": "운영망 배포", "owner": "전 파트", "priority": "마무리", "pending": "-", "start": "08/28", "end": "08/28" },
|
|
43
|
+
{ "kind": "group", "wbs": "M", "task": "마일스톤", "part": "ops" },
|
|
44
|
+
{ "kind": "milestone", "wbs": "M0", "task": "착수", "date": "07/22" },
|
|
45
|
+
{ "kind": "milestone", "wbs": "M1", "task": "요건·UI 확정", "date": "08/03" },
|
|
46
|
+
{ "kind": "milestone", "wbs": "M2", "task": "개발 완료", "date": "08/14" },
|
|
47
|
+
{ "kind": "milestone", "wbs": "M3", "task": "서비스 오픈", "date": "08/28" }
|
|
48
|
+
],
|
|
49
|
+
"legend": [
|
|
50
|
+
{ "color": "BDC3C7", "label": "선행 항목(◆, 점선 = 개발 아님, 외부 선행)", "dotted": true },
|
|
51
|
+
{ "color": "D5DBDB", "label": "준비 트랙(요건 비종속, 바로 병행)" },
|
|
52
|
+
{ "color": "FAD7A0", "label": "선택 = 마감 위험 시 먼저 제외" }
|
|
53
|
+
],
|
|
54
|
+
"notes": [
|
|
55
|
+
{ "heading": "[우선순위]", "lines": ["필수, 권장, 선택(마감 위험 시 먼저 제외), 확정예정(범위 확정 뒤 지정)"] },
|
|
56
|
+
{ "heading": "[가정·변경]", "lines": ["범위 전면 미정: 자리표시 항목, 요건 확정(08/03) 뒤 상세화"] }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wbs-gen.mjs: the helper the wbs-gen skill runs. It turns a WBS plan (JSON) into a Gantt sheet as XLSX and/or a
|
|
4
|
+
* self-contained HTML file. Pure Node, no dependencies; only `holidays` uses the network.
|
|
5
|
+
*
|
|
6
|
+
* node wbs-gen.mjs example print a sample plan (JSON)
|
|
7
|
+
* node wbs-gen.mjs holidays --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--timeout <s>] [--ics <file> | --url <https URL>]
|
|
8
|
+
* node wbs-gen.mjs check --plan <plan.json> [--holidays "<M/D, M/D>"]
|
|
9
|
+
* node wbs-gen.mjs build --plan <plan.json> --out <file.xlsx|file.html>... [--holidays "<M/D, M/D>"] [--force]
|
|
10
|
+
*
|
|
11
|
+
* - holidays prints `MM/DD 이름` lines and a last line `HOLIDAYS MM/DD, MM/DD` (`HOLIDAYS 없음` when there are none)
|
|
12
|
+
* from Google's public Korean holiday calendar (공휴일 only, not 기념일). --ics reads a saved calendar file instead;
|
|
13
|
+
* --url reads another iCalendar URL in the same form (a mirror).
|
|
14
|
+
* - --holidays replaces the plan's holidays; it takes 'M/D' or 'YYYY-MM-DD' items, or 없음.
|
|
15
|
+
* - build checks every --out first (kind, duplicates, folder, existing files) and makes every output before it
|
|
16
|
+
* writes any, each through a random temporary
|
|
17
|
+
* name: a hard link (or an exclusive copy) puts it in place, so a file that appears meanwhile is never replaced;
|
|
18
|
+
* --force renames over an existing file instead.
|
|
19
|
+
* - holidays prints MM/DD within one year and YYYY-MM-DD when --from and --to are in different years.
|
|
20
|
+
*
|
|
21
|
+
* Exit status: 0 done, 1 the plan has errors, 2 usage error or a file that could not be read or written,
|
|
22
|
+
* 3 the holiday calendar could not be reached (ask the user for the holidays instead).
|
|
23
|
+
*/
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { constants, copyFileSync, existsSync, linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, realpathSync } from "node:fs";
|
|
26
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { keyMs, mmdd } from "./wg-dates.mjs";
|
|
29
|
+
import { fetchText, ICS_URL, parseIcs, publicHolidays } from "./wg-holidays.mjs";
|
|
30
|
+
import { toHtml } from "./wg-html.mjs";
|
|
31
|
+
import { layout } from "./wg-plan.mjs";
|
|
32
|
+
import { toXlsx } from "./wg-xlsx.mjs";
|
|
33
|
+
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const EXAMPLE = join(HERE, "..", "assets", "example.json");
|
|
36
|
+
|
|
37
|
+
class Usage extends Error {}
|
|
38
|
+
|
|
39
|
+
const isDir = (p) => {
|
|
40
|
+
try {
|
|
41
|
+
return statSync(p).isDirectory();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Reads --name value pairs; flags listed in bools take no value; --out may repeat. */
|
|
48
|
+
export function parseArgs(argv, bools = ["force"]) {
|
|
49
|
+
const out = { _: [], out: [] };
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (!a.startsWith("--")) {
|
|
53
|
+
out._.push(a);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const name = a.slice(2);
|
|
57
|
+
if (bools.includes(name)) {
|
|
58
|
+
out[name] = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const v = argv[++i];
|
|
62
|
+
if (v === undefined || v.startsWith("--")) throw new Usage(`--${name} 에 값이 없음`);
|
|
63
|
+
if (name === "out") out.out.push(v);
|
|
64
|
+
else out[name] = v;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readPlan(args) {
|
|
70
|
+
if (!args.plan) throw new Usage("--plan <plan.json> 이 필요함");
|
|
71
|
+
let plan;
|
|
72
|
+
try {
|
|
73
|
+
plan = JSON.parse(readFileSync(args.plan, "utf8").replace(/^\uFEFF/, ""));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Usage(`plan 을 읽지 못함: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
if (args.holidays !== undefined && plan && typeof plan === "object") plan.holidays = args.holidays;
|
|
78
|
+
return plan;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function report(res) {
|
|
82
|
+
for (const w of res.warnings) process.stderr.write(`경고: ${w}\n`);
|
|
83
|
+
for (const e of res.errors) process.stderr.write(`오류: ${e}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function summary(m) {
|
|
87
|
+
const tasks = m.rows.filter((r) => r.kind === "task").length;
|
|
88
|
+
const marks = m.rows.reduce((n, r) => n + r.diamonds.length, 0);
|
|
89
|
+
const span = `${m.days[0].key} ~ ${m.days[m.days.length - 1].key}`;
|
|
90
|
+
return `영업일 ${m.days.length} (${span}), 주 ${m.weeks.length}, 행 ${m.rows.length}, 작업 ${tasks}, ◆ ${marks}, 게이트 ${m.gates.length}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cmdCheck(args) {
|
|
94
|
+
const res = layout(readPlan(args));
|
|
95
|
+
report(res);
|
|
96
|
+
if (!res.model) return 1;
|
|
97
|
+
process.stdout.write(`OK ${summary(res.model)}\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** An exclusive copy; a copy that fails after making target (disk full) removes what it made. */
|
|
102
|
+
function copyExclusive(tmp, target) {
|
|
103
|
+
try {
|
|
104
|
+
copyFileSync(tmp, target, constants.COPYFILE_EXCL);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.code !== "EEXIST") rmSync(target, { force: true });
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Puts tmp at target without replacing a file that appeared meanwhile (link, else an exclusive copy). */
|
|
112
|
+
function placeNew(tmp, target) {
|
|
113
|
+
try {
|
|
114
|
+
linkSync(tmp, target);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (e.code === "EEXIST") throw e;
|
|
117
|
+
copyExclusive(tmp, target);
|
|
118
|
+
}
|
|
119
|
+
rmSync(tmp, { force: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeOut(target, data, force) {
|
|
123
|
+
const tmp = join(dirname(target), `.${randomBytes(6).toString("hex")}.wbs-gen.tmp`);
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(tmp, data, { flag: "wx" });
|
|
126
|
+
if (force) renameSync(tmp, target);
|
|
127
|
+
else placeNew(tmp, target);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
rmSync(tmp, { force: true });
|
|
130
|
+
const why = e.code === "EEXIST" ? "이미 있음(덮어쓰려면 --force)" : e.message;
|
|
131
|
+
throw new Usage(`쓰지 못함 ${target}: ${why}`);
|
|
132
|
+
}
|
|
133
|
+
return target;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Checks every output before any is written: kind, duplicates, and (without --force) files already there. */
|
|
137
|
+
function targetsOf(args) {
|
|
138
|
+
if (!args.out.length) throw new Usage("--out <파일.xlsx|파일.html> 이 필요함");
|
|
139
|
+
const targets = args.out.map((f) => ({ path: resolve(f), kind: extname(f).toLowerCase() }));
|
|
140
|
+
const bad = targets.filter((x) => ![".xlsx", ".html", ".htm"].includes(x.kind));
|
|
141
|
+
if (bad.length) throw new Usage(`--out 은 .xlsx 나 .html 이어야 함: ${bad.map((x) => x.path).join(", ")}`);
|
|
142
|
+
if (new Set(targets.map((x) => x.path)).size !== targets.length) throw new Usage("--out 에 같은 파일이 두 번 있음");
|
|
143
|
+
const noDir = targets.filter((x) => !isDir(dirname(x.path)));
|
|
144
|
+
if (noDir.length) throw new Usage(`출력 폴더가 없음: ${noDir.map((x) => dirname(x.path)).join(", ")}`);
|
|
145
|
+
const there = targets.filter((x) => existsSync(x.path));
|
|
146
|
+
if (there.length && !args.force) throw new Usage(`이미 있음(덮어쓰려면 --force): ${there.map((x) => x.path).join(", ")}`);
|
|
147
|
+
return targets;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cmdBuild(args) {
|
|
151
|
+
const targets = targetsOf(args);
|
|
152
|
+
const res = layout(readPlan(args));
|
|
153
|
+
report(res);
|
|
154
|
+
if (!res.model) return 1;
|
|
155
|
+
// Every output is made before any is written, so a drawing error leaves no half set behind.
|
|
156
|
+
const made = targets.map((x) => ({ ...x, data: x.kind === ".xlsx" ? toXlsx(res.model) : toHtml(res.model) }));
|
|
157
|
+
for (const x of made) process.stdout.write(`WROTE ${writeOut(x.path, x.data, args.force)}\n`);
|
|
158
|
+
process.stdout.write(`OK ${summary(res.model)}\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function holidayArgs(args) {
|
|
163
|
+
const { from, to } = args;
|
|
164
|
+
if (keyMs(from) === null || keyMs(to) === null || to < from) throw new Usage("--from, --to 는 YYYY-MM-DD 이고 from <= to 여야 함");
|
|
165
|
+
const seconds = Number(args.timeout ?? 10);
|
|
166
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 60) throw new Usage("--timeout 은 1~60 사이 정수(초)");
|
|
167
|
+
if (args.url && !/^https:\/\//.test(args.url)) throw new Usage("--url 은 https:// 로 시작해야 함");
|
|
168
|
+
return { from, to, ms: seconds * 1000 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The calendar text, or null when it cannot be reached (the caller asks the user instead). */
|
|
172
|
+
async function calendarText(args, ms) {
|
|
173
|
+
if (args.ics) {
|
|
174
|
+
try {
|
|
175
|
+
return readFileSync(args.ics, "utf8");
|
|
176
|
+
} catch (e) {
|
|
177
|
+
throw new Usage(`ics 를 읽지 못함: ${e.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return await fetchText(args.url || ICS_URL, ms);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
process.stderr.write(`공휴일 달력에 닿지 못함(${e.message}). ${ASK}\n`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ASK = "사용자에게 공휴일을 'M/D, M/D' 로 받으십시오.";
|
|
189
|
+
|
|
190
|
+
async function cmdHolidays(args) {
|
|
191
|
+
const { from, to, ms } = holidayArgs(args);
|
|
192
|
+
const text = await calendarText(args, ms);
|
|
193
|
+
if (text === null) return 3;
|
|
194
|
+
const items = parseIcs(text);
|
|
195
|
+
// A calendar with events but not one 공휴일 means its wording changed: do not report "no holidays".
|
|
196
|
+
if (!items.some((h) => h.isPublic)) {
|
|
197
|
+
process.stderr.write(`공휴일 달력에서 공휴일을 읽지 못함. ${ASK}\n`);
|
|
198
|
+
return 3;
|
|
199
|
+
}
|
|
200
|
+
const days = publicHolidays(items, from, to);
|
|
201
|
+
// Within one year a month/day is enough; across years the full date keeps 2026 and 2027 apart.
|
|
202
|
+
const show = from.slice(0, 4) === to.slice(0, 4) ? mmdd : (k) => k;
|
|
203
|
+
for (const d of days) process.stdout.write(`${show(d.key)} ${d.name}\n`);
|
|
204
|
+
process.stdout.write(`HOLIDAYS ${days.length ? days.map((d) => show(d.key)).join(", ") : "없음"}\n`);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function main(argv) {
|
|
209
|
+
const [cmd, ...rest] = argv;
|
|
210
|
+
const args = parseArgs(rest);
|
|
211
|
+
if (cmd === "example") {
|
|
212
|
+
process.stdout.write(readFileSync(EXAMPLE, "utf8"));
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
if (cmd === "holidays") return cmdHolidays(args);
|
|
216
|
+
if (cmd === "check") return cmdCheck(args);
|
|
217
|
+
if (cmd === "build") return cmdBuild(args);
|
|
218
|
+
throw new Usage("명령: example | holidays | check | build");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Compared as real paths: Node resolves links in import.meta.url but not in argv[1], so a linked path would
|
|
222
|
+
// otherwise do nothing and exit 0.
|
|
223
|
+
const real = (p) => {
|
|
224
|
+
try {
|
|
225
|
+
return realpathSync(p);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
if (process.argv[1] && real(process.argv[1]) !== null && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
231
|
+
main(process.argv.slice(2)).then(
|
|
232
|
+
(code) => { process.exitCode = code; },
|
|
233
|
+
(e) => {
|
|
234
|
+
process.stderr.write(`${e instanceof Usage ? "" : "내부 오류: "}${e.message}\n`);
|
|
235
|
+
process.exitCode = 2;
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// wg-dates.mjs: dates for wbs-gen. A date is a 'YYYY-MM-DD' key read as a calendar day (UTC math, no time zone).
|
|
2
|
+
// Input dates may be 'YYYY-MM-DD' or 'M/D' ('MM/DD'); a month/day takes the year that puts it inside the project,
|
|
3
|
+
// and is refused when two years would (a project longer than a year).
|
|
4
|
+
|
|
5
|
+
const DOW = ["일", "월", "화", "수", "목", "금", "토"];
|
|
6
|
+
const DAY = 86400000;
|
|
7
|
+
|
|
8
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
/** Milliseconds of a valid 'YYYY-MM-DD' key, or null. */
|
|
11
|
+
export function keyMs(key) {
|
|
12
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key));
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3]);
|
|
15
|
+
return toKey(ms) === key ? ms : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toKey(ms) {
|
|
19
|
+
const d = new Date(ms);
|
|
20
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const addDays = (key, n) => toKey(keyMs(key) + n * DAY);
|
|
24
|
+
export const dow = (key) => new Date(keyMs(key)).getUTCDay();
|
|
25
|
+
export const dowName = (key) => DOW[dow(key)];
|
|
26
|
+
export const mmdd = (key) => `${key.slice(5, 7)}/${key.slice(8, 10)}`;
|
|
27
|
+
/** Whole days from a to b (keys). */
|
|
28
|
+
export const daysBetween = (a, b) => Math.round((keyMs(b) - keyMs(a)) / DAY);
|
|
29
|
+
|
|
30
|
+
/** One month/day in a year, or null when that day does not exist (2/30, 2/29 of a common year). */
|
|
31
|
+
function monthDay(y, m, d) {
|
|
32
|
+
const key = `${y}-${pad(m)}-${pad(d)}`;
|
|
33
|
+
return keyMs(key) === null ? null : key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads 'YYYY-MM-DD' or 'M/D' against the project range { start, end }.
|
|
38
|
+
* Returns { key, inRange }, { ambiguous: true } when a month/day falls inside the range in two years, or null when
|
|
39
|
+
* the text is not a date. A month/day outside the range takes the start year (inRange false).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveDate(text, range) {
|
|
42
|
+
const s = String(text ?? "").trim();
|
|
43
|
+
if (keyMs(s) !== null) return { key: s, inRange: s >= range.start && s <= range.end };
|
|
44
|
+
const m = /^(\d{1,2})\s*\/\s*(\d{1,2})$/.exec(s);
|
|
45
|
+
return m ? placeMonthDay(+m[1], +m[2], range) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function placeMonthDay(month, day, range) {
|
|
49
|
+
const all = [];
|
|
50
|
+
for (let y = +range.start.slice(0, 4); y <= +range.end.slice(0, 4); y++) all.push(monthDay(y, month, day));
|
|
51
|
+
const keys = all.filter(Boolean);
|
|
52
|
+
const inside = keys.filter((k) => k >= range.start && k <= range.end);
|
|
53
|
+
if (inside.length > 1) return { ambiguous: true };
|
|
54
|
+
if (inside.length) return { key: inside[0], inRange: true };
|
|
55
|
+
return keys.length ? { key: keys[0], inRange: false } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reads a holiday list: an array, or text such as "8/17, 10/03" (commas, spaces or new lines between). The words
|
|
60
|
+
* 없음 / none / - mean no holiday. Returns { keys: Set of keys inside the range, outside: [text], bad: [text],
|
|
61
|
+
* ambiguous: [text] }.
|
|
62
|
+
*/
|
|
63
|
+
export function parseHolidays(list, range) {
|
|
64
|
+
const items = Array.isArray(list) ? list.map(String) : String(list ?? "").split(/[,\s]+/);
|
|
65
|
+
const out = { keys: new Set(), outside: [], bad: [], ambiguous: [] };
|
|
66
|
+
for (const raw of items) {
|
|
67
|
+
const t = raw.trim();
|
|
68
|
+
if (!t || /^(없음|none|-)$/i.test(t)) continue;
|
|
69
|
+
const r = resolveDate(t, range);
|
|
70
|
+
if (!r) out.bad.push(t);
|
|
71
|
+
else if (r.ambiguous) out.ambiguous.push(t);
|
|
72
|
+
else if (r.inRange) out.keys.add(r.key);
|
|
73
|
+
else out.outside.push(t);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The working days from start to end inclusive: Monday to Friday and not in holidays. */
|
|
79
|
+
export function businessDays(start, end, holidays = new Set()) {
|
|
80
|
+
const days = [];
|
|
81
|
+
const last = keyMs(end);
|
|
82
|
+
for (let ms = keyMs(start); ms !== null && last !== null && ms <= last; ms += DAY) {
|
|
83
|
+
const k = toKey(ms);
|
|
84
|
+
const w = new Date(ms).getUTCDay();
|
|
85
|
+
if (w !== 0 && w !== 6 && !holidays.has(k)) days.push(k);
|
|
86
|
+
}
|
|
87
|
+
return days;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Groups working days by calendar week (Monday first): [{ label: 'W<n>', first, count }]. n counts calendar weeks
|
|
92
|
+
* from the week of the first day, so a week with no working day keeps its number.
|
|
93
|
+
*/
|
|
94
|
+
export function weeks(days) {
|
|
95
|
+
const out = [];
|
|
96
|
+
if (!days.length) return out;
|
|
97
|
+
const monday = (k) => addDays(k, -((dow(k) + 6) % 7));
|
|
98
|
+
const base = monday(days[0]);
|
|
99
|
+
days.forEach((k, i) => {
|
|
100
|
+
const n = daysBetween(base, monday(k)) / 7 + 1;
|
|
101
|
+
const last = out[out.length - 1];
|
|
102
|
+
if (last && last.n === n) last.count++;
|
|
103
|
+
else out.push({ n, label: `W${n}`, first: i, count: 1 });
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// wg-holidays.mjs: Korean public holidays from Google's public calendar (iCalendar, no key). Only events whose
|
|
2
|
+
// description starts with 공휴일 count; the calendar also lists observances (기념일) that are working days.
|
|
3
|
+
// When the calendar cannot be reached (an air-gapped network, a proxy Node does not use), the skill asks the user
|
|
4
|
+
// for the holidays as 'M/D, M/D' instead. Nothing here is cached or written.
|
|
5
|
+
import { get } from "node:https";
|
|
6
|
+
import { addDays, keyMs } from "./wg-dates.mjs";
|
|
7
|
+
|
|
8
|
+
export const ICS_URL =
|
|
9
|
+
"https://calendar.google.com/calendar/ical/ko.south_korea%23holiday%40group.v.calendar.google.com/public/basic.ics";
|
|
10
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/** Fetches a URL as text within timeoutMs (redirects followed up to 3 times). Rejects on any failure. */
|
|
13
|
+
export function fetchText(url, timeoutMs, hops = 0, deadline = Date.now() + timeoutMs) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const left = Math.max(1, deadline - Date.now());
|
|
16
|
+
const req = get(url, { headers: { "user-agent": "wbs-gen" } }, (res) => {
|
|
17
|
+
const code = res.statusCode ?? 0;
|
|
18
|
+
if (code >= 300 && code < 400 && res.headers.location && hops < 3) {
|
|
19
|
+
res.resume();
|
|
20
|
+
resolve(fetchText(new URL(res.headers.location, url).href, timeoutMs, hops + 1, deadline));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (code !== 200) {
|
|
24
|
+
res.resume();
|
|
25
|
+
reject(new Error(`HTTP ${code}`));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
readBody(res, resolve, reject);
|
|
29
|
+
});
|
|
30
|
+
// One deadline for the whole exchange, redirects included: a server that sends a little at a time is cut too.
|
|
31
|
+
const timer = setTimeout(() => req.destroy(new Error(`${Math.round(timeoutMs / 1000)}초 안에 끝나지 않음`)), left);
|
|
32
|
+
req.on("close", () => clearTimeout(timer));
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readBody(res, resolve, reject) {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
res.on("data", (c) => {
|
|
41
|
+
size += c.length;
|
|
42
|
+
if (size > MAX_BYTES) res.destroy(new Error("응답이 너무 큼"));
|
|
43
|
+
else chunks.push(c);
|
|
44
|
+
});
|
|
45
|
+
res.on("error", reject);
|
|
46
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const unescape = (s) => s.replace(/\\n/gi, "\n").replace(/\\([,;\\])/g, "$1");
|
|
50
|
+
|
|
51
|
+
function field(block, name) {
|
|
52
|
+
const m = new RegExp(`^${name}(?:;[^:\\n]*)?:(.*)$`, "m").exec(block);
|
|
53
|
+
return m ? unescape(m[1].trim()) : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const icsDate = (v) => {
|
|
57
|
+
const m = /^(\d{4})(\d{2})(\d{2})/.exec(v);
|
|
58
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reads iCalendar text. Returns [{ key, name, isPublic }], one item per day (DTEND is exclusive), sorted by date.
|
|
63
|
+
*/
|
|
64
|
+
export function parseIcs(text) {
|
|
65
|
+
const unfolded = String(text).replace(/\r\n/g, "\n").replace(/\n[ \t]/g, "");
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const m of unfolded.matchAll(/BEGIN:VEVENT\n([\s\S]*?)END:VEVENT/g)) {
|
|
68
|
+
const start = icsDate(field(m[1], "DTSTART"));
|
|
69
|
+
if (!start || keyMs(start) === null) continue;
|
|
70
|
+
const endRaw = icsDate(field(m[1], "DTEND"));
|
|
71
|
+
const end = endRaw && keyMs(endRaw) !== null && endRaw > start ? endRaw : addDays(start, 1);
|
|
72
|
+
const name = field(m[1], "SUMMARY");
|
|
73
|
+
const isPublic = /^공휴일/.test(field(m[1], "DESCRIPTION"));
|
|
74
|
+
for (let k = start; k < end && out.length < 100000; k = addDays(k, 1)) out.push({ key: k, name, isPublic });
|
|
75
|
+
}
|
|
76
|
+
return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Public holidays from start to end inclusive, one per day (names of the same day joined with ' / '). */
|
|
80
|
+
export function publicHolidays(items, start, end) {
|
|
81
|
+
const byDay = new Map();
|
|
82
|
+
for (const h of items) {
|
|
83
|
+
if (!h.isPublic || h.key < start || h.key > end) continue;
|
|
84
|
+
const names = byDay.get(h.key) ?? [];
|
|
85
|
+
if (!names.includes(h.name)) names.push(h.name);
|
|
86
|
+
byDay.set(h.key, names);
|
|
87
|
+
}
|
|
88
|
+
return [...byDay].map(([key, names]) => ({ key, name: names.join(" / ") }));
|
|
89
|
+
}
|