@kaydash9999/banker-plugins 0.26.1 → 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 +39 -0
- package/README.md +25 -10
- package/bin/lib/codex-hooks.mjs +7 -1
- package/codex/command-skills/front-qa/SKILL.md +3 -1
- package/codex/hooks.json +15 -5
- package/codex/manifest.json +4 -0
- package/codex/skills/ralph/SKILL.md +272 -0
- package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
- package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
- package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
- package/codex/transform-matrix.md +5 -4
- package/commands/front-qa.md +3 -1
- package/hooks/ralph-continue-codex.mjs +59 -0
- package/package.json +1 -1
- package/skills/all-in-one/SKILL.md +22 -8
- package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
- package/skills/ralph-qa/references/run-folder.mjs +3 -3
- package/skills/ultra-chain/SKILL.md +11 -4
- package/skills/ultra-init/SKILL.md +9 -7
- package/skills/ultraqa/references/gate-evidence.mjs +3 -3
- package/skills/ultraqa/references/run-folder.mjs +3 -3
- 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/visual-ralph/SKILL.md +9 -4
- 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,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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// wg-html.mjs: draws the wbs-gen model as one self-contained HTML file (a table and CSS, no script, no library, no
|
|
2
|
+
// network). The grid matches the XLSX: 8 fixed columns, week and day headers, cell-fill bars, ◆, the red gate line.
|
|
3
|
+
// Colours reach the page only as 6-digit hex checked by wg-plan.mjs. Fonts: Noto Sans first and Noto Sans KR next;
|
|
4
|
+
// Noto Sans has no Hangul, so Hangul is drawn in Noto Sans KR and everything else in Noto Sans. Nothing is embedded:
|
|
5
|
+
// a viewer without the fonts uses its own sans-serif.
|
|
6
|
+
|
|
7
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8
|
+
const br = (s) => esc(s).replace(/\n/g, "<br>");
|
|
9
|
+
const WIDTHS = [48, 260, 84, 70, 210, 56, 56, 60];
|
|
10
|
+
const DAY = 36;
|
|
11
|
+
const ALIGN = ["c", "l", "c", "c", "l", "c", "c", "c"];
|
|
12
|
+
|
|
13
|
+
const CSS = `
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
body{margin:16px;font-family:"Noto Sans","Noto Sans KR",sans-serif;color:#2C3E50;font-size:12px}
|
|
16
|
+
.wrap{overflow:auto;max-height:calc(100vh - 32px);border:1px solid #D5D8DC}
|
|
17
|
+
table{border-collapse:separate;border-spacing:0;table-layout:fixed}
|
|
18
|
+
th,td{border-right:1px solid #D5D8DC;border-bottom:1px solid #D5D8DC;padding:2px 4px;overflow:hidden;vertical-align:middle;height:22px}
|
|
19
|
+
.l{text-align:left;white-space:normal;word-break:keep-all}.c,th{text-align:center}
|
|
20
|
+
.title th{background:#1F3864;color:#fff;font-size:15px;text-align:left;height:34px;padding:0 10px}
|
|
21
|
+
.h th{background:#34495E;color:#fff;font-size:12px}
|
|
22
|
+
.h th.w{background:#5D6D7E}
|
|
23
|
+
.d th{background:#EBEDEF;color:#2C3E50;font-size:10px;line-height:1.2;height:36px;padding:0;letter-spacing:-.3px}
|
|
24
|
+
thead tr th{position:sticky;z-index:2}
|
|
25
|
+
.title th{top:0}.h th{top:34px}.d th{top:56px}
|
|
26
|
+
.fix{position:sticky;z-index:1;background:#fff}
|
|
27
|
+
thead .fix{z-index:3}
|
|
28
|
+
tr.group td{font-weight:700;color:#1B2631}
|
|
29
|
+
.dia{color:#C0392B;font-weight:700;text-align:center;padding:0}
|
|
30
|
+
.dot{outline:1px dotted #7F8C8D;outline-offset:-2px}
|
|
31
|
+
.gate{box-shadow:inset 2px 0 0 #C0392B}
|
|
32
|
+
.notes{margin-top:18px;font-size:12px}
|
|
33
|
+
.notes h3{font-size:13px;margin:14px 0 6px;color:#1B2631}
|
|
34
|
+
.notes ul{margin:0;padding:0;list-style:none}
|
|
35
|
+
.notes li{margin:3px 0;display:flex;align-items:center;gap:8px}
|
|
36
|
+
.sw{display:inline-block;width:28px;height:14px;border:1px solid #D5D8DC}
|
|
37
|
+
.sw.dotted{border:1px dotted #7F8C8D}
|
|
38
|
+
.sw.gateline{border:0;border-left:2px solid #C0392B;border-right:2px solid #C0392B;width:10px}
|
|
39
|
+
.sw.diamond{border:0;color:#C0392B;font-weight:700;text-align:center;line-height:14px}
|
|
40
|
+
@page{size:A3 landscape;margin:10mm}
|
|
41
|
+
@media print{body{margin:0}.wrap{overflow:visible;max-height:none;border:0}thead tr th,.fix{position:static}
|
|
42
|
+
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function colgroup(n) {
|
|
46
|
+
return `<colgroup>${WIDTHS.map((w) => `<col style="width:${w}px">`).join("")}${`<col style="width:${DAY}px">`.repeat(n)}</colgroup>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Left offsets of the 8 sticky columns.
|
|
50
|
+
const LEFT = WIDTHS.map((_, i) => WIDTHS.slice(0, i).reduce((a, b) => a + b, 0));
|
|
51
|
+
const fixStyle = (c, bg) => ` class="fix" style="left:${LEFT[c]}px${bg ? `;background:#${bg}` : ""}"`;
|
|
52
|
+
|
|
53
|
+
function head(m, gate) {
|
|
54
|
+
const total = 8 + m.days.length;
|
|
55
|
+
const title = `<tr class="title"><th colspan="${total}">${esc(m.title)}</th></tr>`;
|
|
56
|
+
const fixed = m.head.map((h, c) => `<th rowspan="2" class="fix" style="left:${LEFT[c]}px;background:#34495E">${br(h)}</th>`).join("");
|
|
57
|
+
const wk = m.weeks.map((w) => `<th class="w${gate.has(w.first) ? " gate" : ""}" colspan="${w.count}">${esc(w.label)}</th>`).join("");
|
|
58
|
+
const days = m.days.map((d, i) => `<th${gate.has(i) ? ' class="gate"' : ""}>${esc(d.label)}<br>${esc(d.dow)}</th>`).join("");
|
|
59
|
+
return `<thead>${title}<tr class="h">${fixed}${wk}</tr><tr class="d">${days}</tr></thead>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dayTd(row, i, gate) {
|
|
63
|
+
const cls = [gate.has(i) ? "gate" : ""];
|
|
64
|
+
if (row.diamonds.includes(i)) {
|
|
65
|
+
cls.push("dia", row.dotted ? "dot" : "");
|
|
66
|
+
const bg = row.fill ? ` style="background:#${row.fill}"` : "";
|
|
67
|
+
return `<td class="${cls.filter(Boolean).join(" ")}"${bg}>◆</td>`;
|
|
68
|
+
}
|
|
69
|
+
const fill = i >= row.from && i <= row.to ? row.bar : row.fill;
|
|
70
|
+
const c = cls.filter(Boolean).join(" ");
|
|
71
|
+
return `<td${c ? ` class="${c}"` : ""}${fill ? ` style="background:#${fill}"` : ""}></td>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function body(m, gate) {
|
|
75
|
+
const rows = m.rows.map((row) => {
|
|
76
|
+
const fixed = row.cells.map((v, c) => `<td${fixStyle(c, row.fill)}><div class="${ALIGN[c]}">${br(v)}</div></td>`)
|
|
77
|
+
.join("");
|
|
78
|
+
const days = m.days.map((_, i) => dayTd(row, i, gate)).join("");
|
|
79
|
+
return `<tr class="${row.kind}">${fixed}${days}</tr>`;
|
|
80
|
+
});
|
|
81
|
+
return `<tbody>${rows.join("")}</tbody>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function swatch(item) {
|
|
85
|
+
if (item.type === "diamond") return `<span class="sw diamond">◆</span>`;
|
|
86
|
+
if (item.type === "gate") return `<span class="sw gateline"></span>`;
|
|
87
|
+
const cls = item.type === "dotted" ? "sw dotted" : "sw";
|
|
88
|
+
return `<span class="${cls}" style="background:#${item.color}"></span>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notes(m) {
|
|
92
|
+
const legend = m.legend.length
|
|
93
|
+
? `<h3>[범례]</h3><ul>${m.legend.map((l) => `<li>${swatch(l)}<span>${esc(l.label)}</span></li>`).join("")}</ul>`
|
|
94
|
+
: "";
|
|
95
|
+
const blocks = m.notes.map((n) => `${n.heading ? `<h3>${esc(n.heading)}</h3>` : ""}<ul>${n.lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul>`);
|
|
96
|
+
return `<section class="notes">${legend}${blocks.join("")}</section>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns the HTML text for a layout model. */
|
|
100
|
+
export function toHtml(m) {
|
|
101
|
+
const gate = new Set(m.gates.map((g) => g.col));
|
|
102
|
+
// A fixed width keeps every column at its colgroup width, so the sticky offsets match in a narrow window too.
|
|
103
|
+
const width = WIDTHS.reduce((x, y) => x + y, 0) + DAY * m.days.length;
|
|
104
|
+
const table = `<table style="width:${width}px">${colgroup(m.days.length)}${head(m, gate)}${body(m, gate)}</table>`;
|
|
105
|
+
return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
106
|
+
+ `<title>${esc(m.title.split(" | ")[0])}</title><style>${CSS}</style></head>`
|
|
107
|
+
+ `<body><div class="wrap">${table}</div>${notes(m)}</body></html>\n`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// wg-plan.mjs: reads a wbs-gen plan (JSON), checks it and lays it out as one model that the XLSX and HTML writers
|
|
2
|
+
// both draw. Nothing here touches the network or the disk.
|
|
3
|
+
import { businessDays, daysBetween, dowName, keyMs, mmdd, parseHolidays, resolveDate, weeks } from "./wg-dates.mjs";
|
|
4
|
+
|
|
5
|
+
export const HEAD = ["WBS", "작업", "담당", "우선순위", "선행·미확정", "시작", "종료", "일수\n(영업일)"];
|
|
6
|
+
export const KINDS = ["group", "task", "constraint", "milestone"];
|
|
7
|
+
// Five years of calendar days at most: about 1300 day columns, well inside Excel's 16384 columns.
|
|
8
|
+
export const MAX_SPAN_DAYS = 5 * 366;
|
|
9
|
+
// Excel keeps at most 32767 characters in a cell.
|
|
10
|
+
export const MAX_TEXT = 32767;
|
|
11
|
+
const HEX = /^#?[0-9A-Fa-f]{6}$/;
|
|
12
|
+
const DEFAULT_BAR = "5D6D7E";
|
|
13
|
+
const DEFAULT_LIGHT = "EBEDEF";
|
|
14
|
+
|
|
15
|
+
export const hex = (c) => String(c).replace(/^#/, "").toUpperCase();
|
|
16
|
+
const str = (v) => (v === undefined || v === null ? "" : String(v));
|
|
17
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
18
|
+
// Lookups by a plan string go through own keys only, so "constructor" or "__proto__" is just a missing name.
|
|
19
|
+
const own = (o, k) => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
20
|
+
|
|
21
|
+
/** A list field: absent is empty; present but not an array is an error. */
|
|
22
|
+
function list(plan, name, errors) {
|
|
23
|
+
const v = plan[name];
|
|
24
|
+
if (v === undefined || v === null) return [];
|
|
25
|
+
if (Array.isArray(v)) return v;
|
|
26
|
+
errors.push(`${name} 는 배열이어야 함`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRange(plan, errors) {
|
|
31
|
+
const range = { start: str(plan.start), end: str(plan.end) };
|
|
32
|
+
if (keyMs(range.start) === null || keyMs(range.end) === null) {
|
|
33
|
+
errors.push("start, end 는 YYYY-MM-DD 여야 함");
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (range.end < range.start) {
|
|
37
|
+
errors.push("end 가 start 보다 앞섬");
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (daysBetween(range.start, range.end) > MAX_SPAN_DAYS) {
|
|
41
|
+
errors.push("기간이 5년을 넘음. 단계나 해로 나누어 만드십시오");
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return range;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Checks the plan's frame (title, range, holidays, parts, bars). */
|
|
48
|
+
function readFrame(plan, errors, warnings) {
|
|
49
|
+
if (!str(plan.title).trim()) errors.push("title 이 없음");
|
|
50
|
+
const range = readRange(plan, errors);
|
|
51
|
+
if (!range) return null;
|
|
52
|
+
const h = parseHolidays(plan.holidays ?? [], range);
|
|
53
|
+
if (h.bad.length) errors.push(`공휴일을 날짜로 읽지 못함: ${h.bad.join(", ")}`);
|
|
54
|
+
if (h.ambiguous.length) errors.push(`기간 안 두 해에 걸리는 M/D: ${h.ambiguous.join(", ")}. YYYY-MM-DD 로 적으십시오`);
|
|
55
|
+
if (h.outside.length) warnings.push(`기간 밖 공휴일은 뺌: ${h.outside.join(", ")}`);
|
|
56
|
+
return { range, holidays: h.keys, parts: readParts(obj(plan.parts), errors), bars: readBars(obj(plan.bars), errors) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBars(given, errors) {
|
|
60
|
+
const bars = Object.create(null);
|
|
61
|
+
for (const [k, v] of Object.entries(given)) {
|
|
62
|
+
if (HEX.test(str(v))) bars[k] = hex(v);
|
|
63
|
+
else errors.push(`bars.${k} 색이 6자리 16진수가 아님: ${str(v)}`);
|
|
64
|
+
}
|
|
65
|
+
return bars;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readParts(parts, errors) {
|
|
69
|
+
const out = Object.create(null);
|
|
70
|
+
for (const [id, raw] of Object.entries(parts)) {
|
|
71
|
+
const p = obj(raw);
|
|
72
|
+
for (const k of ["light", "dark"]) {
|
|
73
|
+
if (p[k] !== undefined && !HEX.test(str(p[k]))) errors.push(`parts.${id}.${k} 색이 6자리 16진수가 아님: ${str(p[k])}`);
|
|
74
|
+
}
|
|
75
|
+
out[id] = { name: str(p.name || id), light: hex(p.light ?? DEFAULT_LIGHT), dark: hex(p.dark ?? DEFAULT_BAR), legend: p.legend !== false };
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A plan date as a key inside the range that is a working day, or null with an error. */
|
|
81
|
+
function workday(text, what, ctx) {
|
|
82
|
+
const r = resolveDate(text, ctx.range);
|
|
83
|
+
if (!r) ctx.errors.push(`${what}: 날짜로 읽지 못함 (${str(text)})`);
|
|
84
|
+
else if (r.ambiguous) ctx.errors.push(`${what}: ${str(text)} 가 기간 안 두 해에 걸림. YYYY-MM-DD 로 적으십시오`);
|
|
85
|
+
else if (!r.inRange) ctx.errors.push(`${what}: 기간 밖 (${r.key})`);
|
|
86
|
+
else if (ctx.index.get(r.key) === undefined) ctx.errors.push(`${what}: 주말이나 공휴일 (${r.key} ${dowName(r.key)})`);
|
|
87
|
+
else return r.key;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gatesOf(plan, ctx) {
|
|
92
|
+
return list(plan, "gates", ctx.errors).map((g, i) => {
|
|
93
|
+
const key = workday(obj(g).date, `gates[${i}]`, ctx);
|
|
94
|
+
return key && { key, col: ctx.index.get(key), label: str(obj(g).label) };
|
|
95
|
+
}).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function barColor(row, part, ctx) {
|
|
99
|
+
if (row.bar !== undefined) {
|
|
100
|
+
if (HEX.test(str(row.bar))) return hex(row.bar);
|
|
101
|
+
ctx.errors.push(`${str(row.wbs || row.task)}: bar 색이 6자리 16진수가 아님`);
|
|
102
|
+
}
|
|
103
|
+
return own(ctx.bars, str(row.priority)) ?? part?.dark ?? DEFAULT_BAR;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function partOf(row, what, ctx) {
|
|
107
|
+
if (row.part === undefined) return null;
|
|
108
|
+
const p = own(ctx.parts, str(row.part));
|
|
109
|
+
if (!p) ctx.errors.push(`${what}: parts 에 없는 part (${str(row.part)})`);
|
|
110
|
+
return p ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Start and end of a task or constraint; a constraint may give date alone. */
|
|
114
|
+
function spanOf(row, what, ctx) {
|
|
115
|
+
const s = workday(row.start ?? row.date, `${what} 시작`, ctx);
|
|
116
|
+
const e = workday(row.end ?? row.start ?? row.date, `${what} 종료`, ctx);
|
|
117
|
+
if (s && e && e < s) {
|
|
118
|
+
ctx.errors.push(`${what}: 종료가 시작보다 앞섬`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return s && e ? { s, e } : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cellsOf(row, extra) {
|
|
125
|
+
return [str(row.wbs), str(row.task), str(row.owner), str(row.priority), str(row.pending), ...extra];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function layRow(row, i, ctx) {
|
|
129
|
+
const kind = str(row.kind || "task");
|
|
130
|
+
const what = `rows[${i}] ${str(row.wbs || row.task)}`.trim();
|
|
131
|
+
if (!KINDS.includes(kind)) {
|
|
132
|
+
ctx.errors.push(`${what}: kind 는 ${KINDS.join(", ")} 중 하나여야 함 (${kind})`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const part = partOf(row, what, ctx);
|
|
136
|
+
const out = { kind, fill: null, bar: null, from: -1, to: -1, diamonds: [], dotted: false, cells: cellsOf(row, ["", "", ""]) };
|
|
137
|
+
if (kind === "group") return { ...out, fill: part?.light ?? DEFAULT_LIGHT };
|
|
138
|
+
if (kind === "milestone") {
|
|
139
|
+
const k = workday(row.date ?? row.start, what, ctx);
|
|
140
|
+
return { ...out, diamonds: k ? [ctx.index.get(k)] : [] };
|
|
141
|
+
}
|
|
142
|
+
return laySpan(row, kind, part, what, ctx, out);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function laySpan(row, kind, part, what, ctx, out) {
|
|
146
|
+
const span = spanOf(row, what, ctx);
|
|
147
|
+
if (!span) return out;
|
|
148
|
+
const from = ctx.index.get(span.s);
|
|
149
|
+
const to = ctx.index.get(span.e);
|
|
150
|
+
const cells = cellsOf(row, [ctx.label(span.s), ctx.label(span.e), to - from + 1]);
|
|
151
|
+
if (kind === "constraint") return { ...out, diamonds: [to], dotted: true, cells };
|
|
152
|
+
return { ...out, bar: barColor(row, part, ctx), from, to, cells };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function legendOf(plan, parts, gates, errors) {
|
|
156
|
+
const items = Object.values(parts).filter((p) => p.legend).map((p) => ({ type: "swatch", color: p.dark, label: p.name }));
|
|
157
|
+
list(plan, "legend", errors).map(obj).forEach((l, i) => {
|
|
158
|
+
if (!HEX.test(str(l.color))) errors.push(`legend[${i}] 색이 6자리 16진수가 아님`);
|
|
159
|
+
else items.push({ type: l.dotted ? "dotted" : "swatch", color: hex(l.color), label: str(l.label) });
|
|
160
|
+
});
|
|
161
|
+
if (plan.milestoneLegend !== false) items.push({ type: "diamond", color: "", label: str(plan.milestoneLabel || "마일스톤") });
|
|
162
|
+
for (const g of gates) items.push({ type: "gate", color: "C0392B", label: `${g.label || "게이트"}(빨간 수직선 = ${mmdd(g.key)})` });
|
|
163
|
+
return items;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** notes: [{ heading, lines }]; lines may also be one string. */
|
|
167
|
+
function notesOf(plan, errors) {
|
|
168
|
+
return list(plan, "notes", errors).map(obj).map((n, i) => {
|
|
169
|
+
const lines = typeof n.lines === "string" ? [n.lines] : n.lines ?? [];
|
|
170
|
+
if (!Array.isArray(lines)) errors.push(`notes[${i}].lines 는 배열이나 글이어야 함`);
|
|
171
|
+
return { heading: str(n.heading), lines: (Array.isArray(lines) ? lines : []).map(str) };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function rowsOf(plan, ctx) {
|
|
176
|
+
if (!Array.isArray(plan.rows) || !plan.rows.length) {
|
|
177
|
+
ctx.errors.push("rows 가 비어 있음");
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return plan.rows.map((r, i) => layRow(obj(r), i, ctx)).filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function titleOf(plan, range, n) {
|
|
184
|
+
const sameYear = range.start.slice(0, 4) === range.end.slice(0, 4);
|
|
185
|
+
const endText = sameYear ? mmdd(range.end) : range.end;
|
|
186
|
+
const span = `${range.start}(${dowName(range.start)}) ~ ${endText}(${dowName(range.end)})`;
|
|
187
|
+
return [str(plan.title).trim(), span, `${n} 영업일`, str(plan.subtitle).trim()].filter(Boolean).join(" | ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function tooLong(model, errors) {
|
|
191
|
+
const texts = [
|
|
192
|
+
model.title, model.sheet, ...model.legend.map((l) => l.label), ...model.rows.flatMap((r) => r.cells.map(str)),
|
|
193
|
+
...model.notes.flatMap((n) => [n.heading, ...n.lines]),
|
|
194
|
+
];
|
|
195
|
+
if (texts.some((t) => t.length > MAX_TEXT)) errors.push(`한 칸의 글이 ${MAX_TEXT}자를 넘음(Excel 한도)`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Checks a plan and lays it out. Returns { errors, warnings, model }; model is null when there are errors.
|
|
200
|
+
* model: { title, sheet, head, days: [{ key, label, dow }], weeks, gates, rows, legend, notes }
|
|
201
|
+
*/
|
|
202
|
+
export function layout(plan) {
|
|
203
|
+
const errors = [];
|
|
204
|
+
const warnings = [];
|
|
205
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return { errors: ["plan 은 JSON 객체여야 함"], warnings, model: null };
|
|
206
|
+
const frame = readFrame(plan, errors, warnings);
|
|
207
|
+
if (!frame) return { errors, warnings, model: null };
|
|
208
|
+
const days = businessDays(frame.range.start, frame.range.end, frame.holidays);
|
|
209
|
+
if (!days.length) errors.push("기간 안에 영업일이 없음");
|
|
210
|
+
const multiYear = frame.range.start.slice(0, 4) !== frame.range.end.slice(0, 4);
|
|
211
|
+
const label = (k) => (multiYear ? `${k.slice(2, 4)}/${mmdd(k)}` : mmdd(k));
|
|
212
|
+
const ctx = { ...frame, errors, label, index: new Map(days.map((k, i) => [k, i])) };
|
|
213
|
+
const gates = gatesOf(plan, ctx);
|
|
214
|
+
const rows = rowsOf(plan, ctx);
|
|
215
|
+
const legend = legendOf(plan, frame.parts, gates, errors);
|
|
216
|
+
const notes = notesOf(plan, errors);
|
|
217
|
+
const model = {
|
|
218
|
+
title: titleOf(plan, frame.range, days.length),
|
|
219
|
+
sheet: str(plan.sheet || "WBS 간트"),
|
|
220
|
+
head: HEAD,
|
|
221
|
+
days: days.map((k) => ({ key: k, label: mmdd(k), dow: dowName(k) })),
|
|
222
|
+
weeks: weeks(days),
|
|
223
|
+
gates,
|
|
224
|
+
rows,
|
|
225
|
+
legend,
|
|
226
|
+
notes,
|
|
227
|
+
};
|
|
228
|
+
tooLong(model, errors);
|
|
229
|
+
return { errors, warnings, model: errors.length ? null : model };
|
|
230
|
+
}
|