feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
429
scripts/build-menu-guide-ppt-user.py
Normal file
429
scripts/build-menu-guide-ppt-user.py
Normal file
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Platform 메뉴 안내 PPT — 일반 임직원(관리자·특수 메뉴 제외)
|
||||
- 가이드봇·WM·대시보드·업무 체크리스트: 허용 계정만 (본 PPT 범위 외)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHOT_DIR = ROOT / "docs" / "ppt-screenshots-user"
|
||||
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내-일반사용자.pptx"
|
||||
|
||||
# Brand palette
|
||||
NAVY = RGBColor(0x0F, 0x17, 0x2A)
|
||||
BLUE = RGBColor(0x25, 0x63, 0xEB)
|
||||
BLUE_LIGHT = RGBColor(0xDB, 0xEA, 0xFE)
|
||||
SLATE = RGBColor(0x47, 0x55, 0x69)
|
||||
SLATE_LIGHT = RGBColor(0x94, 0xA3, 0xB8)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
BG = RGBColor(0xF8, 0xFA, 0xFC)
|
||||
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
|
||||
SLIDES: list[tuple[str, str, list[str], str | None]] = [
|
||||
(
|
||||
"cover",
|
||||
"XAVIS AI Platform",
|
||||
[
|
||||
"일반 사용자 이용 가이드",
|
||||
"https://ai.xavis.co.kr/",
|
||||
"@ncue.net 이메일 인증 후 이용 · 관리자 모드 불필요",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"시작하기",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"이메일 인증으로 접속",
|
||||
[
|
||||
"1. ai.xavis.co.kr 접속 → 회사 이메일 입력 → [검증]",
|
||||
"2. 메일함 [인증 완료하기] 클릭 (링크 15분 유효)",
|
||||
"3. 인증 완료 후 AI·학습센터 등 메뉴 이용",
|
||||
"로그아웃: 좌측 하단 · 좌측 [관리자]는 운영 담당용(일반 사용자 불필요)",
|
||||
],
|
||||
"login.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"일반 사용자 메뉴 구성",
|
||||
[
|
||||
"AI — 회의록·채팅·FSCAN 등 AI 서비스",
|
||||
"프롬프트 — 업무용 템플릿·팀 공유",
|
||||
"학습센터 — YouTube·PPT·동영상 강의",
|
||||
"과제신청 — AX 과제 온라인 신청",
|
||||
"AI 활용 사례 — 임직원 간 실무 AI 활용 노하우 공유·열람",
|
||||
"※ 가이드봇·WM·대시보드·업무 체크리스트는 허용 계정만 표시",
|
||||
],
|
||||
"menu-overview.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"AI 서비스",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI — 서비스 허브",
|
||||
[
|
||||
"경로: /ai-explore",
|
||||
"검색 + 타입 필터(전체 / 일반 / XScan / FScan)",
|
||||
"카드 클릭으로 각 AI 도구 실행",
|
||||
],
|
||||
"ai-explore.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"회의록 AI",
|
||||
[
|
||||
"텍스트: 회의 원문 붙여넣기 → [회의록 생성] → 수정 후 [저장]",
|
||||
"음성: mp3·m4a·wav(최대 300MB) → 업로드 → 전사 → 회의록 정리",
|
||||
"대안: 클로버노트·Claude 전사 → 텍스트 입력 탭에 붙여넣기",
|
||||
"유의: 결과 검토 필수 · 개인정보·대외비 주의",
|
||||
],
|
||||
"meeting-minutes.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"일반 채팅",
|
||||
[
|
||||
"이메일 인증 후 ChatGPT 기반 사내 채팅",
|
||||
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
|
||||
"반복 업무는 [프롬프트] 템플릿 복사 후 활용",
|
||||
],
|
||||
"chat.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"FSCAN 조사각 선정도우미",
|
||||
[
|
||||
"검사물 H/W 치수 입력 → FSCAN 모델 1차 선정",
|
||||
"영업·기술 검토용 · 최종 스펙은 공식 카탈로그·기술팀 확인",
|
||||
],
|
||||
"fscan.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"업무 지원",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"프롬프트 라이브러리",
|
||||
[
|
||||
"공식 템플릿(회의·메일·보고·OKR 등) 미리보기 → 복사",
|
||||
"워크플로: 4단계 입력으로 맞춤 지시문 초안",
|
||||
"공유하기: 팀 프롬프트 등록(로그인 필요, 기밀 제외)",
|
||||
],
|
||||
"prompts.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"학습센터",
|
||||
[
|
||||
"YouTube · PPT/PDF · 동영상 · 웹 링크 강의 검색·시청",
|
||||
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
|
||||
"캡처: 등록 강의 목록 전체(무한 스크롤 로드 후)",
|
||||
"강의 등록·수정은 운영 담당 — 일반 사용자는 시청·검색",
|
||||
],
|
||||
"learning.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AX 과제 신청",
|
||||
[
|
||||
"Word 양식 다운로드 + 온라인 신청서 작성",
|
||||
"본인 신청 조회·수정(부서·이름·이메일)",
|
||||
"유사 사례는 AI 활용 사례 메뉴 참고",
|
||||
],
|
||||
"ax-apply.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI 활용 사례 — 공유 공간과 열람",
|
||||
[
|
||||
"각자의 실무 AI 활용법을 전사 임직원과 공유하는 공간(학습센터=교육, 여기=현장 검증 사례)",
|
||||
"로그인 임직원 누구나 열람·글쓰기 · 부서·태그·검색으로 유사 사례 탐색",
|
||||
"AX 과제·새 업무 전 참고 · 카드 클릭 → STAR 상세·재현 방법 확인",
|
||||
"경로: 좌측 [AI 활용 사례] · Before/After·활용 도구·성과 중심",
|
||||
],
|
||||
"ai-cases.png",
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"AI 활용 사례 — 나의 경험 공유하기",
|
||||
[
|
||||
"[글쓰기] → STAR 본문(배경→과제→AI 활용→성과) · 활용 AI 태그 · 썸네일",
|
||||
"동료가 내일 바로 따라 할 수 있도록 도구·절차·수치를 구체적으로(과장 금지)",
|
||||
"※ 개인정보·고객·기밀 미포함 · AI 결과는 본인 검증 후 · 문의 AI혁신팀",
|
||||
],
|
||||
"ai-cases-compose.png",
|
||||
),
|
||||
(
|
||||
"section",
|
||||
"정리",
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"content",
|
||||
"업무별 Quick Reference",
|
||||
[
|
||||
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
|
||||
"빠른 질문 → 일반 채팅 · 메일·보고 초안 → 프롬프트",
|
||||
"학습 → 학습센터 · AX 과제 → 과제신청 · 사례 → AI 활용 사례",
|
||||
"문의: AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"closing",
|
||||
"감사합니다",
|
||||
[
|
||||
"XAVIS AI Platform",
|
||||
"https://ai.xavis.co.kr/",
|
||||
"AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _set_solid_fill(shape, color: RGBColor) -> None:
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = color
|
||||
|
||||
|
||||
def _add_header_bar(slide, title: str) -> None:
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, Inches(0.95))
|
||||
_set_solid_fill(bar, NAVY)
|
||||
bar.line.fill.background()
|
||||
|
||||
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(0.06))
|
||||
_set_solid_fill(accent, BLUE)
|
||||
accent.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.55), Inches(0.18), Inches(10.5), Inches(0.55))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.name = "Apple SD Gothic Neo"
|
||||
p.font.size = Pt(24)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
|
||||
|
||||
def _add_footer(slide, page_num: int) -> None:
|
||||
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(7.05), Inches(12.2), Inches(0.015))
|
||||
_set_solid_fill(line, BLUE_LIGHT)
|
||||
line.line.fill.background()
|
||||
|
||||
left = slide.shapes.add_textbox(Inches(0.55), Inches(7.12), Inches(5), Inches(0.28))
|
||||
ltf = left.text_frame
|
||||
ltf.text = "XAVIS AI Platform · 일반 사용자 가이드"
|
||||
lp = ltf.paragraphs[0]
|
||||
lp.font.size = Pt(9)
|
||||
lp.font.color.rgb = SLATE_LIGHT
|
||||
|
||||
right = slide.shapes.add_textbox(Inches(11.8), Inches(7.12), Inches(1.0), Inches(0.28))
|
||||
rtf = right.text_frame
|
||||
rtf.text = str(page_num)
|
||||
rp = rtf.paragraphs[0]
|
||||
rp.font.size = Pt(9)
|
||||
rp.font.color.rgb = SLATE_LIGHT
|
||||
rp.alignment = PP_ALIGN.RIGHT
|
||||
|
||||
|
||||
def _add_bullets(slide, bullets: list[str], left: float, top: float, width: float, height: float, font_size: int = 14) -> None:
|
||||
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, line in enumerate(bullets):
|
||||
para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.name = "Apple SD Gothic Neo"
|
||||
para.font.size = Pt(font_size)
|
||||
para.font.color.rgb = SLATE
|
||||
para.space_after = Pt(8)
|
||||
para.level = 0
|
||||
if not line.startswith("※"):
|
||||
para.text = f"• {line}"
|
||||
|
||||
|
||||
def _add_screenshot(slide, shot_name: str, left: float, top: float, width: float) -> bool:
|
||||
path = SHOT_DIR / shot_name
|
||||
if not path.is_file():
|
||||
return False
|
||||
pic = slide.shapes.add_picture(str(path), Inches(left), Inches(top), width=Inches(width))
|
||||
max_height = Inches(5.85)
|
||||
if pic.height > max_height:
|
||||
scale = max_height / pic.height
|
||||
pic.width = int(pic.width * scale)
|
||||
pic.height = int(pic.height * scale)
|
||||
frame = slide.shapes.add_shape(
|
||||
MSO_SHAPE.RECTANGLE,
|
||||
pic.left - Inches(0.04),
|
||||
pic.top - Inches(0.04),
|
||||
pic.width + Inches(0.08),
|
||||
pic.height + Inches(0.08),
|
||||
)
|
||||
frame.fill.background()
|
||||
frame.line.color.rgb = BLUE_LIGHT
|
||||
frame.line.width = Pt(1.25)
|
||||
# picture on top
|
||||
slide.shapes._spTree.remove(pic._element)
|
||||
slide.shapes._spTree.insert(-1, pic._element)
|
||||
return True
|
||||
|
||||
|
||||
def add_cover_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, NAVY)
|
||||
bg.line.fill.background()
|
||||
|
||||
stripe = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(2.8), SLIDE_W, Inches(0.08))
|
||||
_set_solid_fill(stripe, BLUE)
|
||||
stripe.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(1.5), Inches(11.5), Inches(1.0))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(44)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.2), Inches(11.0), Inches(2.5))
|
||||
stf = sub.text_frame
|
||||
for i, line in enumerate(bullets):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(20 if i == 0 else 16)
|
||||
para.font.bold = i == 0
|
||||
para.font.color.rgb = BLUE_LIGHT if i == 0 else SLATE_LIGHT
|
||||
para.space_after = Pt(10)
|
||||
|
||||
|
||||
def add_section_slide(prs: Presentation, title: str, page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, BLUE)
|
||||
bg.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(3.0), Inches(11), Inches(1.2))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(36)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_text_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
|
||||
"""스크린샷 없이 본문만 — 설명·가이드용"""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
|
||||
_set_solid_fill(bg, BG)
|
||||
bg.line.fill.background()
|
||||
|
||||
_add_header_bar(slide, title)
|
||||
|
||||
highlight = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(1.15), Inches(0.08), Inches(5.6))
|
||||
_set_solid_fill(highlight, BLUE)
|
||||
highlight.line.fill.background()
|
||||
|
||||
_add_bullets(slide, bullets, 0.75, 1.25, 11.8, 5.5, font_size=15)
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_content_slide(prs: Presentation, title: str, bullets: list[str], shot_name: str | None, page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
|
||||
_set_solid_fill(bg, BG)
|
||||
bg.line.fill.background()
|
||||
|
||||
_add_header_bar(slide, title)
|
||||
|
||||
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
|
||||
text_w = 5.6 if has_shot else 12.0
|
||||
fs = 13 if len(bullets) >= 5 else 14
|
||||
_add_bullets(slide, bullets, 0.55, 1.25, text_w, 5.5, font_size=fs)
|
||||
|
||||
if has_shot and shot_name:
|
||||
_add_screenshot(slide, shot_name, 6.45, 1.15, 6.35)
|
||||
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def add_closing_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
|
||||
_set_solid_fill(bg, NAVY)
|
||||
bg.line.fill.background()
|
||||
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(2.6), Inches(11.5), Inches(1.0))
|
||||
tf = tb.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(40)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = WHITE
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.8), Inches(11.5), Inches(1.5))
|
||||
stf = sub.text_frame
|
||||
for i, line in enumerate(bullets):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(18)
|
||||
para.font.color.rgb = SLATE_LIGHT
|
||||
para.alignment = PP_ALIGN.CENTER
|
||||
para.space_after = Pt(6)
|
||||
|
||||
_add_footer(slide, page_num)
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
|
||||
page = 0
|
||||
for kind, title, bullets, shot in SLIDES:
|
||||
page += 1
|
||||
if kind == "cover":
|
||||
add_cover_slide(prs, title, bullets)
|
||||
elif kind == "section":
|
||||
add_section_slide(prs, title, page)
|
||||
elif kind == "closing":
|
||||
add_closing_slide(prs, title, bullets, page)
|
||||
elif kind == "text":
|
||||
add_text_slide(prs, title, bullets, page)
|
||||
else:
|
||||
add_content_slide(prs, title, bullets, shot, page)
|
||||
|
||||
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(OUT_PPT))
|
||||
return OUT_PPT
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = build()
|
||||
print(f"PPT saved: {out}")
|
||||
285
scripts/build-menu-guide-ppt.py
Normal file
285
scripts/build-menu-guide-ppt.py
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Platform 메뉴 안내 PPT 생성 (python-pptx)
|
||||
스크린샷: docs/ppt-screenshots/*.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHOT_DIR = ROOT / "docs" / "ppt-screenshots"
|
||||
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내.pptx"
|
||||
|
||||
# 슬라이드 정의: (제목, 불릿 목록, 스크린샷 파일명 또는 None)
|
||||
SLIDES: list[tuple[str, list[str], str | None]] = [
|
||||
(
|
||||
"XAVIS AI Platform 메뉴 안내",
|
||||
[
|
||||
"사내 AI 도구·학습·과제·활용 사례 통합 포털",
|
||||
"접속: https://ai.xavis.co.kr/",
|
||||
"최초 1회: @ncue.net 이메일 → 인증 메일 링크(15분 유효)",
|
||||
"좌측 메뉴: 회사규정 · WM · AI · 프롬프트 · 학습센터 · 과제신청 · AI 활용 사례 · 대시보드(허용자)",
|
||||
],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"1. 서비스 접속 (로그인)",
|
||||
[
|
||||
"경로: /login — 미인증 시 자동 이동",
|
||||
"회사 이메일 입력 → [검증] → 메일 [인증 완료하기] 클릭",
|
||||
"인증 후 학습센터 등 원하는 화면으로 이동",
|
||||
"유의: @ncue.net 만 가능 · 링크 만료 시 재검증 · 로그아웃은 좌측 하단",
|
||||
],
|
||||
"login.png",
|
||||
),
|
||||
(
|
||||
"2. 회사규정",
|
||||
[
|
||||
"좌측 [회사규정] 클릭 → Google NotebookLM (새 탭, 회사 Google 계정 로그인)",
|
||||
"사내 규정·지침 AI 검색·요약·질의응답",
|
||||
"캡처: 좌측 메뉴 [회사규정] 위치 (학습센터 화면)",
|
||||
"AI 답변은 원문 규정·다우오피스 공식 문서와 반드시 대조",
|
||||
],
|
||||
"company-policy.png",
|
||||
),
|
||||
(
|
||||
"3. WM",
|
||||
[
|
||||
"좌측 [WM] 클릭 → NotebookLM WM 노트북 (새 탭)",
|
||||
"WM 프로세스·용어·가이드 질의",
|
||||
"캡처: 좌측 메뉴 [WM] 위치 (AI 화면)",
|
||||
"공식 매뉴얼·담당 부서 확인 병행",
|
||||
],
|
||||
"wm.png",
|
||||
),
|
||||
(
|
||||
"4. AI (메인 허브)",
|
||||
[
|
||||
"경로: /ai-explore — AI 서비스 카드 허브",
|
||||
"검색 + 타입 필터(전체/일반/XScan/FScan)",
|
||||
"회의록 AI · 업무 체크리스트 · 일반 채팅 · FSCAN 선정도우미",
|
||||
],
|
||||
"ai-explore.png",
|
||||
),
|
||||
(
|
||||
"5. 회의록 AI",
|
||||
[
|
||||
"텍스트 입력: 회의 원문 붙여넣기 → 회의록 생성 → 저장",
|
||||
"음성 파일: mp3·m4a·wav(최대 300MB) → 업로드→전사→회의록 정리",
|
||||
"저장 시 업무 체크리스트 AI와 자동 연동",
|
||||
"대안: Claude 음성 업로드 / 클로버노트 전사 → 텍스트 입력 탭 붙여넣기",
|
||||
"유의: 생성 결과 검토 필수 · 개인정보·대외비 주의",
|
||||
],
|
||||
"meeting-minutes.png",
|
||||
),
|
||||
(
|
||||
"6. 업무 체크리스트 AI",
|
||||
[
|
||||
"회의록 AI 저장분의 액션·체크리스트 통합 관리",
|
||||
"진행/완료 필터 · 회의별 필터 · 완료 처리 메모",
|
||||
"흐름: 회의록 AI 저장 → 체크리스트에서 추적",
|
||||
],
|
||||
"task-checklist.png",
|
||||
),
|
||||
(
|
||||
"7. 일반 채팅",
|
||||
[
|
||||
"ChatGPT 기반 사내 인앱 채팅 (로그인 필요)",
|
||||
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
|
||||
"반복 프롬프트는 [프롬프트] 메뉴 템플릿 활용",
|
||||
"유의: AI 답변은 실수할 수 있음 · 기밀 입력 자제",
|
||||
],
|
||||
"chat.png",
|
||||
),
|
||||
(
|
||||
"8. FSCAN 조사각 선정도우미",
|
||||
[
|
||||
"검사 대상물 H/W 치수 입력 → FSCAN 모델 1차 선정",
|
||||
"영업·기술 검토 시 빠른 선정용",
|
||||
"최종 스펙은 공식 카탈로그·기술팀 확인 필수",
|
||||
],
|
||||
"fscan.png",
|
||||
),
|
||||
(
|
||||
"9. 프롬프트 라이브러리",
|
||||
[
|
||||
"공식 템플릿: 회의 요약·이메일·보고·OKR·코드리뷰 등 → 복사",
|
||||
"워크플로: 4단계 입력 → 맞춤 프롬프트 초안",
|
||||
"공유하기: 팀 프롬프트·참고 파일 (기밀 제외)",
|
||||
],
|
||||
"prompts.png",
|
||||
),
|
||||
(
|
||||
"10. 학습센터",
|
||||
[
|
||||
"YouTube · PPT/PDF · 동영상 · 웹·뉴스 링크 강의",
|
||||
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
|
||||
"검색 예: claude, 클로드 → 도구별 강의 찾기",
|
||||
],
|
||||
"learning.png",
|
||||
),
|
||||
(
|
||||
"11. AX 과제 신청",
|
||||
[
|
||||
"온라인 신청 + Word 양식 다운로드",
|
||||
"Pain Point · AI 기대 · 데이터·효과 등 작성",
|
||||
"조회로 본인 신청 확인·수정 · AI 활용 사례 참고",
|
||||
],
|
||||
"ax-apply.png",
|
||||
),
|
||||
(
|
||||
"12. AI 활용 사례",
|
||||
[
|
||||
"부서별 도입·성과 사례 카드 열람",
|
||||
"글쓰기: STAR 형식(1.Situation~4.Result)",
|
||||
"AX 과제·팀 공유 레퍼런스",
|
||||
],
|
||||
"ai-cases.png",
|
||||
),
|
||||
(
|
||||
"13. 대시보드 (허용 계정)",
|
||||
[
|
||||
"허용 이메일만 좌측 메뉴 표시",
|
||||
"경영성과: 연도·분기 KPI 차트 + 매출일보 엑셀 업로드",
|
||||
],
|
||||
"dashboard.png",
|
||||
),
|
||||
(
|
||||
"14. 경영성과 대시보드",
|
||||
[
|
||||
"상단: Chart.js KPI·차트 조회",
|
||||
"하단: .xlsx 매출일보 업로드 → 스냅샷 저장",
|
||||
],
|
||||
"dashboard-business-performance.png",
|
||||
),
|
||||
(
|
||||
"업무별 Quick Reference",
|
||||
[
|
||||
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
|
||||
"할 일 추적 → 업무 체크리스트 AI",
|
||||
"빠른 질문 → 일반 채팅 · 메일·보고 → 프롬프트",
|
||||
"규정 → 회사규정 · 학습 → 학습센터 · AI 과제 → 과제신청",
|
||||
"문의: AI혁신팀",
|
||||
],
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
ACCENT = RGBColor(0x25, 0x63, 0xEB)
|
||||
DARK = RGBColor(0x11, 0x18, 0x27)
|
||||
GRAY = RGBColor(0x4B, 0x55, 0x63)
|
||||
|
||||
|
||||
def add_title_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
|
||||
layout = prs.slide_layouts[6] # blank
|
||||
slide = prs.slides.add_slide(layout)
|
||||
slide.background.fill.solid()
|
||||
slide.background.fill.fore_color.rgb = RGBColor(0xF0, 0xF4, 0xFF)
|
||||
|
||||
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), Inches(11.5), Inches(1.2))
|
||||
tf = box.text_frame
|
||||
tf.text = title
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(40)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = DARK
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
sub = slide.shapes.add_textbox(Inches(1.2), Inches(3.6), Inches(10.8), Inches(2.5))
|
||||
stf = sub.text_frame
|
||||
stf.word_wrap = True
|
||||
for i, line in enumerate(bullets[:4]):
|
||||
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
|
||||
para.text = line
|
||||
para.font.size = Pt(18)
|
||||
para.font.color.rgb = GRAY
|
||||
para.space_after = Pt(8)
|
||||
para.alignment = PP_ALIGN.CENTER
|
||||
|
||||
|
||||
def add_content_slide(
|
||||
prs: Presentation,
|
||||
title: str,
|
||||
bullets: list[str],
|
||||
shot_name: str | None,
|
||||
) -> None:
|
||||
layout = prs.slide_layouts[6]
|
||||
slide = prs.slides.add_slide(layout)
|
||||
|
||||
# 제목
|
||||
title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.25), Inches(12.3), Inches(0.6))
|
||||
ttf = title_box.text_frame
|
||||
ttf.text = title
|
||||
tp = ttf.paragraphs[0]
|
||||
tp.font.size = Pt(26)
|
||||
tp.font.bold = True
|
||||
tp.font.color.rgb = ACCENT
|
||||
|
||||
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
|
||||
text_left = Inches(0.5)
|
||||
text_top = Inches(0.95)
|
||||
text_w = Inches(5.8) if has_shot else Inches(12.3)
|
||||
text_h = Inches(6.2)
|
||||
|
||||
body = slide.shapes.add_textbox(text_left, text_top, text_w, text_h)
|
||||
btf = body.text_frame
|
||||
btf.word_wrap = True
|
||||
for i, line in enumerate(bullets):
|
||||
para = btf.paragraphs[0] if i == 0 else btf.add_paragraph()
|
||||
para.text = f"• {line}"
|
||||
para.font.size = Pt(14)
|
||||
para.font.color.rgb = DARK
|
||||
para.space_after = Pt(6)
|
||||
para.level = 0
|
||||
|
||||
if has_shot:
|
||||
shot_path = str(SHOT_DIR / shot_name)
|
||||
slide.shapes.add_picture(
|
||||
shot_path,
|
||||
Inches(6.6),
|
||||
Inches(0.85),
|
||||
width=Inches(6.2),
|
||||
)
|
||||
elif shot_name:
|
||||
note = slide.shapes.add_textbox(Inches(6.6), Inches(2.5), Inches(6.0), Inches(1.0))
|
||||
ntf = note.text_frame
|
||||
ntf.text = f"(캡처 없음: {shot_name})"
|
||||
ntf.paragraphs[0].font.size = Pt(12)
|
||||
ntf.paragraphs[0].font.color.rgb = GRAY
|
||||
|
||||
# 슬라이드 번호
|
||||
num = len(prs.slides)
|
||||
footer = slide.shapes.add_textbox(Inches(12.0), Inches(7.05), Inches(0.8), Inches(0.3))
|
||||
ftf = footer.text_frame
|
||||
ftf.text = str(num)
|
||||
ftf.paragraphs[0].font.size = Pt(10)
|
||||
ftf.paragraphs[0].font.color.rgb = GRAY
|
||||
ftf.paragraphs[0].alignment = PP_ALIGN.RIGHT
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
for idx, (title, bullets, shot) in enumerate(SLIDES):
|
||||
if idx == 0:
|
||||
add_title_slide(prs, title, bullets)
|
||||
else:
|
||||
add_content_slide(prs, title, bullets, shot)
|
||||
|
||||
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(OUT_PPT))
|
||||
return OUT_PPT
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = build()
|
||||
print(f"PPT saved: {out}")
|
||||
293
scripts/capture-menu-screenshots.mjs
Normal file
293
scripts/capture-menu-screenshots.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AI Platform 메뉴 화면 캡처 (Playwright)
|
||||
* - 로그인: 운영 URL (PROD 전용 화면)
|
||||
* - 나머지: 로컬 SUPER 모드 서버 (인증 없이 캡처)
|
||||
* - 대시보드: OPS 세션 쿠키(허용 이메일) 주입 후 캡처
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR =
|
||||
process.env.CAPTURE_OUT_DIR ||
|
||||
path.join(__dirname, "..", "docs", "ppt-screenshots");
|
||||
const LOCAL_BASE = process.env.CAPTURE_BASE_URL || "http://127.0.0.1:8030";
|
||||
const PROD_BASE = (process.env.CAPTURE_PROD_BASE || "https://ai.xavis.co.kr").replace(/\/$/, "");
|
||||
const PROD_LOGIN = `${PROD_BASE}/login`;
|
||||
/** 목록(학습·사례)은 운영 데이터·전체 로드 후 캡처 */
|
||||
const CAPTURE_LISTS_FROM_PROD = process.env.CAPTURE_LISTS_FROM_PROD !== "0";
|
||||
/** 일반 사용자 OPS 세션 (관리자·특수 메뉴 쿠키 없음) */
|
||||
const CAPTURE_USER_EMAIL = (
|
||||
process.env.CAPTURE_USER_EMAIL || "employee@ncue.net"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
/** general: 가이드봇·WM·대시보드·체크리스트 제외 캡처 */
|
||||
const CAPTURE_PROFILE = (process.env.CAPTURE_PROFILE || "general").trim().toLowerCase();
|
||||
|
||||
const LOCAL_PAGES_ALL = [
|
||||
{ id: "ai-explore", path: "/ai-explore", waitMs: 800 },
|
||||
{ id: "meeting-minutes", path: "/ai-explore/meeting-minutes", waitMs: 1200 },
|
||||
{ id: "task-checklist", path: "/ai-explore/task-checklist", waitMs: 1200 },
|
||||
{ id: "chat", path: "/ai-explore/chat", waitMs: 800 },
|
||||
{ id: "fscan", path: "/ai-explore/fscan", waitMs: 2000 },
|
||||
{ id: "prompts", path: "/ai-explore/prompts", waitMs: 1500 },
|
||||
{ id: "ax-apply", path: "/ax-apply", waitMs: 1000 },
|
||||
];
|
||||
|
||||
/** 목록 패널 전체(element) 캡처 — viewport 잘림 방지 */
|
||||
const LIST_PANEL_PAGES = [
|
||||
{
|
||||
id: "learning",
|
||||
path: "/learning",
|
||||
selector: "section.panel:has(#lecture-results-root)",
|
||||
useProd: true,
|
||||
preload: "learning",
|
||||
},
|
||||
{
|
||||
id: "ai-cases",
|
||||
path: "/ai-cases",
|
||||
selector: "section.panel:has(.success-story-grid)",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
{
|
||||
id: "ai-cases-compose",
|
||||
path: "/ai-cases/compose",
|
||||
selector: "main.use-case-compose",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
];
|
||||
|
||||
const LOCAL_PAGES =
|
||||
CAPTURE_PROFILE === "general"
|
||||
? LOCAL_PAGES_ALL.filter(
|
||||
(p) => !["task-checklist", "dashboard", "dashboard-business-performance"].includes(p.id)
|
||||
)
|
||||
: LOCAL_PAGES_ALL;
|
||||
|
||||
/** 일반 사용자 좌측 메뉴(가이드봇·WM·대시보드 없음) */
|
||||
const MENU_OVERVIEW = { id: "menu-overview", path: "/ai-explore", waitMs: 1000 };
|
||||
|
||||
function readEnvValue(key) {
|
||||
const envPath = path.join(__dirname, "..", ".env");
|
||||
try {
|
||||
const raw = fsSync.readFileSync(envPath, "utf8");
|
||||
const re = new RegExp(`^${key}=(.+)$`, "m");
|
||||
const m = raw.match(re);
|
||||
if (!m) return "";
|
||||
return m[1].split("#")[0].trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** ops-auth.js 와 동일한 세션 쿠키 서명 */
|
||||
function buildOpsSessionCookie(email) {
|
||||
const secret = readEnvValue("AUTH_SECRET") || readEnvValue("ADMIN_TOKEN") || "ncue-admin";
|
||||
const exp = Number.MAX_SAFE_INTEGER;
|
||||
const iat = Date.now();
|
||||
const payload = `${email}|${exp}|${iat}`;
|
||||
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url");
|
||||
}
|
||||
|
||||
function captureUserEmail() {
|
||||
return CAPTURE_USER_EMAIL;
|
||||
}
|
||||
|
||||
async function capturePage(page, url, outPath, waitMs = 1000, fullPage = false) {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage });
|
||||
console.log(" saved:", outPath);
|
||||
}
|
||||
|
||||
/** 학습센터 무한 스크롤 — 모든 페이지 로드 */
|
||||
async function loadAllLearningPages(page) {
|
||||
await page.waitForSelector("#lecture-grid", { timeout: 60000 });
|
||||
let guard = 0;
|
||||
while (guard < 80) {
|
||||
guard += 1;
|
||||
const state = await page.evaluate(() => {
|
||||
const sentinel = document.getElementById("infinite-scroll-sentinel");
|
||||
const btn = document.getElementById("lecture-load-more-btn");
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return {
|
||||
hasNext: !!(sentinel && sentinel.getAttribute("data-has-next") === "true"),
|
||||
hasBtn: !!btn,
|
||||
total: countEl ? countEl.textContent.trim() : "",
|
||||
loaded: cards.length,
|
||||
};
|
||||
});
|
||||
if (!state.hasNext) break;
|
||||
if (state.hasBtn) {
|
||||
await page.locator("#lecture-load-more-btn").click({ timeout: 5000 }).catch(() => {});
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
const s = document.getElementById("infinite-scroll-sentinel");
|
||||
if (s) s.scrollIntoView({ block: "end", behavior: "instant" });
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(900);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById("infinite-scroll-loading");
|
||||
return !el || el.style.display === "none" || el.style.display === "";
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
const finalCount = await page.evaluate(() => {
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return { total: countEl ? countEl.textContent.trim() : "?", loaded: cards.length };
|
||||
});
|
||||
console.log(" learning loaded:", finalCount.loaded, "/ total", finalCount.total);
|
||||
}
|
||||
|
||||
/** 목록 섹션(element) 캡처 — 카드 전체가 포함되도록 */
|
||||
async function captureListPanel(page, baseUrl, item, outPath) {
|
||||
const url = `${baseUrl}${item.path}`;
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
if (item.preload === "learning") {
|
||||
await loadAllLearningPages(page);
|
||||
}
|
||||
await page.waitForSelector(item.selector, { timeout: 60000 });
|
||||
await page.waitForTimeout(600);
|
||||
const panel = page.locator(item.selector).first();
|
||||
await panel.scrollIntoViewIfNeeded();
|
||||
await panel.screenshot({ path: outPath });
|
||||
const meta = await page.evaluate((sel) => {
|
||||
const cards =
|
||||
sel.includes("lecture")
|
||||
? document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card").length
|
||||
: document.querySelectorAll(".success-story-grid .success-story-card, .success-story-grid a").length;
|
||||
const chip = document.querySelector(".count-chip");
|
||||
return { cards, chip: chip ? chip.textContent.trim() : "" };
|
||||
}, item.selector);
|
||||
console.log(" saved:", outPath, meta.chip || "", "visible cards:", meta.cards);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
locale: "ko-KR",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const userEmail = captureUserEmail();
|
||||
const opsCookie = buildOpsSessionCookie(userEmail);
|
||||
const cookieJar = [
|
||||
{
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
url: LOCAL_BASE,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
];
|
||||
if (CAPTURE_LISTS_FROM_PROD) {
|
||||
cookieJar.push({
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
domain: new URL(PROD_BASE).hostname,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
}
|
||||
await context.addCookies(cookieJar);
|
||||
console.log("Capture persona:", userEmail, "(no admin cookie)", "profile:", CAPTURE_PROFILE);
|
||||
console.log("Output dir:", OUT_DIR);
|
||||
|
||||
console.log("[1/4] Login (production)...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
PROD_LOGIN,
|
||||
path.join(OUT_DIR, "login.png"),
|
||||
1500
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" login capture failed:", err.message);
|
||||
}
|
||||
|
||||
console.log("[2/4] Local menus (PROD + OPS cookie, no admin)...");
|
||||
for (const item of LOCAL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[2b/4] List panels (full load + element capture)...");
|
||||
for (const item of LIST_PANEL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
const base = item.useProd && CAPTURE_LISTS_FROM_PROD ? PROD_BASE : LOCAL_BASE;
|
||||
try {
|
||||
await captureListPanel(page, base, item, outPath);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} list panel failed (${base}):`, err.message);
|
||||
if (base !== LOCAL_BASE) {
|
||||
try {
|
||||
await captureListPanel(page, LOCAL_BASE, item, outPath);
|
||||
} catch (err2) {
|
||||
console.warn(` ${item.id} local fallback failed:`, err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[3/4] Menu overview...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
`${LOCAL_BASE}${MENU_OVERVIEW.path}`,
|
||||
path.join(OUT_DIR, `${MENU_OVERVIEW.id}.png`),
|
||||
MENU_OVERVIEW.waitMs
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" menu-overview failed:", err.message);
|
||||
}
|
||||
|
||||
if (CAPTURE_PROFILE !== "general") {
|
||||
console.log("[4/4] Dashboard (허용 계정:", userEmail, ")...");
|
||||
for (const item of [
|
||||
{ id: "dashboard", path: "/dashboard", waitMs: 1000 },
|
||||
{ id: "dashboard-business-performance", path: "/dashboard/business-performance", waitMs: 2500 },
|
||||
]) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("[4/4] Dashboard skip (general profile)");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("Done. Output:", OUT_DIR);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
24
scripts/lib/load-env.sh
Executable file
24
scripts/lib/load-env.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시)
|
||||
load_project_env() {
|
||||
local env_file="$1"
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
echo "load_project_env: .env not found: $env_file" >&2
|
||||
return 1
|
||||
fi
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
[[ "$line" == \[* ]] && continue
|
||||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
local key="${BASH_REMATCH[1]}"
|
||||
local val="${BASH_REMATCH[2]}"
|
||||
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
export "$key=$val"
|
||||
fi
|
||||
done < "$env_file"
|
||||
}
|
||||
122
scripts/merge-orphan-ai-success-stories.js
Normal file
122
scripts/merge-orphan-ai-success-stories.js
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* data/ai-success-stories.json 에는 없는데 data/ai-success-stories/*.md 만 남은 경우( Git pull로 json만 예전으로 돌아간 경우 등 )
|
||||
* 본문 파일을 스캔해 메타 항목을 자동 추가합니다.
|
||||
*
|
||||
* 사용: 프로젝트 루트에서
|
||||
* node scripts/merge-orphan-ai-success-stories.js
|
||||
*
|
||||
* 동작: json 백업(data/ai-success-stories.json.bak.<시간>) 후, 등록되지 않은 .md 마다 항목 추가.
|
||||
* 제목은 md 첫 줄의 # 제목을 쓰고, 없으면 슬러그에서 추정한 임시 제목.
|
||||
* pdfUrl은 비어 있음 → 관리자 /ai-cases/write 에서 해당 사례 편집 후 PDF 경로를 다시 넣으세요.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DATA_DIR = path.join(ROOT, "data");
|
||||
const META_PATH = path.join(DATA_DIR, "ai-success-stories.json");
|
||||
const CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories");
|
||||
|
||||
function parseSlugFromMdName(basename) {
|
||||
const noExt = basename.replace(/\.md$/i, "");
|
||||
const m = noExt.match(/^(.+)-(\d{10,20})$/);
|
||||
if (m) return m[1].toLowerCase();
|
||||
return noExt.toLowerCase().replace(/[^a-z0-9\-]/g, "") || "story";
|
||||
}
|
||||
|
||||
function firstHeadingOrTitle(md, slug) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith("# ")) return t.slice(2).trim();
|
||||
}
|
||||
return (
|
||||
slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ") || "사례 (제목 확인)"
|
||||
);
|
||||
}
|
||||
|
||||
function excerptFromMd(md) {
|
||||
const lines = md.split(/\r?\n/).map((l) => l.trim());
|
||||
const paras = [];
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (line.startsWith("```")) break;
|
||||
paras.push(line);
|
||||
if (paras.join(" ").length > 200) break;
|
||||
}
|
||||
const e = paras.join(" ").slice(0, 280);
|
||||
return e || "";
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(META_PATH)) {
|
||||
console.error("없음:", META_PATH);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(CONTENT_DIR)) {
|
||||
console.error("없음:", CONTENT_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(META_PATH, "utf8");
|
||||
const meta = JSON.parse(raw);
|
||||
if (!Array.isArray(meta)) {
|
||||
console.error("ai-success-stories.json 형식이 배열이 아닙니다.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const used = new Set(meta.map((m) => (m.contentFile || "").split("/").pop()).filter(Boolean));
|
||||
const files = fs.readdirSync(CONTENT_DIR).filter((f) => /\.md$/i.test(f));
|
||||
|
||||
const orphans = files.filter((f) => !used.has(f));
|
||||
if (orphans.length === 0) {
|
||||
console.log("추가할 고아 .md 없음. 종료.");
|
||||
return;
|
||||
}
|
||||
|
||||
const bak = `${META_PATH}.bak.${Date.now()}`;
|
||||
fs.copyFileSync(META_PATH, bak);
|
||||
console.log("백업:", bak);
|
||||
|
||||
for (const file of orphans) {
|
||||
const full = path.join(CONTENT_DIR, file);
|
||||
const md = fs.readFileSync(full, "utf8");
|
||||
const slug = parseSlugFromMdName(file);
|
||||
if (meta.some((m) => m.slug === slug)) {
|
||||
console.warn("슬러그 충돌 건너뜀 (이미 다른 항목에 동일 slug):", slug, file);
|
||||
continue;
|
||||
}
|
||||
const title = firstHeadingOrTitle(md, slug);
|
||||
const excerpt = excerptFromMd(md) || title.slice(0, 140);
|
||||
const tsMatch = file.match(/-(\d{10,20})\.md$/i);
|
||||
const id = tsMatch ? `story-${tsMatch[1]}` : `story-${Date.now()}`;
|
||||
const row = {
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
excerpt,
|
||||
author: "",
|
||||
department: "",
|
||||
publishedAt: new Date().toISOString().slice(0, 10),
|
||||
tags: [],
|
||||
contentFile: file,
|
||||
pdfUrl: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
meta.push(row);
|
||||
console.log("추가:", file, "→ slug:", slug);
|
||||
}
|
||||
|
||||
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2), "utf8");
|
||||
console.log("저장 완료:", META_PATH, "총", meta.length, "건");
|
||||
console.log("pdfUrl이 비어 있으면 관리자 화면에서 PDF 경로를 지정하세요.");
|
||||
}
|
||||
|
||||
main();
|
||||
181
scripts/pg-backup.sh
Executable file
181
scripts/pg-backup.sh
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 DB_* 및 PG_BACKUP_* 사용.
|
||||
# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장.
|
||||
# 운영 서버 cron 예: 0 2 * * * cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=scripts/lib/load-env.sh
|
||||
source "$REPO_ROOT/scripts/lib/load-env.sh"
|
||||
|
||||
load_project_env "$REPO_ROOT/.env"
|
||||
|
||||
: "${DB_HOST:?DB_HOST is required in .env}"
|
||||
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
|
||||
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
|
||||
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
|
||||
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
PG_BACKUP_DIR="${PG_BACKUP_DIR:-/home/xavis/workspace/backup/ai_platform}"
|
||||
PG_BACKUP_RETENTION_DAYS="${PG_BACKUP_RETENTION_DAYS:-30}"
|
||||
PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}"
|
||||
PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}"
|
||||
|
||||
export PGHOST="$DB_HOST"
|
||||
export PGPORT="$DB_PORT"
|
||||
|
||||
if ! command -v pg_dump >/dev/null 2>&1; then
|
||||
echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "pg-backup: psql not found. Install postgresql-client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||
|
||||
set_backup_credentials() {
|
||||
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
else
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
fi
|
||||
}
|
||||
|
||||
list_all_databases() {
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \
|
||||
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;"
|
||||
}
|
||||
|
||||
dump_database() {
|
||||
local db_name="$1"
|
||||
local dump_file="$2"
|
||||
pg_dump \
|
||||
-h "$PGHOST" \
|
||||
-p "$PGPORT" \
|
||||
-U "$PGUSER" \
|
||||
-d "$db_name" \
|
||||
-Fc \
|
||||
--no-password \
|
||||
-f "$dump_file"
|
||||
}
|
||||
|
||||
backup_globals() {
|
||||
if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2
|
||||
return 0
|
||||
fi
|
||||
local prev_user="$PGUSER" prev_pass="$PGPASSWORD"
|
||||
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
pg_dumpall \
|
||||
-h "$PGHOST" \
|
||||
-p "$PGPORT" \
|
||||
-U "$PGUSER" \
|
||||
--globals-only \
|
||||
--no-password \
|
||||
-f "$RUN_DIR/00_globals.sql"
|
||||
export PGUSER="$prev_user"
|
||||
export PGPASSWORD="$prev_pass"
|
||||
echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql"
|
||||
}
|
||||
|
||||
set_backup_credentials
|
||||
|
||||
# cron 일 1회 기준: YYYYMMDD 폴더에 당일 백업 저장
|
||||
STAMP="$(date +%Y%m%d)"
|
||||
RUN_DIR="$PG_BACKUP_DIR/$STAMP"
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${PG_BACKUP_RETENTION_DAYS} days)"
|
||||
|
||||
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then
|
||||
echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${DB_USERNAME}." >&2
|
||||
fi
|
||||
|
||||
backup_globals
|
||||
|
||||
declare -a TARGET_DBS=()
|
||||
if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then
|
||||
TARGET_DBS=("$DB_DATABASE")
|
||||
else
|
||||
while IFS= read -r db; do
|
||||
[[ -n "$db" ]] && TARGET_DBS+=("$db")
|
||||
done < <(list_all_databases)
|
||||
if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then
|
||||
echo "pg-backup: no databases found to backup." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
MANIFEST="$RUN_DIR/00_manifest.txt"
|
||||
{
|
||||
echo "# pg-backup manifest $(log_ts)"
|
||||
echo "scope=${PG_BACKUP_SCOPE}"
|
||||
echo "host=${PGHOST}:${PGPORT}"
|
||||
echo "user=${PGUSER}"
|
||||
} > "$MANIFEST"
|
||||
|
||||
dump_ok=0
|
||||
dump_fail=0
|
||||
for db_name in "${TARGET_DBS[@]}"; do
|
||||
dump_file="$RUN_DIR/${db_name}.dump"
|
||||
echo "[$(log_ts)] dumping database: $db_name"
|
||||
if dump_database "$db_name" "$dump_file"; then
|
||||
bytes="$(wc -c < "$dump_file" | tr -d ' ')"
|
||||
echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)"
|
||||
echo "${db_name}.dump ${bytes}" >> "$MANIFEST"
|
||||
dump_ok=$((dump_ok + 1))
|
||||
else
|
||||
echo "[$(log_ts)] dump failed: $db_name" >&2
|
||||
dump_fail=$((dump_fail + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$dump_ok" -eq 0 ]]; then
|
||||
echo "pg-backup: all database dumps failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$dump_fail" -gt 0 ]]; then
|
||||
echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2
|
||||
fi
|
||||
|
||||
ln -sfn "$RUN_DIR" "$PG_BACKUP_DIR/latest"
|
||||
|
||||
prune_old_backups() {
|
||||
local cutoff removed=0 base day entry
|
||||
if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then
|
||||
cutoff=$(date -d "${PG_BACKUP_RETENTION_DAYS} days ago" +%Y%m%d)
|
||||
else
|
||||
cutoff=$(date -v-"${PG_BACKUP_RETENTION_DAYS}"d +%Y%m%d)
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
for entry in "$PG_BACKUP_DIR"/*; do
|
||||
[[ -d "$entry" ]] || continue
|
||||
base=$(basename "$entry")
|
||||
[[ "$base" == "latest" ]] && continue
|
||||
if [[ "$base" =~ ^([0-9]{8}) ]]; then
|
||||
day="${BASH_REMATCH[1]}"
|
||||
if [[ "$day" < "$cutoff" ]]; then
|
||||
rm -rf "$entry"
|
||||
removed=$((removed + 1))
|
||||
echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${PG_BACKUP_RETENTION_DAYS} days)"
|
||||
}
|
||||
|
||||
if [[ "$PG_BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] && [[ "$PG_BACKUP_RETENTION_DAYS" -gt 0 ]]; then
|
||||
prune_old_backups
|
||||
fi
|
||||
|
||||
echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $PG_BACKUP_DIR/latest"
|
||||
225
scripts/pg-restore.sh
Executable file
225
scripts/pg-restore.sh
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL 논리 복원 (pg_restore). custom format(.dump) 전용.
|
||||
# 사용 예:
|
||||
# bash scripts/pg-restore.sh /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
# bash scripts/pg-restore.sh --all --globals --confirm /home/xavis/workspace/backup/ai_platform/latest/
|
||||
# bash scripts/pg-restore.sh --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash scripts/pg-restore.sh [options] <path-to.dump | backup-dir>
|
||||
|
||||
Options:
|
||||
--all 백업 디렉터리 안의 *.dump 전체 복원 (00_globals.sql 있으면 --globals 권장)
|
||||
--test 복원 대상 DB를 {dbname}_restore_test 로 생성 (단일 dump 또는 --all)
|
||||
--clean pg_restore --clean --if-exists (기존 객체 삭제 후 복원, --test 미사용 시 위험)
|
||||
--confirm 확인 없이 실행 (cron·자동화용; --test 없이 --clean 시 필수)
|
||||
--globals 같은 디렉터리의 00_globals.sql 을 먼저 적용 (슈퍼유저 env 필요)
|
||||
-h, --help 도움말
|
||||
|
||||
환경 변수 (.env):
|
||||
DB_* 연결 정보
|
||||
PG_BACKUP_SUPERUSER --globals 시 사용 (기본 postgres)
|
||||
PG_BACKUP_SUPERUSER_PASSWORD
|
||||
EOF
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=scripts/lib/load-env.sh
|
||||
source "$REPO_ROOT/scripts/lib/load-env.sh"
|
||||
|
||||
MODE="prod"
|
||||
CLEAN=0
|
||||
CONFIRM=0
|
||||
GLOBALS=0
|
||||
RESTORE_ALL=0
|
||||
TARGET_PATH=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--test) MODE="test"; shift ;;
|
||||
--clean) CLEAN=1; shift ;;
|
||||
--confirm) CONFIRM=1; shift ;;
|
||||
--globals) GLOBALS=1; shift ;;
|
||||
--all) RESTORE_ALL=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
*)
|
||||
if [[ -n "$TARGET_PATH" ]]; then
|
||||
echo "Unexpected argument: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
TARGET_PATH="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TARGET_PATH" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
load_project_env "$REPO_ROOT/.env"
|
||||
|
||||
: "${DB_HOST:?DB_HOST is required in .env}"
|
||||
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
|
||||
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
|
||||
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
|
||||
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
export PGHOST="$DB_HOST"
|
||||
export PGPORT="$DB_PORT"
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
if ! command -v pg_restore >/dev/null 2>&1; then
|
||||
echo "pg-restore: pg_restore not found. Install postgresql-client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||
|
||||
resolve_superuser_creds() {
|
||||
SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}"
|
||||
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
|
||||
SU_USER="$SUPERUSER"
|
||||
SU_PASS="$PG_BACKUP_SUPERUSER_PASSWORD"
|
||||
else
|
||||
SU_USER="$DB_USERNAME"
|
||||
SU_PASS="$DB_PASSWORD"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_globals() {
|
||||
local globals_file="$1"
|
||||
if [[ ! -f "$globals_file" ]]; then
|
||||
echo "pg-restore: globals file not found: $globals_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${PG_BACKUP_SUPERUSER_PASSWORD:?PG_BACKUP_SUPERUSER_PASSWORD required for --globals}"
|
||||
resolve_superuser_creds
|
||||
export PGUSER="$SU_USER"
|
||||
export PGPASSWORD="$SU_PASS"
|
||||
echo "[$(log_ts)] applying globals: $globals_file"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -f "$globals_file"
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
}
|
||||
|
||||
ensure_database() {
|
||||
local db_name="$1"
|
||||
resolve_superuser_creds
|
||||
export PGUSER="$SU_USER"
|
||||
export PGPASSWORD="$SU_PASS"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -tc \
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" \
|
||||
>/dev/null 2>&1 || true
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$db_name\";"
|
||||
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c \
|
||||
"CREATE DATABASE \"$db_name\" OWNER \"$DB_USERNAME\";"
|
||||
fi
|
||||
export PGUSER="$DB_USERNAME"
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
}
|
||||
|
||||
restore_one_dump() {
|
||||
local dump_path="$1"
|
||||
local source_db
|
||||
source_db="$(basename "$dump_path" .dump)"
|
||||
local target_db="$source_db"
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
target_db="${source_db}_restore_test"
|
||||
ensure_database "$target_db"
|
||||
fi
|
||||
|
||||
local restore_args=(
|
||||
-h "$PGHOST"
|
||||
-p "$PGPORT"
|
||||
-U "$PGUSER"
|
||||
-d "$target_db"
|
||||
--no-owner
|
||||
--role="$DB_USERNAME"
|
||||
-v
|
||||
)
|
||||
if [[ "$CLEAN" -eq 1 ]]; then
|
||||
restore_args+=(--clean --if-exists)
|
||||
fi
|
||||
|
||||
echo "[$(log_ts)] pg_restore → $target_db ($dump_path)"
|
||||
pg_restore "${restore_args[@]}" "$dump_path"
|
||||
echo "[$(log_ts)] pg-restore done: $target_db"
|
||||
}
|
||||
|
||||
confirm_restore() {
|
||||
local message="$1"
|
||||
if [[ "$MODE" == "prod" && "$CLEAN" -eq 1 && "$CONFIRM" -ne 1 ]]; then
|
||||
echo "pg-restore: --clean on production DB requires --confirm" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONFIRM" -eq 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "$message"
|
||||
read -r -p "Continue? [y/N] " ans
|
||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || exit 0
|
||||
}
|
||||
|
||||
if [[ -d "$TARGET_PATH" ]]; then
|
||||
BACKUP_DIR="${TARGET_PATH%/}"
|
||||
if [[ "$RESTORE_ALL" -ne 1 ]]; then
|
||||
echo "pg-restore: path is a directory. Use --all to restore every *.dump inside." >&2
|
||||
exit 1
|
||||
fi
|
||||
confirm_restore "About to restore ALL dumps in: $BACKUP_DIR on $DB_HOST:$PGPORT"
|
||||
if [[ "$GLOBALS" -eq 1 ]]; then
|
||||
apply_globals "$BACKUP_DIR/00_globals.sql"
|
||||
fi
|
||||
shopt -s nullglob
|
||||
dumps=( "$BACKUP_DIR"/*.dump )
|
||||
shopt -u nullglob
|
||||
if [[ ${#dumps[@]} -eq 0 ]]; then
|
||||
echo "pg-restore: no .dump files in $BACKUP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
for dump in "${dumps[@]}"; do
|
||||
restore_one_dump "$dump"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$TARGET_PATH" ]]; then
|
||||
echo "pg-restore: path not found: $TARGET_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$RESTORE_ALL" -eq 1 ]]; then
|
||||
echo "pg-restore: --all requires a backup directory path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_DB="$DB_DATABASE"
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
TARGET_DB="${DB_DATABASE}_restore_test"
|
||||
fi
|
||||
|
||||
confirm_restore "About to restore into database: $TARGET_DB on $DB_HOST:$PGPORT
|
||||
Dump: $TARGET_PATH"
|
||||
|
||||
if [[ "$GLOBALS" -eq 1 ]]; then
|
||||
apply_globals "$(dirname "$TARGET_PATH")/00_globals.sql"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
ensure_database "$TARGET_DB"
|
||||
fi
|
||||
|
||||
restore_one_dump "$TARGET_PATH"
|
||||
|
||||
if [[ "$MODE" == "test" ]]; then
|
||||
echo "Verify with: psql -h $DB_HOST -U $DB_USERNAME -d $TARGET_DB -c '\\dt'"
|
||||
echo "Drop test DB: psql ... -c 'DROP DATABASE \"$TARGET_DB\";'"
|
||||
fi
|
||||
49
scripts/safe-git-pull.sh
Executable file
49
scripts/safe-git-pull.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# 배포 서버에서 런타임 data 파일의 **로컬 수정** 때문에
|
||||
# "Your local changes to the following files would be overwritten by merge" 가 나와
|
||||
# git pull 이 막힐 때 사용합니다.
|
||||
# 동작: 지정 경로를 백업 → 인덱스/작업트리를 리셃해 pull을 허용 → 백업을 다시 복사합니다.
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# pull 전에 Git이 덮어쓰기를 거부하는 런타임 전용 경로(필요 시 추가)
|
||||
PULL_SAFE_PATHS=(
|
||||
"data/ai-success-stories.json"
|
||||
)
|
||||
|
||||
STAMP="$(date +%Y%m%d%H%M%S)"
|
||||
BAK_DIR="${REPO_ROOT}/.tmp-safe-pull-backup-${STAMP}"
|
||||
mkdir -p "$BAK_DIR"
|
||||
|
||||
backed=()
|
||||
for relpath in "${PULL_SAFE_PATHS[@]}"; do
|
||||
if [[ -f "$relpath" ]]; then
|
||||
dest="${BAK_DIR}/$(echo "$relpath" | tr / _)"
|
||||
cp -a "$relpath" "$dest"
|
||||
backed+=("$relpath|$dest")
|
||||
fi
|
||||
done
|
||||
|
||||
# 추적 중이면 머지/체크아웃이 막힘 — 먼저 백업했으므로 작업트리·스테이징만 맞춤
|
||||
for relpath in "${PULL_SAFE_PATHS[@]}"; do
|
||||
if git ls-files --error-unmatch -- "$relpath" >/dev/null 2>&1; then
|
||||
if ! git restore --worktree --staged -- "$relpath" 2>/dev/null; then
|
||||
git reset -q HEAD -- "$relpath" 2>/dev/null || true
|
||||
git checkout -- "$relpath" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
git pull "$@"
|
||||
|
||||
for ent in "${backed[@]}"; do
|
||||
relpath="${ent%%|*}"
|
||||
src="${ent#*|}"
|
||||
if [[ -f "$src" ]]; then
|
||||
cp -a "$src" "$relpath"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$BAK_DIR"
|
||||
echo "safe-git-pull: 완료. 런타임 파일은 백업에서 복원되었습니다."
|
||||
Reference in New Issue
Block a user