commit 과 push test
This commit is contained in:
42
.claude/hooks/aptabase-accumulate.py
Normal file
42
.claude/hooks/aptabase-accumulate.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Stop 훅: 트랜스크립트의 새 assistant 메시지에서 usage 를 읽어 프로젝트 누적기에 더한다.
|
||||||
|
|
||||||
|
- 누적 범위: <git-root>/.claude/state/aptabase-accum.json
|
||||||
|
- 커밋 훅(aptabase-commit.py)이 flush 하고 0으로 리셋한다
|
||||||
|
- 네트워크 호출 없음 (순수 파일 I/O)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from aptabase_common import ( # noqa: E402
|
||||||
|
accumulate_from_transcript,
|
||||||
|
load_config,
|
||||||
|
load_state,
|
||||||
|
save_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if load_config() is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_input = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
transcript_path = hook_input.get("transcript_path") or ""
|
||||||
|
if not transcript_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
cwd = hook_input.get("cwd")
|
||||||
|
state = load_state(cwd)
|
||||||
|
accumulate_from_transcript(state, transcript_path)
|
||||||
|
save_state(state, cwd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.claude/hooks/aptabase-accumulate.sh
Normal file
10
.claude/hooks/aptabase-accumulate.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop 훅: 직전 응답의 토큰 사용량을 프로젝트 누적기에 더한다.
|
||||||
|
# 실패해도 Claude 응답 흐름을 차단하지 않는다.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# __pycache__ 생성 방지 (import 시 .pyc 파일이 hooks 폴더에 생기는 것 방지)
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
cat | python3 "$SCRIPT_DIR/aptabase-accumulate.py" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
157
.claude/hooks/aptabase-commit.py
Normal file
157
.claude/hooks/aptabase-commit.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
커밋 flush 훅: git HEAD 가 변했으면 누적 토큰을 Aptabase 로 전송한다.
|
||||||
|
|
||||||
|
호출 경로 두 가지:
|
||||||
|
1. Claude PostToolUse(Bash) 훅 → stdin 에 JSON 입력
|
||||||
|
2. git post-commit 훅 → stdin 비어 있음 (직접 git 에서 호출)
|
||||||
|
|
||||||
|
두 경로 모두 같은 로직 (HEAD 비교 → 변했으면 flush → 리셋).
|
||||||
|
|
||||||
|
전송 시점:
|
||||||
|
- 훅 호출 시마다 HEAD 확인
|
||||||
|
- state.last_sent_commit 와 다르면 새 커밋으로 간주 → flush
|
||||||
|
|
||||||
|
전송 후 동작:
|
||||||
|
- state.accum 을 0으로 리셋
|
||||||
|
- state.last_sent_commit 를 현재 HEAD 로 갱신
|
||||||
|
- 실패 시 state 유지 → 다음 호출에서 재시도
|
||||||
|
|
||||||
|
전송 필드:
|
||||||
|
- claude_oauth_id : Claude OAuth 이메일
|
||||||
|
- plan : 구독 플랜
|
||||||
|
- user_name : aptabase.json 에서 지정
|
||||||
|
- local_ip : 로컬 IP
|
||||||
|
- public_ip : 공인 IP
|
||||||
|
- commit_hash : 현재 HEAD 해시
|
||||||
|
- commit_message : git log -1 --pretty=%B
|
||||||
|
- issue_number : 커밋 메시지에서 추출 (없으면 null)
|
||||||
|
- repository : owner/repo 또는 디렉터리명
|
||||||
|
- repository_url : remote.origin.url
|
||||||
|
- total_tokens : 누적 합계
|
||||||
|
- input_tokens, cache_creation_tokens, cache_read_tokens, output_tokens
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from aptabase_common import ( # noqa: E402
|
||||||
|
EMPTY_TOTALS,
|
||||||
|
EVENT_NAME,
|
||||||
|
accumulate_from_transcript,
|
||||||
|
build_system_props,
|
||||||
|
consumed_tokens,
|
||||||
|
extract_issue_number,
|
||||||
|
get_claude_oauth_id,
|
||||||
|
get_local_ip,
|
||||||
|
get_plan,
|
||||||
|
get_public_ip,
|
||||||
|
get_repository_info,
|
||||||
|
load_config,
|
||||||
|
load_state,
|
||||||
|
make_aptabase_session_id,
|
||||||
|
now_iso,
|
||||||
|
post_event,
|
||||||
|
run_git,
|
||||||
|
save_state,
|
||||||
|
total_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
cfg = load_config()
|
||||||
|
if cfg is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# stdin 파싱: Claude 훅 모드(JSON) vs git post-commit 모드(비어 있음)
|
||||||
|
hook_input: dict = {}
|
||||||
|
try:
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
if raw.strip():
|
||||||
|
hook_input = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
hook_input = {}
|
||||||
|
|
||||||
|
# Claude 훅 모드면 Bash 툴에서 온 호출만 처리 (다른 툴은 무시)
|
||||||
|
# git 훅 모드면 hook_input 이 비어 있어서 이 체크를 통과한다
|
||||||
|
if hook_input and hook_input.get("tool_name") != "Bash":
|
||||||
|
return
|
||||||
|
|
||||||
|
cwd = hook_input.get("cwd") or os.getcwd()
|
||||||
|
|
||||||
|
# git 저장소가 아니면 무시
|
||||||
|
head = run_git(["rev-parse", "HEAD"], cwd=cwd)
|
||||||
|
if not head:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = load_state(cwd)
|
||||||
|
last_sent = state.get("last_sent_commit", "")
|
||||||
|
|
||||||
|
if head == last_sent:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not last_sent:
|
||||||
|
# 첫 실행 — 기준점만 기록하고 다음 커밋을 기다린다
|
||||||
|
state["last_sent_commit"] = head
|
||||||
|
save_state(state, cwd)
|
||||||
|
return
|
||||||
|
|
||||||
|
# HEAD 가 바뀌었다 — Stop 훅이 놓쳤을 수 있는 최신 토큰을 flush.
|
||||||
|
# transcript_path 우선순위:
|
||||||
|
# 1) hook_input (Claude PostToolUse 훅 모드)
|
||||||
|
# 2) state.last_transcript (git post-commit 모드, Stop 훅이 저장한 값)
|
||||||
|
#
|
||||||
|
# 이 덕분에 한 턴에 여러 번 커밋하는 경우에도 각 커밋 직전까지 생성된
|
||||||
|
# 토큰이 해당 커밋에 flush 된다 (턴 중간 커밋의 0 토큰 문제 해결).
|
||||||
|
transcript_path = (
|
||||||
|
hook_input.get("transcript_path")
|
||||||
|
or state.get("last_transcript", "")
|
||||||
|
)
|
||||||
|
if transcript_path:
|
||||||
|
accumulate_from_transcript(state, transcript_path)
|
||||||
|
|
||||||
|
commit_message = run_git(["log", "-1", "--pretty=%B", head], cwd=cwd)
|
||||||
|
repo = get_repository_info(cwd)
|
||||||
|
accum = state.get("accum", dict(EMPTY_TOTALS))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"timestamp": now_iso(),
|
||||||
|
"sessionId": make_aptabase_session_id(),
|
||||||
|
"eventName": EVENT_NAME,
|
||||||
|
"systemProps": build_system_props(),
|
||||||
|
"props": {
|
||||||
|
# 커밋 정보
|
||||||
|
"commit_hash": head,
|
||||||
|
"commit_message": commit_message,
|
||||||
|
"issue_number": extract_issue_number(commit_message),
|
||||||
|
"repository": repo["name"] if not repo["url"] else repo["url"],
|
||||||
|
# 사용자
|
||||||
|
"claude_oauth_id": get_claude_oauth_id(),
|
||||||
|
"plan": get_plan(),
|
||||||
|
"user_name": cfg.get("user_name", "unknown"),
|
||||||
|
# 네트워크
|
||||||
|
"local_ip": get_local_ip(),
|
||||||
|
"public_ip": get_public_ip(),
|
||||||
|
# 토큰 (consumed 가 실제 쿼터 차감량, total 은 원시 합계)
|
||||||
|
"consumed_tokens": consumed_tokens(accum),
|
||||||
|
"total_tokens": total_tokens(accum),
|
||||||
|
"input_tokens": int(accum.get("input_tokens", 0)),
|
||||||
|
"output_tokens": int(accum.get("output_tokens", 0)),
|
||||||
|
"cache_creation_tokens": int(accum.get("cache_creation_tokens", 0)),
|
||||||
|
"cache_read_tokens": int(accum.get("cache_read_tokens", 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = post_event(cfg["aptabase_host"], cfg["app_key"], payload)
|
||||||
|
if ok:
|
||||||
|
# 누적 리셋 + 전송 기준점 갱신
|
||||||
|
state["accum"] = dict(EMPTY_TOTALS)
|
||||||
|
state["last_sent_commit"] = head
|
||||||
|
save_state(state, cwd)
|
||||||
|
# 실패: state 유지 → 다음 Bash 호출에서 재시도 (누적 + 새 커밋 병합)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.claude/hooks/aptabase-commit.sh
Normal file
10
.claude/hooks/aptabase-commit.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PostToolUse(Bash) / git post-commit 훅: git HEAD 가 바뀌었으면 누적 토큰을 Aptabase 로 flush.
|
||||||
|
# 실패해도 Claude 흐름을 차단하지 않는다.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# __pycache__ 생성 방지 (import 시 .pyc 파일이 hooks 폴더에 생기는 것 방지)
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
|
cat | python3 "$SCRIPT_DIR/aptabase-commit.py" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
9
.claude/hooks/aptabase.json
Normal file
9
.claude/hooks/aptabase.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"app_key": "A-SH-7756143445",
|
||||||
|
"aptabase_host": "https://aptabase.hmac.kr",
|
||||||
|
"user_name": "김민성(b16213)",
|
||||||
|
"git_repositories": [
|
||||||
|
"D:/MYCLAUDE_PROJECT/recordingtest"
|
||||||
|
]
|
||||||
|
}
|
||||||
362
.claude/hooks/aptabase_common.py
Normal file
362
.claude/hooks/aptabase_common.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Aptabase 훅 공통 유틸.
|
||||||
|
|
||||||
|
상태 파일: <git-root>/.claude/state/aptabase-accum.json
|
||||||
|
- accum : 누적 토큰 (input/cache_creation/cache_read/output)
|
||||||
|
- offsets : 트랜스크립트별 읽은 byte offset
|
||||||
|
- last_sent_commit : 마지막으로 Aptabase 로 전송한 HEAD 커밋 해시
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOOK_DIR = Path(__file__).resolve().parent
|
||||||
|
CONFIG_PATH = HOOK_DIR / "aptabase.json"
|
||||||
|
# OAuth 정보는 ~/.claude.json (top-level) 에 저장됨. ~/.claude/config.json 은 API 키 캐시용.
|
||||||
|
CLAUDE_OAUTH_CONFIG = Path.home() / ".claude.json"
|
||||||
|
CLAUDE_API_CONFIG = Path.home() / ".claude" / "config.json"
|
||||||
|
SDK_VERSION = "claude-hook-aptabase@0.3.0"
|
||||||
|
|
||||||
|
# Aptabase 이벤트 이름은 고정 — 커밋 단위 flush 하나뿐
|
||||||
|
EVENT_NAME = "claude_commit"
|
||||||
|
|
||||||
|
EMPTY_TOTALS = {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"cache_creation_tokens": 0,
|
||||||
|
"cache_read_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── 쿼터 차감 가중치 ─────────────────────────────────────────────────────
|
||||||
|
# Anthropic 은 각 토큰 타입을 다른 요율로 쿼터에서 차감한다.
|
||||||
|
# "input 등가 토큰(Input-Equivalent Tokens)" = 실제 구독 한도에서 차감되는 양.
|
||||||
|
#
|
||||||
|
# 가중치 (모든 모델 공통, 2026-04 기준):
|
||||||
|
# input × 1.00 — 기본 기준
|
||||||
|
# cache_creation × 1.25 — 캐시 쓰기 프리미엄
|
||||||
|
# cache_read × 0.10 — 캐시 읽기 90% 할인
|
||||||
|
# output × 5.00 — 출력이 입력의 5배
|
||||||
|
TOKEN_WEIGHTS = {
|
||||||
|
"input_tokens": 1.00,
|
||||||
|
"cache_creation_tokens": 1.25,
|
||||||
|
"cache_read_tokens": 0.10,
|
||||||
|
"output_tokens": 5.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 커밋 메시지에서 이슈 번호 추출 — 가장 구체적인 패턴부터
|
||||||
|
ISSUE_PATTERNS = [
|
||||||
|
r"(?:FEAT|BUG|FIX|TASK|PROJ|ISSUE)[-\s](\d+)",
|
||||||
|
r"(?:GH|gh|issue|close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s#-]+(\d+)",
|
||||||
|
r"#(\d+)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 설정 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_config() -> dict | None:
|
||||||
|
try:
|
||||||
|
cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not cfg.get("enabled", True):
|
||||||
|
return None
|
||||||
|
if not cfg.get("app_key") or not cfg.get("aptabase_host"):
|
||||||
|
return None
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_claude_oauth_config() -> dict:
|
||||||
|
"""~/.claude.json — OAuth 로그인 / 구독 정보가 들어 있음."""
|
||||||
|
return _read_json(CLAUDE_OAUTH_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def get_claude_api_config() -> dict:
|
||||||
|
"""~/.claude/config.json — API 키 캐시 등 레거시 필드."""
|
||||||
|
return _read_json(CLAUDE_API_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Claude / User 식별 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_claude_oauth_id() -> str:
|
||||||
|
"""OAuth 로그인 이메일. 없으면 anonymous."""
|
||||||
|
oauth = get_claude_oauth_config().get("oauthAccount") or {}
|
||||||
|
return oauth.get("emailAddress") or oauth.get("email") or "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan() -> str:
|
||||||
|
"""구독 플랜 식별자.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. oauthAccount.subscriptionType (max/pro/team/enterprise)
|
||||||
|
2. oauthAccount.billingType (stripe_subscription / apple_subscription / ...)
|
||||||
|
3. apikey (primaryApiKey 만 있는 경우)
|
||||||
|
4. unknown
|
||||||
|
"""
|
||||||
|
oauth = get_claude_oauth_config().get("oauthAccount") or {}
|
||||||
|
plan = (
|
||||||
|
oauth.get("subscriptionType")
|
||||||
|
or oauth.get("plan")
|
||||||
|
or oauth.get("billingType")
|
||||||
|
)
|
||||||
|
if plan:
|
||||||
|
return str(plan)
|
||||||
|
if get_claude_api_config().get("primaryApiKey"):
|
||||||
|
return "apikey"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 네트워크 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_local_ip() -> str:
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return socket.gethostbyname(socket.gethostname())
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_ip(timeout: float = 2.0) -> str:
|
||||||
|
for url in ("https://api.ipify.org", "https://ifconfig.me/ip"):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "claude-hook"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
ip = resp.read().decode().strip()
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Git ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_git(args: list, cwd: str | None = None) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git"] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_root(cwd: str | None = None) -> Path | None:
|
||||||
|
root = run_git(["rev-parse", "--show-toplevel"], cwd=cwd)
|
||||||
|
return Path(root) if root else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository_info(cwd: str | None = None) -> dict:
|
||||||
|
"""저장소 식별 정보.
|
||||||
|
|
||||||
|
- remote.origin.url 이 있으면: 그 URL + owner/repo 추출
|
||||||
|
- 없으면 (로컬 저장소): git root 의 절대 경로를 URL 자리에 넣고, 디렉터리명을 name 으로
|
||||||
|
"""
|
||||||
|
url = run_git(["config", "--get", "remote.origin.url"], cwd=cwd)
|
||||||
|
root = get_git_root(cwd)
|
||||||
|
|
||||||
|
name = ""
|
||||||
|
if url:
|
||||||
|
m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?/?$", url)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
if not name and root:
|
||||||
|
name = root.name
|
||||||
|
|
||||||
|
# 로컬 저장소 (remote 미설정): 경로를 URL 대용으로 기록
|
||||||
|
if not url and root:
|
||||||
|
url = str(root).replace("\\", "/")
|
||||||
|
|
||||||
|
return {"name": name or "unknown", "url": url or ""}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_issue_number(commit_message: str) -> str | None:
|
||||||
|
if not commit_message:
|
||||||
|
return None
|
||||||
|
for pattern in ISSUE_PATTERNS:
|
||||||
|
m = re.search(pattern, commit_message)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 상태 파일 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_state_path(cwd: str | None = None) -> Path:
|
||||||
|
root = get_git_root(cwd)
|
||||||
|
base = root if root else Path(cwd or os.getcwd())
|
||||||
|
return base / ".claude" / "state" / "aptabase-accum.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(cwd: str | None = None) -> dict:
|
||||||
|
path = get_state_path(cwd)
|
||||||
|
try:
|
||||||
|
state = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
state = {}
|
||||||
|
state.setdefault("accum", dict(EMPTY_TOTALS))
|
||||||
|
for k in EMPTY_TOTALS:
|
||||||
|
state["accum"].setdefault(k, 0)
|
||||||
|
state.setdefault("offsets", {})
|
||||||
|
state.setdefault("last_sent_commit", "")
|
||||||
|
# 최근 본 트랜스크립트 경로 — git post-commit 훅이 stdin 없이 호출될 때
|
||||||
|
# fallback 으로 사용해서 턴 중간 커밋에서도 최신 토큰을 flush 할 수 있게 함
|
||||||
|
state.setdefault("last_transcript", "")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict, cwd: str | None = None) -> None:
|
||||||
|
path = get_state_path(cwd)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def total_tokens(accum: dict) -> int:
|
||||||
|
"""원시 토큰 합계 (단순 합, 참고용)."""
|
||||||
|
return sum(int(accum.get(k, 0) or 0) for k in EMPTY_TOTALS)
|
||||||
|
|
||||||
|
|
||||||
|
def consumed_tokens(accum: dict) -> int:
|
||||||
|
"""쿼터에서 실제 차감되는 "input 등가 토큰" 수.
|
||||||
|
|
||||||
|
각 토큰 타입에 가중치를 곱해 합산한다. 이 값이 구독 한도 소모를
|
||||||
|
가장 정확히 반영한다.
|
||||||
|
"""
|
||||||
|
return int(round(sum(
|
||||||
|
int(accum.get(k, 0) or 0) * w
|
||||||
|
for k, w in TOKEN_WEIGHTS.items()
|
||||||
|
)))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 트랜스크립트 파싱 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def accumulate_from_transcript(state: dict, transcript_path: str) -> None:
|
||||||
|
"""트랜스크립트의 새 영역(offset 이후)만 파싱해 state.accum 에 추가.
|
||||||
|
|
||||||
|
append-only JSONL 이라 byte offset 기반이 안전하다.
|
||||||
|
"""
|
||||||
|
if not transcript_path or not os.path.exists(transcript_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized = str(Path(transcript_path))
|
||||||
|
offset = int(state["offsets"].get(normalized, 0))
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(transcript_path)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if size < offset:
|
||||||
|
# 파일이 교체됐을 수 있음 — 처음부터 다시 읽는다
|
||||||
|
offset = 0
|
||||||
|
if size == offset:
|
||||||
|
return
|
||||||
|
|
||||||
|
accum = state["accum"]
|
||||||
|
try:
|
||||||
|
with open(transcript_path, "rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
data = f.read(size - offset)
|
||||||
|
new_offset = f.tell()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in data.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
msg = entry.get("message")
|
||||||
|
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
usage = msg.get("usage")
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
continue
|
||||||
|
accum["input_tokens"] += int(usage.get("input_tokens", 0) or 0)
|
||||||
|
accum["cache_creation_tokens"] += int(
|
||||||
|
usage.get("cache_creation_input_tokens", 0) or 0
|
||||||
|
)
|
||||||
|
accum["cache_read_tokens"] += int(
|
||||||
|
usage.get("cache_read_input_tokens", 0) or 0
|
||||||
|
)
|
||||||
|
accum["output_tokens"] += int(usage.get("output_tokens", 0) or 0)
|
||||||
|
|
||||||
|
state["offsets"][normalized] = new_offset
|
||||||
|
# git post-commit 훅 fallback 용으로 경로 저장
|
||||||
|
state["last_transcript"] = normalized
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Aptabase 전송 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_aptabase_session_id() -> str:
|
||||||
|
"""epoch_seconds + 8 random digits (Aptabase 권장 형식)."""
|
||||||
|
return f"{int(time.time())}{random.randint(0, 99999999):08d}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_props() -> dict:
|
||||||
|
return {
|
||||||
|
"isDebug": False,
|
||||||
|
"locale": os.environ.get("LANG", "en-US").split(".")[0].replace("_", "-"),
|
||||||
|
"osName": platform.system(),
|
||||||
|
"osVersion": platform.release(),
|
||||||
|
"appVersion": "1.0.0",
|
||||||
|
"sdkVersion": SDK_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
def post_event(host: str, app_key: str, payload: dict) -> bool:
|
||||||
|
url = host.rstrip("/") + "/api/v0/event"
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"App-Key": app_key,
|
||||||
|
"User-Agent": f"ClaudeCodeHook/{SDK_VERSION}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
resp.read()
|
||||||
|
return True
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
||||||
|
return False
|
||||||
86
.claude/hooks/install-git-hook.sh
Normal file
86
.claude/hooks/install-git-hook.sh
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# .git/hooks/post-commit 을 설치해서 모든 커밋(IDE / 터미널 / git GUI 포함)에서
|
||||||
|
# aptabase-commit.sh 가 호출되도록 한다.
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# bash .claude/hooks/install-git-hook.sh # 설치
|
||||||
|
# bash .claude/hooks/install-git-hook.sh --force # 기존 훅 덮어쓰기
|
||||||
|
# bash .claude/hooks/install-git-hook.sh --uninstall
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MARKER="# aptabase-commit-auto-hook"
|
||||||
|
FORCE=false
|
||||||
|
UNINSTALL=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) FORCE=true ;;
|
||||||
|
--uninstall) UNINSTALL=true ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,11p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $arg" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||||
|
echo "Error: not inside a git repository" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
HOOK_PATH="$GIT_ROOT/.git/hooks/post-commit"
|
||||||
|
|
||||||
|
if [[ "$UNINSTALL" == "true" ]]; then
|
||||||
|
if [[ -f "$HOOK_PATH" ]] && grep -q "$MARKER" "$HOOK_PATH" 2>/dev/null; then
|
||||||
|
rm -f "$HOOK_PATH"
|
||||||
|
echo "Removed: $HOOK_PATH"
|
||||||
|
else
|
||||||
|
echo "Nothing to uninstall (no aptabase hook found at $HOOK_PATH)"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 기존 훅 체크
|
||||||
|
if [[ -f "$HOOK_PATH" ]]; then
|
||||||
|
if grep -q "$MARKER" "$HOOK_PATH" 2>/dev/null; then
|
||||||
|
echo "Already installed: $HOOK_PATH"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$FORCE" != "true" ]]; then
|
||||||
|
echo "Error: existing post-commit hook found at $HOOK_PATH" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Options:" >&2
|
||||||
|
echo " 1. Re-run with --force to overwrite (backup will be saved as .bak)" >&2
|
||||||
|
echo " 2. Manually append the following line to the existing hook:" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " bash .claude/hooks/aptabase-commit.sh < /dev/null 2>/dev/null || true" >&2
|
||||||
|
echo "" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$HOOK_PATH" "$HOOK_PATH.bak"
|
||||||
|
echo "Backed up existing hook to: $HOOK_PATH.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$HOOK_PATH")"
|
||||||
|
cat > "$HOOK_PATH" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptabase-commit-auto-hook
|
||||||
|
# Auto-installed by .claude/hooks/install-git-hook.sh
|
||||||
|
# 모든 커밋 후 aptabase-commit.sh 를 호출해 누적 토큰을 flush 한다.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PROJECT_HOOK="$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/aptabase-commit.sh"
|
||||||
|
if [[ -f "$PROJECT_HOOK" ]]; then
|
||||||
|
bash "$PROJECT_HOOK" < /dev/null >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$HOOK_PATH"
|
||||||
|
|
||||||
|
echo "Installed: $HOOK_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "이제 IDE, 터미널, git GUI 에서 커밋해도 aptabase 에 누적 토큰이 전송됩니다."
|
||||||
295
.claude/hooks/usage.md
Normal file
295
.claude/hooks/usage.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# common/.claude/hooks 사용법
|
||||||
|
|
||||||
|
Claude 의 모든 응답 토큰을 프로젝트 단위로 누적하고, git 커밋이 일어나면 **Aptabase** 로 한 번에 전송하는 훅 묶음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/hooks/
|
||||||
|
├── aptabase.json # 설정 (app_key, aptabase_host, user_name, enabled)
|
||||||
|
├── aptabase_common.py # 공통 유틸 (설정/IP/git/state/transcript/POST)
|
||||||
|
├── aptabase-accumulate.sh # Stop 훅 진입점
|
||||||
|
├── aptabase-accumulate.py # 트랜스크립트 → state.accum 누적 (네트워크 없음)
|
||||||
|
├── aptabase-commit.sh # 커밋 flush 진입점 (Claude 훅 + git 훅 공용)
|
||||||
|
├── aptabase-commit.py # HEAD 변화 감지 → POST → 리셋
|
||||||
|
└── install-git-hook.sh # .git/hooks/post-commit 설치 스크립트
|
||||||
|
|
||||||
|
.claude/state/
|
||||||
|
└── aptabase-accum.json # 누적 상태 (자동 생성, git root 기준)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설치 (프로젝트 1회)
|
||||||
|
|
||||||
|
### 1. 훅 파일 복사
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프로젝트 루트에서
|
||||||
|
cp -r path/to/common/.claude/hooks .claude/hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. aptabase.json 채우기
|
||||||
|
|
||||||
|
`.claude/hooks/aptabase.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"app_key": "A-SH-XXXXXXXXXX",
|
||||||
|
"aptabase_host": "https://aptabase.example.com",
|
||||||
|
"user_name": "kim"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 키 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | false 면 모든 훅이 즉시 종료 (일시 비활성) |
|
||||||
|
| `app_key` | Aptabase App Key (self-hosted 는 `A-SH-` 접두사) |
|
||||||
|
| `aptabase_host` | Aptabase 인스턴스 base URL |
|
||||||
|
| `user_name` | props.user_name 으로 전송할 사용자 식별자 |
|
||||||
|
|
||||||
|
### 3. settings.json 에 Claude 훅 등록
|
||||||
|
|
||||||
|
`.claude/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "bash .claude/hooks/aptabase-accumulate.sh" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "bash .claude/hooks/aptabase-commit.sh" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 훅이 있으면 같은 `hooks` 배열에 추가.
|
||||||
|
|
||||||
|
### 4. git post-commit 훅 설치 (필수)
|
||||||
|
|
||||||
|
**Claude 외부**(IDE, 터미널, git GUI)에서 이루어진 커밋도 포착하려면 반드시 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/install-git-hook.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/install-git-hook.sh --force # 기존 post-commit 을 .bak 로 백업 후 덮어쓰기
|
||||||
|
bash .claude/hooks/install-git-hook.sh --uninstall # 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
> `.git/hooks/` 는 버전 관리되지 않으므로 **clone 할 때마다 재설치 필요**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 동작 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude 응답 완료 ──┐
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[aptabase-accumulate.sh]
|
||||||
|
└─ 트랜스크립트 JSONL 의 새 byte 영역만 파싱
|
||||||
|
└─ assistant 메시지의 usage 를 state.accum 에 누적
|
||||||
|
└─ 네트워크 호출 없음
|
||||||
|
|
||||||
|
git commit (Claude 의 Bash 툴 또는 외부 도구) ──┐
|
||||||
|
│
|
||||||
|
┌─────────────────────────┴────────┐
|
||||||
|
▼ ▼
|
||||||
|
[PostToolUse(Bash)] [.git/hooks/post-commit]
|
||||||
|
└─ aptabase-commit.sh └─ aptabase-commit.sh
|
||||||
|
│ │
|
||||||
|
└──────────────┬───────────────────┘
|
||||||
|
▼
|
||||||
|
[aptabase-commit.py]
|
||||||
|
├─ git rev-parse HEAD
|
||||||
|
├─ last_sent_commit 과 같으면 return (중복 방지)
|
||||||
|
├─ 다르면:
|
||||||
|
│ ├─ 최신 트랜스크립트 flush (Claude 훅 모드)
|
||||||
|
│ ├─ commit_message / issue_number / repository 수집
|
||||||
|
│ ├─ claude_oauth_id / plan / local_ip / public_ip 수집
|
||||||
|
│ ├─ POST {aptabase_host}/api/v0/event
|
||||||
|
│ ├─ 성공: state.accum = 0, last_sent_commit = HEAD
|
||||||
|
│ └─ 실패: state 유지, 다음 호출에서 재시도
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전송되는 이벤트
|
||||||
|
|
||||||
|
**이벤트 이름:** `claude_commit` (고정)
|
||||||
|
|
||||||
|
### props 필드
|
||||||
|
|
||||||
|
| 필드 | 출처 |
|
||||||
|
|---|---|
|
||||||
|
| `claude_oauth_id` | `~/.claude/config.json` 의 OAuth 이메일 (없으면 `anonymous`) |
|
||||||
|
| `plan` | 구독 플랜 (`max` / `pro` / `team` / `enterprise` / `apikey` / `unknown`) |
|
||||||
|
| `user_name` | `aptabase.json` 의 `user_name` |
|
||||||
|
| `local_ip` | UDP connect 트릭 (패킷 미전송) |
|
||||||
|
| `public_ip` | `api.ipify.org` → `ifconfig.me` (2초 타임아웃) |
|
||||||
|
| `commit_hash` | `git rev-parse HEAD` |
|
||||||
|
| `commit_message` | `git log -1 --pretty=%B` |
|
||||||
|
| `issue_number` | 커밋 메시지에서 regex 추출. 없으면 `null` |
|
||||||
|
| `repository` | `owner/repo` (remote URL) 또는 디렉터리명 |
|
||||||
|
| `repository_url` | `git config --get remote.origin.url` |
|
||||||
|
| `total_tokens` | 누적 합계 |
|
||||||
|
| `input_tokens`, `cache_creation_tokens`, `cache_read_tokens`, `output_tokens` | 누적 세부 |
|
||||||
|
|
||||||
|
**이슈 번호 추출 패턴** (우선순위 순):
|
||||||
|
- `FEAT-123`, `BUG-45`, `FIX-7`, `TASK-9`, `PROJ-100`, `ISSUE-12`
|
||||||
|
- `closes #45`, `fixes #12`, `resolves #3`, `GH-8`
|
||||||
|
- `#123`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
### 1. Aptabase 도달성 확인 (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_KEY=$(jq -r .app_key .claude/hooks/aptabase.json)
|
||||||
|
HOST=$(jq -r .aptabase_host .claude/hooks/aptabase.json)
|
||||||
|
curl -i -X POST "$HOST/api/v0/event" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "App-Key: $APP_KEY" \
|
||||||
|
-H "User-Agent: ClaudeCodeHook/test" \
|
||||||
|
-d '{
|
||||||
|
"timestamp":"2026-04-07T00:00:00.000Z",
|
||||||
|
"sessionId":"manual-test",
|
||||||
|
"eventName":"claude_commit",
|
||||||
|
"systemProps":{"osName":"Test","sdkVersion":"manual"},
|
||||||
|
"props":{"commit_message":"manual test","total_tokens":1}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`HTTP/1.1 200` + `{}` 이면 성공.
|
||||||
|
|
||||||
|
### 2. Stop 훅 누적 확인
|
||||||
|
|
||||||
|
Claude 와 대화 후:
|
||||||
|
```bash
|
||||||
|
cat .claude/state/aptabase-accum.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`accum.input_tokens`, `output_tokens` 등이 0 이 아니면 정상 동작.
|
||||||
|
|
||||||
|
### 3. 커밋 훅 수동 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/aptabase-commit.sh < /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Aptabase 대시보드에서 이벤트 확인 → `accum` 이 0 으로 리셋되면 성공.
|
||||||
|
|
||||||
|
### 4. E2E 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Claude 와 대화 → 토큰 누적
|
||||||
|
# 2. cat .claude/state/aptabase-accum.json (accum 비어있지 않음 확인)
|
||||||
|
git commit --allow-empty -m "test: aptabase hook #999"
|
||||||
|
# 3. Aptabase 대시보드에서 claude_commit 이벤트 확인
|
||||||
|
# 4. cat .claude/state/aptabase-accum.json (accum 리셋 확인)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 누적 상태 파일
|
||||||
|
|
||||||
|
`<git-root>/.claude/state/aptabase-accum.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accum": {
|
||||||
|
"input_tokens": 1234,
|
||||||
|
"cache_creation_tokens": 5678,
|
||||||
|
"cache_read_tokens": 9012,
|
||||||
|
"output_tokens": 345
|
||||||
|
},
|
||||||
|
"offsets": {
|
||||||
|
"C:\\Users\\...\\projects\\d--foo\\session-uuid.jsonl": 45678
|
||||||
|
},
|
||||||
|
"last_sent_commit": "abc123def..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `accum`: 현재 누적 중인 토큰 (커밋 시 리셋됨)
|
||||||
|
- `offsets`: 트랜스크립트 JSONL 별로 이미 읽은 byte 위치 (append-only 특성 덕에 중복 집계 방지)
|
||||||
|
- `last_sent_commit`: 마지막으로 Aptabase 에 전송한 HEAD 해시 (중복 전송 방지)
|
||||||
|
|
||||||
|
**수동 리셋:** `rm .claude/state/aptabase-accum.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 엣지 케이스
|
||||||
|
|
||||||
|
| 상황 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| 첫 설치 후 첫 Bash 호출 | `last_sent_commit` 가 빈 상태 → 현재 HEAD 를 기준점으로 기록만. 전송 안 함 |
|
||||||
|
| 네트워크 실패 | `state` 유지. 다음 호출에서 재시도 (누적값 + 새 커밋 병합) |
|
||||||
|
| Claude 가 Bash 로 커밋 | `PostToolUse(Bash)` 와 `.git/hooks/post-commit` 둘 다 발동. `last_sent_commit` 덕에 한 번만 전송 |
|
||||||
|
| 외부(IDE)에서 커밋 | `.git/hooks/post-commit` 만 발동 |
|
||||||
|
| git 저장소 밖에서 실행 | `git rev-parse HEAD` 실패 → 조용히 종료 |
|
||||||
|
| OAuth 로그인 안 됨 (API 키) | `claude_oauth_id = "anonymous"`, `plan = "apikey"` |
|
||||||
|
| `amend`, `rebase`, `cherry-pick` | HEAD 해시가 바뀌므로 모두 포착 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
**누적이 안 쌓임 (`accum` 이 계속 0)**
|
||||||
|
1. `aptabase.json` 의 `enabled: true`
|
||||||
|
2. `app_key`, `aptabase_host` 실제 값인지
|
||||||
|
3. settings.json 의 `Stop` 훅에 `aptabase-accumulate.sh` 등록되어 있는지
|
||||||
|
4. `transcript_path` 가 훅 입력 JSON 에 실제로 있는지 (Claude Code 버전 확인)
|
||||||
|
5. 해당 트랜스크립트 파일 읽기 권한
|
||||||
|
|
||||||
|
**커밋했는데 Aptabase 에 안 뜸**
|
||||||
|
1. settings.json 의 `PostToolUse(Bash)` 훅 등록 확인
|
||||||
|
2. `.git/hooks/post-commit` 이 실제로 있는지: `cat .git/hooks/post-commit`
|
||||||
|
3. `last_sent_commit` 이 이미 현재 HEAD 와 같은지 (이미 전송됨)
|
||||||
|
4. curl 로 직접 POST 했을 때 200 이 오는지 (네트워크/인증 분리 검증)
|
||||||
|
5. `python3 --version` 동작
|
||||||
|
6. git 저장소 안에서 실행 중인지
|
||||||
|
|
||||||
|
**첫 커밋이 무시됨**
|
||||||
|
의도된 동작. 첫 실행 시 `last_sent_commit` 를 현재 HEAD 로 기록하고 종료. 다음 커밋부터 전송.
|
||||||
|
|
||||||
|
**전송 기준점 초기화** (디버깅용)
|
||||||
|
```bash
|
||||||
|
jq '.last_sent_commit = ""' .claude/state/aptabase-accum.json > /tmp/_s && mv /tmp/_s .claude/state/aptabase-accum.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**일시 비활성**
|
||||||
|
`aptabase.json` 의 `enabled` 를 `false` 로. 훅 등록은 유지해도 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제거
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. git post-commit 훅 제거
|
||||||
|
bash .claude/hooks/install-git-hook.sh --uninstall
|
||||||
|
|
||||||
|
# 2. settings.json 에서 Stop / PostToolUse(Bash) 훅 엔트리 제거
|
||||||
|
|
||||||
|
# 3. 파일 삭제 (선택)
|
||||||
|
rm -rf .claude/hooks .claude/state/aptabase-accum.json
|
||||||
|
```
|
||||||
@@ -51,6 +51,26 @@
|
|||||||
"command": "bash .claude/hooks/stop-handoff-reminder.sh"
|
"command": "bash .claude/hooks/stop-handoff-reminder.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/hooks/aptabase-accumulate.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/hooks/aptabase-commit.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
229
.claude/skills/aptabase/SKILL.md
Normal file
229
.claude/skills/aptabase/SKILL.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: aptabase
|
||||||
|
description: Aptabase 커밋 기준 토큰 기록 훅을 설치/검증/디버깅한다. 사용자가 "aptabase 설정", "aptabase 테스트", "토큰 기록 확인", "누적 토큰"을 요청할 때 사용.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Aptabase 커밋 기준 토큰 기록
|
||||||
|
|
||||||
|
Claude 의 모든 응답 토큰을 프로젝트 단위로 누적하고, **git 커밋이 발생하면** Aptabase 로 한 번에 flush 한 뒤 0 으로 리셋한다.
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/hooks/
|
||||||
|
├── aptabase.json # 설정 (app_key, aptabase_host, user_name)
|
||||||
|
├── aptabase_common.py # 공통 유틸 (Python)
|
||||||
|
├── aptabase-accumulate.sh # Stop 훅 진입점
|
||||||
|
├── aptabase-accumulate.py # Stop 로직 — 토큰 누적만
|
||||||
|
├── aptabase-commit.sh # 커밋 flush 진입점 (Claude 훅 + git 훅 공용)
|
||||||
|
├── aptabase-commit.py # 커밋 감지 + Aptabase POST + 리셋
|
||||||
|
└── install-git-hook.sh # .git/hooks/post-commit 설치 스크립트
|
||||||
|
|
||||||
|
.claude/state/
|
||||||
|
└── aptabase-accum.json # 누적 상태 (자동 생성, git root 기준)
|
||||||
|
|
||||||
|
.git/hooks/
|
||||||
|
└── post-commit # install-git-hook.sh 가 설치 (모든 커밋 포착)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 동작 방식
|
||||||
|
|
||||||
|
| 시점 | 훅 | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| 응답 완료 | `Stop` → `aptabase-accumulate.sh` | 트랜스크립트 새 영역 파싱 → `state.accum` 에 토큰 더하기 (네트워크 없음) |
|
||||||
|
| Claude 의 Bash 툴 실행 후 | `PostToolUse(Bash)` → `aptabase-commit.sh` | HEAD 변화 감지. 바뀌었으면 flush → POST → 리셋 |
|
||||||
|
| **모든 커밋** (IDE / 터미널 / GUI) | `.git/hooks/post-commit` → `aptabase-commit.sh` | 같은 로직. Claude 밖에서 이루어진 커밋도 포착 |
|
||||||
|
|
||||||
|
`aptabase-commit.py` 는 stdin 에 JSON 이 있으면 Claude 훅 모드, 비어 있으면 git 훅 모드로 동작한다. 두 경로가 중복 실행돼도 `last_sent_commit` 비교 덕에 이중 전송되지 않는다.
|
||||||
|
|
||||||
|
- **누적 범위**: `<git-root>/.claude/state/aptabase-accum.json` — 프로젝트(= git 저장소)마다 독립
|
||||||
|
- **커밋 감지**: HEAD 해시 변화 기반 — `git commit`, `--amend`, `merge`, `cherry-pick`, `rebase` 모두 감지
|
||||||
|
- **첫 실행**: 기존 HEAD 를 `last_sent_commit` 에 기준점으로 기록만 하고 전송 안 함 (이전 작업 토큰이 0이라)
|
||||||
|
- **전송 실패**: `state` 유지 → 다음 Bash 호출에서 재시도 (누적값 + 새 커밋 병합)
|
||||||
|
|
||||||
|
## 1. 설정
|
||||||
|
|
||||||
|
`.claude/hooks/aptabase.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"app_key": "A-SH-XXXXXXXXXX",
|
||||||
|
"aptabase_host": "https://aptabase.example.com",
|
||||||
|
"user_name": "kim"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 키 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | false 로 두면 훅이 즉시 종료 (일시 비활성) |
|
||||||
|
| `app_key` | Aptabase App Key (self-hosted 는 `A-SH-` 접두사) |
|
||||||
|
| `aptabase_host` | Aptabase 인스턴스 base URL |
|
||||||
|
| `user_name` | props.user_name 으로 전송할 사용자 식별자 |
|
||||||
|
|
||||||
|
## 2. settings.json 에 훅 등록
|
||||||
|
|
||||||
|
`.claude/settings.json` 의 `hooks` 에 두 항목 모두 등록:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "bash .claude/hooks/aptabase-accumulate.sh" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "bash .claude/hooks/aptabase-commit.sh" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이미 다른 Stop / PostToolUse 훅이 있으면 같은 `hooks` 배열에 추가한다.
|
||||||
|
|
||||||
|
## 2-1. git post-commit 훅 설치 (필수)
|
||||||
|
|
||||||
|
Claude 외부에서 이루어진 커밋(IDE, 터미널, git GUI 등)도 포착하려면 `.git/hooks/post-commit` 을 설치해야 한다. 프로젝트 루트에서 한 번만 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/install-git-hook.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
이미 `.git/hooks/post-commit` 이 존재하면 에러가 나면서 병합 방법을 안내한다. 기존 훅을 덮어쓰려면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/install-git-hook.sh --force # 기존 훅을 .bak 로 백업하고 덮어쓰기
|
||||||
|
```
|
||||||
|
|
||||||
|
제거:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .claude/hooks/install-git-hook.sh --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
> `.git/hooks/` 는 버전 관리되지 않으므로 **clone 할 때마다 재설치 필요**하다.
|
||||||
|
> CI/자동 설정 스크립트에서 프로젝트 초기화 시 함께 실행하는 것을 권장한다.
|
||||||
|
|
||||||
|
## 3. 전송되는 payload
|
||||||
|
|
||||||
|
Aptabase 이벤트 이름은 **`claude_commit`** (고정).
|
||||||
|
|
||||||
|
### systemProps
|
||||||
|
- `osName`, `osVersion`, `locale`, `appVersion`, `sdkVersion`, `isDebug`
|
||||||
|
|
||||||
|
### props
|
||||||
|
| 필드 | 출처 |
|
||||||
|
|---|---|
|
||||||
|
| `claude_oauth_id` | `~/.claude/config.json` 의 `oauthAccount.emailAddress` (없으면 `anonymous`) |
|
||||||
|
| `plan` | `oauthAccount.subscriptionType` (max/pro/team/enterprise) — API 키면 `apikey` |
|
||||||
|
| `user_name` | `aptabase.json` 의 `user_name` |
|
||||||
|
| `local_ip` | UDP connect 트릭으로 추출 (패킷 미전송) |
|
||||||
|
| `public_ip` | `api.ipify.org` / `ifconfig.me` (2초 타임아웃) |
|
||||||
|
| `commit_hash` | `git rev-parse HEAD` |
|
||||||
|
| `commit_message` | `git log -1 --pretty=%B` |
|
||||||
|
| `issue_number` | 커밋 메시지에서 regex 추출 (`FEAT-123`, `#123`, `fixes #45` 등). 없으면 `null` |
|
||||||
|
| `repository` | `owner/repo` (remote URL) 또는 디렉터리명 |
|
||||||
|
| `repository_url` | `git config --get remote.origin.url` |
|
||||||
|
| `total_tokens` | 누적 합계 |
|
||||||
|
| `input_tokens`, `cache_creation_tokens`, `cache_read_tokens`, `output_tokens` | 누적 세부 |
|
||||||
|
|
||||||
|
> Aptabase 최상위 `sessionId` 는 epoch+random 으로 매 이벤트 새로 생성된다.
|
||||||
|
> Claude 세션 UUID 는 사용하지 않는다.
|
||||||
|
|
||||||
|
## 4. 연결 테스트
|
||||||
|
|
||||||
|
**단계 1 — Aptabase 도달 가능성 확인 (curl):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_KEY=$(jq -r .app_key .claude/hooks/aptabase.json)
|
||||||
|
HOST=$(jq -r .aptabase_host .claude/hooks/aptabase.json)
|
||||||
|
curl -i -X POST "$HOST/api/v0/event" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "App-Key: $APP_KEY" \
|
||||||
|
-H "User-Agent: ClaudeCodeHook/test" \
|
||||||
|
-d '{
|
||||||
|
"timestamp":"2026-04-07T00:00:00.000Z",
|
||||||
|
"sessionId":"manual-test",
|
||||||
|
"eventName":"claude_commit",
|
||||||
|
"systemProps":{"osName":"Test","sdkVersion":"manual"},
|
||||||
|
"props":{"commit_message":"manual test","total_tokens":1}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
200 + `{}` 이면 Aptabase 수신 OK.
|
||||||
|
|
||||||
|
**단계 2 — 훅 직접 실행 (Stop 누적):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"transcript_path":"","cwd":"'"$PWD"'","hook_event_name":"Stop"}' \
|
||||||
|
| bash .claude/hooks/aptabase-accumulate.sh
|
||||||
|
cat .claude/state/aptabase-accum.json
|
||||||
|
```
|
||||||
|
|
||||||
|
transcript_path 가 비어있으면 accum 은 0 그대로지만, state 파일은 생성돼야 한다.
|
||||||
|
|
||||||
|
**단계 3 — 실제 동작 확인:**
|
||||||
|
|
||||||
|
1. Claude 와 대화해서 토큰 쌓기
|
||||||
|
2. `cat .claude/state/aptabase-accum.json` 으로 누적 확인
|
||||||
|
3. `git commit` 실행
|
||||||
|
4. Aptabase 대시보드에서 `claude_commit` 이벤트 확인
|
||||||
|
5. `cat .claude/state/aptabase-accum.json` — `accum` 이 0 으로 리셋, `last_sent_commit` 갱신됐는지 확인
|
||||||
|
|
||||||
|
## 5. 누적 상태 파일 포맷
|
||||||
|
|
||||||
|
`<git-root>/.claude/state/aptabase-accum.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accum": {
|
||||||
|
"input_tokens": 1234,
|
||||||
|
"cache_creation_tokens": 5678,
|
||||||
|
"cache_read_tokens": 9012,
|
||||||
|
"output_tokens": 345
|
||||||
|
},
|
||||||
|
"offsets": {
|
||||||
|
"C:\\Users\\...\\projects\\d--foo\\session-uuid.jsonl": 45678
|
||||||
|
},
|
||||||
|
"last_sent_commit": "abc123def..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `offsets`: 트랜스크립트 JSONL 파일별로 이미 읽은 byte 위치. append-only 특성 덕에 중복 집계 방지
|
||||||
|
- 수동 리셋이 필요하면 `rm .claude/state/aptabase-accum.json`
|
||||||
|
|
||||||
|
## 6. 트러블슈팅
|
||||||
|
|
||||||
|
**누적이 안 쌓임 (`accum` 이 계속 0):**
|
||||||
|
1. `aptabase.json` 의 `enabled: true`
|
||||||
|
2. `app_key`, `aptabase_host` 실제 값
|
||||||
|
3. settings.json 의 Stop 훅에 `aptabase-accumulate.sh` 등록
|
||||||
|
4. `transcript_path` 가 훅 입력 JSON 에 실제로 있는지
|
||||||
|
5. 해당 파일 읽기 권한
|
||||||
|
|
||||||
|
**커밋했는데 Aptabase 에 안 뜸:**
|
||||||
|
1. settings.json 의 PostToolUse(Bash) 훅에 `aptabase-commit.sh` 등록
|
||||||
|
2. `last_sent_commit` 가 이미 현재 HEAD 와 같은지 (이미 전송된 상태)
|
||||||
|
3. curl 로 직접 POST 했을 때 200 이 오는지 (네트워크/인증 분리 검증)
|
||||||
|
4. `python3 --version` 동작
|
||||||
|
5. git 저장소 안에서 동작 중인지 (`git rev-parse HEAD` 성공해야 함)
|
||||||
|
|
||||||
|
**첫 커밋이 무시됨:**
|
||||||
|
의도된 동작. `last_sent_commit` 가 빈 상태일 때는 현재 HEAD 를 기준점으로 기록만 하고 전송하지 않는다. 다음 커밋부터 전송된다.
|
||||||
|
|
||||||
|
**전송 기준점 초기화:**
|
||||||
|
```bash
|
||||||
|
jq '.last_sent_commit = ""' .claude/state/aptabase-accum.json > /tmp/_s && mv /tmp/_s .claude/state/aptabase-accum.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**일시 비활성:**
|
||||||
|
`aptabase.json` 에서 `enabled: false`. 훅 등록은 유지해도 된다.
|
||||||
165
EG-BIMModeler_Source/MainWindow.xaml
Normal file
165
EG-BIMModeler_Source/MainWindow.xaml
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<mah:MetroWindow x:Class="EGModeler.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
||||||
|
xmlns:eg="http://hanmaceng.co.kr/HmEG"
|
||||||
|
xmlns:cb="clr-namespace:Editor.CommandControl;assembly=Editor04.CommandControl"
|
||||||
|
xmlns:local="clr-namespace:EGModeler"
|
||||||
|
xmlns:v="clr-namespace:EditorCore.View;assembly=EditorCore"
|
||||||
|
xmlns:control="clr-namespace:EGModeler.Controls"
|
||||||
|
xmlns:corecontrol="clr-namespace:EditorCore.Controls;assembly=EditorCore"
|
||||||
|
xmlns:commonUI="clr-namespace:HmCommonUI;assembly=HmCommonUI"
|
||||||
|
xmlns:vm="clr-namespace:EGModeler.ViewModels"
|
||||||
|
xmlns:enums="clr-namespace:EditorCore.Enums;assembly=EditorCore"
|
||||||
|
xmlns:localization="clr-namespace:Editor.Localization;assembly=Editor01.Localization"
|
||||||
|
MinWidth="1345" MinHeight="600" d:Width="1920" d:Height="1080" Width="1280" Height="720"
|
||||||
|
mc:Ignorable="d" WindowState="{Binding WindowState}" mah:DialogParticipation.Register="{Binding}"
|
||||||
|
ShowTitleBar="True" ShowMinButton="False" ShowCloseButton="False" ShowMaxRestoreButton="False"
|
||||||
|
Title="EGModeler" Name="root" Background="Transparent" Loaded="MainWindow_Loaded">
|
||||||
|
|
||||||
|
<mah:MetroWindow.TitleTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<control:ModelerUpperBar x:Name="titleBar" Loaded="titleBar_Loaded"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</mah:MetroWindow.TitleTemplate>
|
||||||
|
|
||||||
|
<mah:MetroWindow.Resources>
|
||||||
|
<local:BoolToParameterConverter x:Key="BoolToParameterConverter"/>
|
||||||
|
<Style TargetType="commonUI:StrokeText" BasedOn="{StaticResource {x:Type commonUI:StrokeText}}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Opacity" Value="0.4"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</mah:MetroWindow.Resources>
|
||||||
|
|
||||||
|
|
||||||
|
<Grid x:Name="MainWindowContentGrid">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="3"/>
|
||||||
|
<RowDefinition Height="0" MaxHeight="400"/>
|
||||||
|
<RowDefinition Height="70"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="75"/>
|
||||||
|
<ColumnDefinition Width="315"/>
|
||||||
|
<ColumnDefinition MinWidth="200" Width="*"/>
|
||||||
|
<ColumnDefinition Width="2"/>
|
||||||
|
<ColumnDefinition MinWidth="200" Width="*"/>
|
||||||
|
<ColumnDefinition Width="75"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<localization:DummyControl Language="ko_KR"/>
|
||||||
|
<Grid x:Name="MainViewportGrid" Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" Grid.ColumnSpan="6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=2}" />
|
||||||
|
<ColumnDefinition Width="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=380}" MinWidth="{Binding IsSidePanelVisible, Converter={StaticResource BoolToParameterConverter}, ConverterParameter=380}" MaxWidth="1000"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid Grid.Column="0">
|
||||||
|
<control:LeftViewportPanel AllowDrop="True" DragEnter="ViewPortPanel_DragEnter" Drop="ViewPortPanel_Drop" />
|
||||||
|
<corecontrol:PropertySummaryBar VerticalAlignment="Top" HorizontalAlignment="Left" Visibility="Hidden" Margin="330 0 0 0"/>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom" HorizontalAlignment="Left">
|
||||||
|
<Border CornerRadius="0 5 5 0" Background="#ECE4D2" BorderBrush="Black" BorderThickness="1" Visibility="{Binding IsLeftSidePanelVisible, Converter={StaticResource boolToVisibilityConverter}}">
|
||||||
|
<StackPanel Width="140">
|
||||||
|
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Bottom" IsEnabled="{Binding IsUsingDefaultSelectFilter}" Margin="0 2">
|
||||||
|
<commonUI:StrokeText Fill="{DynamicResource SolidColorBrushTable.Type4.Normal.Foreground}" FontWeight="SemiBold" Text="{DynamicResource Control@SELECTIONFILTER}" Margin="2 5 2 2"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@POINT}" IsChecked="{Binding Point, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@LINE}" IsChecked="{Binding Curve, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@MESH}" IsChecked="{Binding Mesh, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@DIMENSION}" IsChecked="{Binding Dimension, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@TEXT}" IsChecked="{Binding Text, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@HATCH}" IsEnabled="False"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@BLOCK}" IsChecked="{Binding Block, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@GROUP}" IsEnabled="False"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@BILLBOARD}" IsChecked="{Binding Billboard, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@CONTROL_POINT}" IsChecked="{Binding ControlPoint, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@VERTEX}" IsChecked="{Binding TopologyVertex, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@EDGE}" IsChecked="{Binding TopologyEdge, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@FACE}" IsChecked="{Binding TopologyFace, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@OTHER}" IsChecked="{Binding Other, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Bottom" IsEnabled="{Binding IsOsnap}" Margin="0 2">
|
||||||
|
<commonUI:StrokeText Fill="{DynamicResource SolidColorBrushTable.Type4.Normal.Foreground}" FontWeight="SemiBold" Text="{DynamicResource Control@OSNAP}" Margin="2 5 2 2"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@POINT}" IsChecked="{Binding PointOsnap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@NEAR_POINT}" IsChecked="{Binding NearOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@END_POINT}" IsChecked="{Binding EndOsnap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@MID_POINT}" IsChecked="{Binding MidpointOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@QUADRANT_POINT}" IsChecked="{Binding QuadrantOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@CENTER_POINT}" IsChecked="{Binding CenterOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@TANGENT_POINT}" IsChecked="{Binding TangentOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@VERTEX}" IsChecked="{Binding VertexOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@PERPENDICULAR_POINT}" IsChecked="{Binding PerpendicularOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@EXTENSION}" IsEnabled="False"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@INTERSECTION}" IsChecked="{Binding IntersectionOsnap, Mode=TwoWay}"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@KNOT_POINT}" IsChecked="{Binding KnotOsnap, Mode=TwoWay}" IsEnabled="False"/>
|
||||||
|
<commonUI:HmCheckBox Margin="2 3" Content="{DynamicResource Control@PROJECTION_POINT}" IsChecked="{Binding ProjectionOsnap, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<corecontrol:FlipToggleButton HorizontalAlignment="Left" VerticalAlignment="Bottom" IsChecked="{Binding IsLeftSidePanelVisible, Mode=TwoWay}" IsLeftFlip="True" Margin="-1 0 0 10"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="15 5 0 5">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="330"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid Visibility="{Binding CursorCoordinateDisplay, Converter={StaticResource boolToVisibilityConverter}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<commonUI:StrokeText Grid.Column="0" Text="{Binding MousePosX}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
|
||||||
|
<commonUI:StrokeText Grid.Column="1" Text="{Binding MousePosY}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
|
||||||
|
<commonUI:StrokeText Grid.Column="2" Text="{Binding MousePosZ}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" VerticalAlignment="Bottom"/>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Grid.Column="3" Margin="0 0 10 0">
|
||||||
|
<commonUI:StrokeText Text="{Binding ShowCoordOpt}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold" Visibility="{Binding CursorCoordinateDisplay, Converter={StaticResource boolToVisibilityConverter}}"/>
|
||||||
|
<commonUI:StrokeText Text="{Binding CurrentUnitStr}" Fill="White" StrokeThickness="2" FontSize="13" FontWeight="Bold"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<corecontrol:FlipToggleButton HorizontalAlignment="Right" IsChecked="{Binding IsSidePanelVisible, Mode=TwoWay}" Margin="0 0 -1 0"/>
|
||||||
|
<GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False"/>
|
||||||
|
<v:SidePanel Grid.Column="2" Visibility="{Binding IsSidePanelVisible, Converter={StaticResource boolToVisibilityConverter}}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<GridSplitter Grid.Row="1" Grid.Column="2" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False" MouseDoubleClick="GridSplitter_MouseDoubleClick"/>
|
||||||
|
|
||||||
|
<Button Grid.Row="3" Grid.Column="0" HorizontalAlignment="Right" Style="{DynamicResource LowerExpandButtonButtonStyle}" IsDefault="True" Content="{DynamicResource Control@SETTINGS}" Command="{Binding SeeAllCommands}" CommandParameter="{x:Static enums:AllCommandsWindowCategory.Settings}"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3" Grid.Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="0" Focusable="False" Content="{DynamicResource Control@OSNAP}" IsChecked="{Binding IsOsnap}" Kind="Object_Snap" />
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="1" Focusable="False" Content="{DynamicResource Control@GRIDSNAP}" IsChecked="{Binding GridSnap}" Kind="Grid_Snap" />
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="0" Grid.Column="2" Focusable="False" Content="{DynamicResource Control@SNAP_TRACE}" IsChecked="{Binding SnapTrace}" Kind="Custom" PathData="{DynamicResource Object_Trace_DrawingImage}"/>
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="0" Focusable="False" Content="{DynamicResource Control@ORTHOMODE}" IsChecked="{Binding OrthoMode}" Kind="Orthogonal" />
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="1" Focusable="False" Content="{DynamicResource Control@PLANARMODE}" IsChecked="{Binding PlanarMode}" Kind="Orthogonal" />
|
||||||
|
<commonUI:HmFooterToggleButton Grid.Row="1" Grid.Column="2" Focusable="False" Content="{DynamicResource Control@SELECTIONFILTER}" IsChecked="{Binding IsUsingDefaultSelectFilter}" Kind="Visual_Effect" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<cb:CommandPanel Grid.Row="2" Grid.Column="2" Grid.RowSpan="2" SizeChanged="CommandPanel_SizeChanged" InstanceIdx="0"/>
|
||||||
|
|
||||||
|
<GridSplitter Grid.Row="3" Grid.Column="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" Focusable="False"/>
|
||||||
|
|
||||||
|
<control:CommandTabPanel Grid.Row="3" Grid.Column="4"/>
|
||||||
|
|
||||||
|
<Button Grid.Row="3" Grid.Column="5" HorizontalAlignment="Right" Style="{DynamicResource LowerExpandButtonButtonStyle}" IsDefault="True" Content="{DynamicResource Control@SEEALLCOMMANDS}" Command="{Binding SeeAllCommands}" CommandParameter="{x:Static enums:AllCommandsWindowCategory.Create}"/>
|
||||||
|
|
||||||
|
<Grid x:Name="WidzetCanvas" Grid.ColumnSpan="100" Grid.RowSpan="100"/>
|
||||||
|
|
||||||
|
<Grid x:Name="settingOptionPanel" HorizontalAlignment="Right" VerticalAlignment="Top" Grid.Column="4" Grid.ColumnSpan="2" Height="100" Width="408"/>
|
||||||
|
</Grid>
|
||||||
|
</mah:MetroWindow>
|
||||||
441
EG-BIMModeler_Source/MainWindow.xaml.cs
Normal file
441
EG-BIMModeler_Source/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
using Editor.AppManager;
|
||||||
|
using Editor.CommandControl.ViewModel;
|
||||||
|
using Editor.CommandCore;
|
||||||
|
using Editor.CommandCustom;
|
||||||
|
using Editor.PluginInterface;
|
||||||
|
using Editor.WidzetPluginInterface;
|
||||||
|
using EditorCore;
|
||||||
|
using EGModeler.Controls;
|
||||||
|
using EGModeler.ViewModels;
|
||||||
|
using HmEG;
|
||||||
|
using HmEG.Controls;
|
||||||
|
using MahApps.Metro.Controls;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
|
||||||
|
|
||||||
|
namespace EGModeler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interaction logic for MainWindow.xaml
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow : MetroWindow
|
||||||
|
{
|
||||||
|
private ECommandManager ECommandManager { get => ECommandManager.GetCommandManager(0); }
|
||||||
|
private SnapAssist? _snap;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
//테마 테스트
|
||||||
|
//ThemeManager.Instance.SwitchTheme(ThemeType.ColorThemeBlack);
|
||||||
|
InitializeComponent();
|
||||||
|
MainWindowLoadingHelper.Instance.IsInitialized = true;
|
||||||
|
this.DataContext = MainWindowViewModel.Instance;
|
||||||
|
WidzetPlugin.AttachWidzet();
|
||||||
|
HmEGAppManager.GetAppManager(0).SettingManager.PropertyChanged += PropertyChanged;
|
||||||
|
//#if !DEBUG //모델러 모드 강제 (배포 전 작업, 주석 해제 후 사용)
|
||||||
|
// HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode = ModelerMode.ModelingNViewing;
|
||||||
|
//#endif
|
||||||
|
_snap = new SnapAssist(this);
|
||||||
|
_snap.Attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PropertyChanged(object? sender, System.Reflection.PropertyInfo e)
|
||||||
|
{
|
||||||
|
if(e.Name.Equals("IsSidePanelVisible"))
|
||||||
|
{
|
||||||
|
var MainViewportGrid = this.FindChild<Grid>("MainViewportGrid");
|
||||||
|
var isShow = HmEGAppManager.GetAppManager(0).SettingManager.IsSidePanelVisible;
|
||||||
|
if(isShow)
|
||||||
|
{
|
||||||
|
MainViewportGrid.ColumnDefinitions[1].Width = new GridLength(2);
|
||||||
|
MainViewportGrid.ColumnDefinitions[2].Width = new GridLength(MainViewportGrid.ColumnDefinitions[2].Width.Value, GridUnitType.Auto);
|
||||||
|
MainViewportGrid.ColumnDefinitions[2].MinWidth = 370;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MainViewportGrid.ColumnDefinitions[1].Width = new GridLength(0);
|
||||||
|
MainViewportGrid.ColumnDefinitions[2].Width = new GridLength(0);
|
||||||
|
MainViewportGrid.ColumnDefinitions[2].MinWidth = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//#if !DEBUG //모델러 모드 강제 (배포 전 작업, 주석 해제 후 사용)
|
||||||
|
// if (e.Name.Equals("ModelerMode"))
|
||||||
|
// {
|
||||||
|
// var mode = HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode;
|
||||||
|
// if (mode != ModelerMode.ModelingNViewing)
|
||||||
|
// {
|
||||||
|
// HmEGAppManager.GetAppManager(0).SettingManager.ModelerMode = ModelerMode.ModelingNViewing;
|
||||||
|
// HmEGAppManager.GetAppManager(0).UpdateSettingJson();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private void titleBar_Loaded(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if(sender != null)
|
||||||
|
{
|
||||||
|
this.TitleBarHeight = (int)((System.Windows.Controls.UserControl)sender).ActualHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLength tmpHeight;
|
||||||
|
|
||||||
|
GradientTransparencySettingPanel gradientTransparencySettingPanel = new GradientTransparencySettingPanel();
|
||||||
|
private AdornerLayer _adornerLayer;
|
||||||
|
private CustomAdorner _adorner;
|
||||||
|
|
||||||
|
private void MainWindow_Loaded(object sender, System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_adornerLayer = AdornerLayer.GetAdornerLayer(settingOptionPanel);
|
||||||
|
|
||||||
|
if (_adorner != null)
|
||||||
|
{
|
||||||
|
_adornerLayer.Remove(_adorner);
|
||||||
|
_adorner = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollContentPresenter scrollPresenter = new ScrollContentPresenter
|
||||||
|
{
|
||||||
|
Content = gradientTransparencySettingPanel
|
||||||
|
};
|
||||||
|
|
||||||
|
_adorner = new CustomAdorner(settingOptionPanel, scrollPresenter);
|
||||||
|
_adornerLayer.Add(_adorner);
|
||||||
|
|
||||||
|
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
|
||||||
|
if (MainWindowContentGrid != null)
|
||||||
|
{
|
||||||
|
MainWindowContentGrid.RowDefinitions[2].Height = new GridLength(HmEGAppManager.GetAppManager(0).SettingManager.MainCommandPanelHeight);
|
||||||
|
tmpHeight = MainWindowContentGrid.RowDefinitions[2].Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
var screens = System.Windows.Forms.Screen.AllScreens;
|
||||||
|
|
||||||
|
//뷰포트가 전부 로드되면 로딩창 닫는다.
|
||||||
|
var viewports = this.FindChildren<EGViewport>();
|
||||||
|
foreach(var viewport in viewports)
|
||||||
|
{
|
||||||
|
viewCount++;
|
||||||
|
viewport.OnRendered += Viewport_OnRendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HmEGAppManager.GetAppManager(0).SettingManager.IsSecondWindowOn)
|
||||||
|
{
|
||||||
|
var secondWindow = new SecondWindow();
|
||||||
|
var primaryScreen = screens.FirstOrDefault(s => s.Primary);
|
||||||
|
|
||||||
|
if (primaryScreen != null)
|
||||||
|
{
|
||||||
|
var secondaryScreen = screens
|
||||||
|
.Where(s => s.Bounds.X > primaryScreen.Bounds.X)
|
||||||
|
.OrderBy(s => s.Bounds.X)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
var dpi = GetDpiForScreen();
|
||||||
|
if (secondaryScreen != null)
|
||||||
|
{
|
||||||
|
secondWindow.Left = (secondaryScreen.WorkingArea.Left) / dpi;
|
||||||
|
secondWindow.Top = (secondaryScreen.WorkingArea.Top) / dpi;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
secondWindow.Left = (primaryScreen.WorkingArea.Left) / dpi;
|
||||||
|
secondWindow.Top = (primaryScreen.WorkingArea.Top) / dpi;
|
||||||
|
}
|
||||||
|
secondWindow.Show();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
secondWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewports2 = secondWindow.FindChildren<EGViewport>();
|
||||||
|
if (HmEGAppManager.GetAppManager(0).SettingManager.IsSecondWindowOn)
|
||||||
|
{
|
||||||
|
//세컨드윈도우 활성화를위한
|
||||||
|
foreach (var viewport in viewports2)
|
||||||
|
{
|
||||||
|
viewCount2++;
|
||||||
|
viewport.OnRendered += Viewport_OnRendered2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var viewport in viewports2)
|
||||||
|
{
|
||||||
|
HmEGAppManager.GetAppManager(0).UnRegisterViewport(viewport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalViewCount = viewCount + viewCount2;
|
||||||
|
MainWindowLoadingHelper.Instance.IsLoaded = true;
|
||||||
|
Application.Current.MainWindow = this;
|
||||||
|
IsSetLayout = true;
|
||||||
|
CommandPanelViewModel.IncludedControls.Add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int totalViewCount = 0;
|
||||||
|
//로드안된 뷰포트 수
|
||||||
|
private int viewCount = 0;
|
||||||
|
private async void Viewport_OnRendered(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var host = (DX11ImageSourceRenderHost)sender;
|
||||||
|
((EGViewport)host.Viewport).OnRendered -= Viewport_OnRendered;
|
||||||
|
viewCount--;
|
||||||
|
totalViewCount--;
|
||||||
|
if (totalViewCount == 0)
|
||||||
|
{
|
||||||
|
if (App.LoadingWindow != null)
|
||||||
|
{
|
||||||
|
if (App.LoadingWindow.Dispatcher.CheckAccess())
|
||||||
|
App.LoadingWindow.Close();
|
||||||
|
else
|
||||||
|
App.LoadingWindow.Dispatcher.Invoke(() => App.LoadingWindow.Close());
|
||||||
|
}
|
||||||
|
ResourceManager.Instance.GetMaterialImages();
|
||||||
|
}
|
||||||
|
if (viewCount == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PluginLoader.LoadProjectPluginsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { }
|
||||||
|
|
||||||
|
//모든 뷰포트가 로드되면 로딩창 닫기전에 메인창 활성화 - 안하면 뒤로간다
|
||||||
|
this.Activate();
|
||||||
|
HmEGAppManager.GetAppManager(0).StartupCommandManager.ExecuteStartCommands();
|
||||||
|
HmEG_DebugWidzetPlugin.AttachDebugTabs();
|
||||||
|
Conference_DebugWidzetPlugin.AttachDebugTabs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//로드안된 두번째 윈도우 뷰포트 수
|
||||||
|
private int viewCount2 = 0;
|
||||||
|
private void Viewport_OnRendered2(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var host = (DX11ImageSourceRenderHost)sender;
|
||||||
|
((EGViewport)host.Viewport).OnRendered -= Viewport_OnRendered2;
|
||||||
|
viewCount2--;
|
||||||
|
totalViewCount--;
|
||||||
|
//뷰포트가 로드되면 활성화
|
||||||
|
if (viewCount2 == 0)
|
||||||
|
{
|
||||||
|
Window.GetWindow((EGViewport)host.Viewport).Activate();
|
||||||
|
}
|
||||||
|
if (totalViewCount == 0)
|
||||||
|
{
|
||||||
|
App.LoadingWindow.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
App.LoadingWindow.Close();
|
||||||
|
//App.LoadingWindow.Dispatcher.InvokeShutdown();
|
||||||
|
});
|
||||||
|
ResourceManager.Instance.GetMaterialImages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private double GetDpiForScreen()
|
||||||
|
{
|
||||||
|
var source = PresentationSource.FromVisual(this);
|
||||||
|
var matrix = source.CompositionTarget.TransformToDevice;
|
||||||
|
|
||||||
|
double dpi = matrix.M11 * 96.0;
|
||||||
|
|
||||||
|
return dpi / 96.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ViewPortPanel_DragEnter(object sender, System.Windows.DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
|
{
|
||||||
|
e.Effects = DragDropEffects.Copy;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.Effects = DragDropEffects.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> targetFiles;
|
||||||
|
|
||||||
|
readonly Dictionary<string, string> extensions = new Dictionary<string, string>()
|
||||||
|
{
|
||||||
|
{ "HANMAC Engineering Format", ".hmeg" },
|
||||||
|
{ "AutoCAD Drawing", ".dwg" },
|
||||||
|
{ "AutoCAD Drawing Exchange", ".dxf" },
|
||||||
|
{ "GRIMI Drawing File", ".grm" },
|
||||||
|
{ "Wavefront Object Format", ".obj" },
|
||||||
|
//{ "Rhino 3D Model", ".3dm" },
|
||||||
|
{ "WPB File", ".wpb" },
|
||||||
|
{ "WPB_IFC File", ".wpb_ifc" },
|
||||||
|
{ "Industry Foundation Classes", ".ifc" },
|
||||||
|
{ "Triangulated Irregular Network", ".tins" },
|
||||||
|
{ "3D Studio", ".3ds" },
|
||||||
|
{ "Collada File", ".dae" },
|
||||||
|
{ "MotionBuilder", ".fbx" },
|
||||||
|
{ "glTF Binary File", ".glb" },
|
||||||
|
{ "glTF Text File", ".gltf" },
|
||||||
|
{ "XML File", ".xml" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ViewPortPanel_Drop(object sender, System.Windows.DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
|
{
|
||||||
|
string[] files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
|
|
||||||
|
var allowedExtensions = extensions.Values.Select(ext => ext.ToLower()).ToList();
|
||||||
|
|
||||||
|
var validFiles = files.Where(file =>
|
||||||
|
{
|
||||||
|
string extension = System.IO.Path.GetExtension(file).ToLower();
|
||||||
|
return allowedExtensions.Contains(extension);
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
if (validFiles.Length > 1)
|
||||||
|
{
|
||||||
|
if (!LoadingWindowHelperThread.Instance.IsOpen)
|
||||||
|
LoadingWindowHelperThread.Instance.Open();
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("! ImportByPath ");
|
||||||
|
for (int i = 0; i < files.Count(); i++)
|
||||||
|
{
|
||||||
|
sb.Append("\"" + files[i] + "\"");
|
||||||
|
if (i != files.Count() - 1)
|
||||||
|
sb.Append("\u001F,");
|
||||||
|
}
|
||||||
|
ECommandManager.ExcuteCommand(sb.ToString());
|
||||||
|
}
|
||||||
|
else if(validFiles.Length == 1)
|
||||||
|
{
|
||||||
|
string file = validFiles[0];
|
||||||
|
var owner = Application.Current.Windows.OfType<MainWindow>().FirstOrDefault();
|
||||||
|
MessageBoxResult result;
|
||||||
|
if (owner != null)
|
||||||
|
{
|
||||||
|
result = MessageBox.Show(
|
||||||
|
owner,
|
||||||
|
"파일을 Import 하시겠습니까?\n'아니오'를 선택하면 Open됩니다.",
|
||||||
|
"파일 처리 옵션",
|
||||||
|
MessageBoxButton.YesNoCancel,
|
||||||
|
MessageBoxImage.Question
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = MessageBox.Show(
|
||||||
|
"파일을 Import 하시겠습니까?\n'아니오'를 선택하면 Open됩니다.",
|
||||||
|
"파일 처리 옵션",
|
||||||
|
MessageBoxButton.YesNoCancel,
|
||||||
|
MessageBoxImage.Question
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
if (!LoadingWindowHelperThread.Instance.IsOpen)
|
||||||
|
LoadingWindowHelperThread.Instance.Open();
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("! ImportByPath ");
|
||||||
|
foreach (var vfile in validFiles)
|
||||||
|
{
|
||||||
|
sb.Append("\"" + vfile + "\"");
|
||||||
|
}
|
||||||
|
ECommandManager.ExcuteCommand(sb.ToString());
|
||||||
|
}
|
||||||
|
else if (result == MessageBoxResult.No)
|
||||||
|
{
|
||||||
|
HmEGAppManager.GetAppManager(0).FileManager.Open(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsSetLayout;
|
||||||
|
private void CommandPanel_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
|
||||||
|
if (MainWindowContentGrid != null & IsSetLayout)
|
||||||
|
{
|
||||||
|
HmEGAppManager.GetAppManager(0).SettingManager.MainCommandPanelHeight = MainWindowContentGrid.RowDefinitions[2].Height.Value;
|
||||||
|
tmpHeight = MainWindowContentGrid.RowDefinitions[2].Height.Value < 100 ? tmpHeight : MainWindowContentGrid.RowDefinitions[2].Height;
|
||||||
|
HmEGAppManager.GetAppManager(0).UpdateSettingJson();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GridSplitter_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
var MainWindowContentGrid = this.FindChild<Grid>("MainWindowContentGrid");
|
||||||
|
if (MainWindowContentGrid != null)
|
||||||
|
{
|
||||||
|
if(MainWindowContentGrid.RowDefinitions[2].Height.Value == 0)
|
||||||
|
{
|
||||||
|
MainWindowContentGrid.RowDefinitions[2].Height = tmpHeight;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MainWindowContentGrid.RowDefinitions[2].Height = new GridLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BoolToParameterConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool boolValue)
|
||||||
|
{
|
||||||
|
if (boolValue)
|
||||||
|
{
|
||||||
|
if (parameter != null)
|
||||||
|
{
|
||||||
|
return ConvertParameter(parameter, targetType);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException("ConvertBack is not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ConvertParameter(object parameter, Type targetType)
|
||||||
|
{
|
||||||
|
if (parameter is string paramString)
|
||||||
|
{
|
||||||
|
if (targetType == typeof(double))
|
||||||
|
return double.TryParse(paramString, out double result) ? result : 0;
|
||||||
|
if (targetType == typeof(int))
|
||||||
|
return int.TryParse(paramString, out int result) ? result : 0;
|
||||||
|
if (targetType == typeof(GridLength))
|
||||||
|
return double.TryParse(paramString, out double result) ? new GridLength(result) : new GridLength(0);
|
||||||
|
}
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user