commit 809d3bc3e49065a42aec965b0aa508860f04bf55 Author: matsuo makito Date: Fri Apr 3 18:22:33 2026 +0900 スクリプトファイルを追加 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62bd33b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/old diff --git a/TicketSend.py b/TicketSend.py new file mode 100644 index 0000000..de16a94 --- /dev/null +++ b/TicketSend.py @@ -0,0 +1,214 @@ + +# -*- coding: utf-8 -*- +""" +OpenProjectの条件付きチケットを取得して、TeamsへAdaptive Cardで自動投稿します。 +必要環境: pip install requests +""" + +import base64 +import datetime +import json +import os +import sys +import urllib.parse +import requests +from requests.auth import HTTPBasicAuth + +# ====== 設定(config.ini / 環境変数 両対応) ====== +import os, configparser + +CONFIG_FILE = "op_config.ini" +config = configparser.ConfigParser() +read_ok = config.read(CONFIG_FILE) + +# まず config.ini を試し、無ければ環境変数にフォールバック +OP_BASE_URL = None +API_KEY = None +PROJECT_IDENTIFIER = None +API_BASE = None + +if read_ok and "openproject" in config: + section = config["openproject"] + OP_BASE_URL = section.get("base_url", "").strip().rstrip("/") + API_KEY = section.get("api_key", "").strip() + PROJECT_IDENTIFIER = section.get("project_identifier", "").strip() + +# 環境変数(OP_BASE_URL / OP_API_KEY / OP_PROJECT_IDENTIFIER)があれば上書き +OP_BASE_URL = os.getenv("OP_BASE_URL", OP_BASE_URL or "").strip().rstrip("/") +API_KEY = os.getenv("OP_API_KEY", API_KEY or "").strip() +PROJECT_IDENTIFIER = os.getenv("OP_PROJECT_IDENTIFIER", PROJECT_IDENTIFIER or "rmsisutemukai-fa").strip() +API_BASE = f"{OP_BASE_URL}/api/v3" + +if not OP_BASE_URL or not API_KEY: + raise RuntimeError( + "API接続設定が不足しています。config.ini または環境変数(OP_BASE_URL / OP_API_KEY)を設定してください。" + ) + + +OUTPUT_PREFIX = "openproject_hours" +TIMEOUT = 15 + +# ====== セッション(Basic + APIキー) ====== +session = requests.Session() +session.auth = HTTPBasicAuth("apikey", API_KEY) +session.headers.update({ + "Accept": "application/hal+json", + "Content-Type": "application/json" +}) + + +# TeamsワークフローのWebhook URL(作成したフローの「Webhookを受け取る」トリガーURL) +TEAMS_WORKFLOW_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "https://defaulta61f1847023b4699907f786bfcaee1.b2.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/6c6e0154d3064b9384c064c1c83a43c4/triggers/manual/paths/invoke?api-version=1") + + +# ======= ここまで設定 ======= + + +def op_headers_basic(api_key: str): + # OpenProject API v3のBasic認証:ユーザー名にAPIキー、パスワードはダミー(Xなど) + # Authorization: Basic base64(":X") + token = base64.b64encode(f"{api_key}:X".encode("utf-8")).decode("ascii") + return { + "Authorization": f"Basic {token}", + "Accept": "application/json" + } + + +def build_filters_for_today(assignee_ids=None): + """本日を含む期間(startDate <= today AND dueDate >= today)+担当者条件のfiltersを構築""" + today = datetime.date.today().strftime("%Y-%m-%d") + filters = [ + {"startDate": {"operator": "<=d", "values": [today]}}, + {"dueDate": {"operator": ">=d", "values": [today]}}, + ] + """ + filters = [ + { "date": { "operator": "<>d", "values": [today, today] } } + ] + """ + #if assignee_ids: + #filters.append({"assignee": {"operator": "=", "values": assignee_ids}}) + #filters.append({"assignee": {"operator": "<>", "values": ""}}) + # もし「担当者あり(未割当除外)」演算子が環境にあるなら queries/form で確認して置き換え可 + return filters + + +def fetch_work_packages(base_url, api_key, project_identifier=None, assignee_ids=None, page_size=200): + """条件に合致するWork packagesを取得(必要に応じてページング)""" + filters = build_filters_for_today(assignee_ids=assignee_ids) + print("filters:", filters) + # filters_q = urllib.parse.quote(json.dumps(filters)) + + if project_identifier: + url = f"{base_url}/api/v3/projects/{project_identifier}/work_packages" + else: + url = f"{base_url}/api/v3/work_packages" + + + params = { + "filters": json.dumps(filters), # URLエンコードはrequestsに任せる + "pageSize": 200 + } + + + all_items = [] + while url: + resp = requests.get(url, params=params, auth=(API_KEY, "X"), timeout=30) + try: + resp.raise_for_status() + except requests.HTTPError as e: + print("Status:", resp.status_code) + print("URL :", resp.url) + print("Body :", resp.text) # エラー本文の詳細を確認 + raise + + data = resp.json() + elements = data.get("_embedded", {}).get("elements", []) + all_items.extend(elements) + + # 次ページ(_links.next.href)があれば辿る + next_link = data.get("_links", {}).get("next", {}) + url = next_link.get("href") + if url and not url.startswith("http"): + url = base_url + url + + return all_items + + +def to_adaptive_card(items): + """Work packagesをAdaptive CardのFactSetに整形""" + today = datetime.date.today().strftime("%Y-%m-%d") + facts = [] + for wp in items: + _id = wp.get("id") + subj = wp.get("subject") or "(題名なし)" + due = wp.get("dueDate") or "-" + assg = (wp.get("_embedded", {}) + .get("assignee", {}) + .get("name")) or "(未割当)" + # 各WPの詳細URL(selfリンク) + href = (wp.get("_links", {}) + .get("self", {}) + .get("href")) + if href and not href.startswith("http"): + # APIのselfは相対のことがあるためベースURL付与 + href = OPENPROJECT_BASE_URL + href + + value = f"{subj} / 期日: {due} / 担当: {assg}" + if href: + value += f" / 詳細: {href}" + facts.append({"title": f"WP #{_id}", "value": value}) + + # カード本文 + card = { + "type": "message", + "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + {"type": "TextBlock", "size": "Large", "weight": "Bolder", + "text": "本日を含む担当割当チケット一覧"}, + {"type": "TextBlock", "isSubtle": True, "spacing": "Small", + "text": today}, + {"type": "FactSet", "facts": facts or [{"title": "該当なし", "value": "対象チケットはありません"}]} + ] + } + }] + } + return card + + +def post_to_teams_workflow(webhook_url, adaptive_card): + """TeamsワークフローのWebhookへAdaptive CardをPOST""" + headers = {"Content-Type": "application/json"} + resp = requests.post(webhook_url, headers=headers, data=json.dumps(adaptive_card), timeout=30) + resp.raise_for_status() + return resp.status_code + + +def main(): + if not (OP_BASE_URL and OP_BASE_URL and PROJECT_IDENTIFIER): + print("環境変数 OP_BASE_URL / OP_API_KEY / TEAMS_WEBHOOK_URL を設定してください。", file=sys.stderr) + sys.exit(1) + + # 1) OpenProjectから条件付きWP取得 + items = fetch_work_packages( + base_url=OP_BASE_URL, + api_key=API_KEY, + project_identifier=PROJECT_IDENTIFIER, + assignee_ids=PROJECT_IDENTIFIER + ) + + # 2) Adaptive Cardに整形 + card = to_adaptive_card(items) + + # 3) Teamsへ投稿 + #status = post_to_teams_workflow(TEAMS_WORKFLOW_WEBHOOK_URL, card) + print(f"Teams投稿完了(HTTP {status})") + + +if __name__ == "__main__": + main() diff --git a/compare_php/compare_php_folders.py b/compare_php/compare_php_folders.py new file mode 100644 index 0000000..5884681 --- /dev/null +++ b/compare_php/compare_php_folders.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +複数フォルダ配下の PHP ファイルを比較し、A/B/C... の内容グループで Excel に結果を書き出すスクリプト。 + +- フォルダ一覧はテキストファイル(1行: 「ラベル:/absolute/path」 形式。#始まりと空行は無視) +- 比較キーは既定で「ファイル名のみ」(--key name)。相対パスで比較する場合は --key relative。 +- 改行コード差(CRLF/LF)は既定で無視 (--keep-eol を付けると無視しない)。 +- --ignore-whitespace で空白/タブ/改行を全無視して比較。 +- NEW: --ignore-comments で PHP コメント(//, #, /* */, /** */)を除去して比較。 +- 進捗は「n/m 個目のフォルダ」「現在のファイル」を改行なしで表示。 +- 出力: Excel (結果シート + 必要に応じて重複シート) +""" + +import argparse +import os +from pathlib import Path +import hashlib +import re +from collections import defaultdict, OrderedDict +from typing import Dict, List, Tuple, Optional +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + + +# ----------------------------- +# 入力(folders.txt)読み込み +# ----------------------------- +def read_folder_list(list_file: Path): + """ + folders.txt の形式: + ラベル:/absolute/path/to/folder + を読み込み、[(label, Path), ...] のリストを返す + """ + result = [] + for line in list_file.read_text(encoding="utf-8").splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + + if ":" not in s: + raise ValueError(f"ラベル:フォルダパス の形式で記述してください → {s}") + + label, folder = s.split(":", 1) + label = label.strip() + folder = folder.strip() + + p = Path(folder).expanduser().resolve() + if not p.exists() or not p.is_dir(): + raise FileNotFoundError(f"フォルダが存在しません: {folder}") + + result.append((label, p)) + + if not result: + raise ValueError("フォルダ一覧が空です。") + + return result + + +# ----------------------------- +# ファイル列挙 +# ----------------------------- +def iter_php_files(root: Path, recursive: bool = True): + """root 配下の .php を列挙。戻り値: (ファイル名, 相対フォルダパス, 相対パス, 絶対パス)""" + if recursive: + for dirpath, _, filenames in os.walk(root): + dirpath_p = Path(dirpath) + for fn in filenames: + if fn.lower().endswith(".php"): + rel_path = dirpath_p.relative_to(root) / fn if dirpath_p != root else Path(fn) + rel_dir = rel_path.parent.as_posix() if str(rel_path) != fn else "" + yield (fn, rel_dir, rel_path.as_posix(), (dirpath_p / fn)) + else: + for fn in os.listdir(root): + if fn.lower().endswith(".php"): + yield (fn, "", fn, (root / fn)) + + +# ----------------------------- +# コメント除去(--ignore-comments) +# ----------------------------- +_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", flags=re.DOTALL) # /* ... */ と /** ... */ +_COMMENT_SLASH_SLASH = re.compile(r"//.*?$", flags=re.MULTILINE) # // ... +_COMMENT_HASH = re.compile(r"#.*?$", flags=re.MULTILINE) # # ... + +def strip_php_comments_from_bytes(b: bytes) -> bytes: + """ + PHP のコメント(//, #, /* */, /** */)を削除。 + - バイナリ→latin1文字列で無損失に扱い、正規表現適用後に再度 bytes 化 + - PHP タグ外のテキストでもパターンに合致すれば削除されます(必要に応じ高度化可能) + """ + s = b.decode("latin1") + s = _COMMENT_BLOCK.sub("", s) + s = _COMMENT_SLASH_SLASH.sub("", s) + s = _COMMENT_HASH.sub("", s) + return s.encode("latin1") + + +# ----------------------------- +# 正規化(EOL/空白/コメント) +# ----------------------------- +def normalize_bytes_for_hash( + data: bytes, + keep_eol: bool, + ignore_whitespace: bool, + ignore_comments: bool, +) -> bytes: + """比較前のバイト列正規化""" + b = data + + # コメント無視(先に行うことで、その後のEOL/空白処理の影響も受ける) + if ignore_comments: + try: + b = strip_php_comments_from_bytes(b) + except Exception: + # コメ除去中に失敗しても比較を止めない + pass + + if not keep_eol: + # CRLF/LF/CR の差異を LF に正規化 + b = b.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + + if ignore_whitespace: + # 全ホワイトスペースを除去(空白・タブ・LF) + table = {ord(' '): None, ord('\t'): None, ord('\n'): None, ord('\r'): None} + s = b.decode('latin1') # バイト値を壊さないダミーエンコーディング + s = s.translate(table) + b = s.encode('latin1') + + return b + + +def file_hash( + path: Path, + keep_eol: bool, + ignore_whitespace: bool, + ignore_comments: bool, +) -> str: + with path.open("rb") as f: + data = f.read() + data = normalize_bytes_for_hash( + data, + keep_eol=keep_eol, + ignore_whitespace=ignore_whitespace, + ignore_comments=ignore_comments, + ) + return hashlib.sha256(data).hexdigest() + + +# ----------------------------- +# ユーティリティ +# ----------------------------- +def excel_letter_index(n: int) -> str: + """ + 0 -> 'A', 1 -> 'B', ..., 25 -> 'Z', 26 -> 'AA' ... のようにグループ記号を生成 + Excel列名と同様のロジック + """ + s = [] + n += 1 + while n > 0: + n, r = divmod(n - 1, 26) + s.append(chr(65 + r)) + return "".join(reversed(s)) + +def auto_width(ws): + col_widths = defaultdict(int) + for row in ws.iter_rows(values_only=True): + for i, val in enumerate(row, start=1): + if val is None: + continue + length = len(str(val)) + if length > col_widths[i]: + col_widths[i] = length + for i, w in col_widths.items(): + ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 10), 80) + + +# ----------------------------- +# メイン +# ----------------------------- +def main(): + parser = argparse.ArgumentParser(description="複数フォルダの PHP ファイルを内容比較して Excel に出力します。") + parser.add_argument("--list", required=True, help="フォルダ一覧テキストファイルのパス(1行: ラベル:/path)") + parser.add_argument("--output", default="php_compare.xlsx", help="出力Excelファイル名(既定: php_compare.xlsx)") + parser.add_argument("--key", choices=["name", "relative"], default="name", + help="比較キー: name=ファイル名, relative=相対パス(既定: name)") + parser.add_argument("--no-recursive", action="store_true", help="再帰を無効にして直下のみを対象") + parser.add_argument("--keep-eol", action="store_true", help="改行コードの違いを無視しない(厳密一致)") + parser.add_argument("--ignore-whitespace", action="store_true", help="空白/タブ/改行等のホワイトスペースを無視して比較") + parser.add_argument("--ignore-comments", action="store_true", help="PHPコメント(//, #, /* */, /** */)を無視して比較") + args = parser.parse_args() + + list_file = Path(args.list) + out_path = Path(args.output).resolve() + + folder_items = read_folder_list(list_file) + labels = [item[0] for item in folder_items] + folders = [item[1] for item in folder_items] + recursive = not args.no_recursive + + # 各フォルダごと: key -> (abs_path, rel_dir, file_name) + per_folder_maps: List[Dict[str, Tuple[Path, str, str]]] = [] + duplicate_report: List[Tuple[int, str, List[str]]] = [] # (folder_idx, key, [abs paths]) + + total_folders = len(folders) + print(f"対象フォルダ数: {total_folders}") + + # フォルダ走査 & ハッシュ計算 + per_folder_hashes: List[Dict[str, str]] = [] # key -> hash + for idx, root in enumerate(folders, start=1): + mapping: Dict[str, Tuple[Path, str, str]] = {} + key_to_abslist: Dict[str, List[str]] = defaultdict(list) + + files = list(iter_php_files(root, recursive=recursive)) + total_files = len(files) + for j, (fn, rel_dir, rel_path, abs_path) in enumerate(files, start=1): + # 進捗を改行せずに出力 + print(f"\r[{idx}/{total_folders}] {j}/{total_files} {fn}", end="", flush=True) + + key = fn if args.key == "name" else rel_path + key_to_abslist[key].append(str(abs_path)) + + # 代表(最初に出会ったもの)を保持 + if key not in mapping: + mapping[key] = (abs_path, rel_dir, fn) + + # 重複(同一フォルダで同じ key が複数)を記録 + for key, lst in key_to_abslist.items(): + if len(lst) > 1: + duplicate_report.append((idx, key, lst)) + + # ハッシュ計算 + key_hash: Dict[str, str] = {} + for key, (abs_path, _rel_dir, _fn) in mapping.items(): + try: + h = file_hash( + abs_path, + keep_eol=args.keep_eol, + ignore_whitespace=args.ignore_whitespace, + ignore_comments=args.ignore_comments, + ) + except Exception as e: + h = f"__ERROR__:{e}" + key_hash[key] = h + + per_folder_maps.append(mapping) + per_folder_hashes.append(key_hash) + print("") # フォルダ行終端 + + # 全キー(ファイル名 or 相対パス)のユニオン + all_keys: List[str] = sorted(set().union(*[set(m.keys()) for m in per_folder_maps])) + + # Excel ワークブック作成 + wb = Workbook() + ws = wb.active + ws.title = "結果" + + # ヘッダ行 + headers = ["フォルダパス", "ファイル名"] + for label in labels: + headers.append(label) + ws.append(headers) + + # 各キー(=行)ごとに A/B/C... を割り当て + for key in all_keys: + group_map: "OrderedDict[str, str]" = OrderedDict() # hash -> letter + next_group_idx = 0 + + # 代表(最初に見つかった場所の情報) + rep_rel_dir: Optional[str] = None + rep_file_name: Optional[str] = None + + for m in per_folder_maps: + if key in m: + _abs, rel_dir, fn = m[key] + rep_rel_dir = rel_dir if rel_dir else "" + rep_file_name = fn + break + + if rep_rel_dir is None: + rep_rel_dir = "" + if rep_file_name is None: + rep_file_name = key if args.key == "name" else Path(key).name + + row = [rep_rel_dir, rep_file_name] + + # 各フォルダ列 + for (m, hmap) in zip(per_folder_maps, per_folder_hashes): + if key not in m: + row.append("—") + continue + h = hmap.get(key) + if h is None: + row.append("—") + continue + if h.startswith("__ERROR__"): + row.append("ERR") + continue + + if h not in group_map: + group_map[h] = excel_letter_index(next_group_idx) + next_group_idx += 1 + row.append(group_map[h]) + + ws.append(row) + + # 重複シート(同一フォルダ内で同名 or 同相対パスが複数ある) + if duplicate_report: + ws2 = wb.create_sheet(title="重複") + ws2.append(["フォルダ番号(1~)", "キー(name/relative)", "該当ファイル(絶対パス)"]) + for folder_idx, key, paths in duplicate_report: + ws2.append([folder_idx, key, paths[0]]) + for p in paths[1:]: + ws2.append(["", "", p]) + auto_width(ws2) + + auto_width(ws) + wb.save(out_path) + print(f"完了: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/compare_php/folders.txt b/compare_php/folders.txt new file mode 100644 index 0000000..82e3639 --- /dev/null +++ b/compare_php/folders.txt @@ -0,0 +1,26 @@ +Reserve2:C:\work\ReserveMart\src\Reserve3_src\public_html\core +nagahama:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\nagahama +moriyama:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\moriyama +terasupo:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\terasupo +ena:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\ena +Reserve2.2:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\Reserve2.2 +makubetsu:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\makubetsu +sanjo:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\sanjo +toki:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\toki +city_date:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\city_date +kasamatsu:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\kasamatsu +kuwana:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\kuwana +uki:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\uki +motomiya:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\motomiya +yatsushiro:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\yatsushiro +oita:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\oita +tatebayashi:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\tatebayashi +hyogo:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\hyogo +kazakoshi:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\kazakoshi +shinagawa:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\shinagawa +meikyokudo:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\meikyokudo +studio-node:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\studio-node +yamori:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\yamori +zipang:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\zipang +katariba:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\katariba +shinjukumura:C:\work\ReserveMart\src\Reserve3_src\public_html\overlay\shinjukumura diff --git a/compare_php/php_compare.xlsx b/compare_php/php_compare.xlsx new file mode 100644 index 0000000..5884a7a Binary files /dev/null and b/compare_php/php_compare.xlsx differ diff --git a/compare_php/readme.md b/compare_php/readme.md new file mode 100644 index 0000000..90b118b --- /dev/null +++ b/compare_php/readme.md @@ -0,0 +1,30 @@ +# PHPファイル差分リスト作成スクリプト + +## はじめに + +指定したフォルダ配下同士でPHPの差分の有り無しを自動で調査するスクリプトです。 +差分がないファイル同士は同じアルファベットで表記されています。 + +## 準備 + +- folders.txt + - 調査結果Excelに表示するラベル名とフォルダパスを設定する + - ラベル名:C:\work...... + +## 実行例 + +``` +■ 基本(フォルダパスも考慮して比較するファイルを決定、改行空白を無視、コメントを無視) +python compare_php_folders.py --list folders.txt --output php_compare.xlsx --key relative --ignore-comments + +■ 改行差も空白も無視(より寛容) +python compare_php_folders.py --list folders.txt --output php_compare.xlsx --key relative --ignore-comments --ignore-whitespace + +■ 直下のみ探索(再帰なし) +python compare_php_folders.py --list folders.txt --no-recursive --ignore-comments +``` + +## 実行結果 + +比較結果はphp_compare.xlsxに生成もしくは上書きされる。 +※実行前に本Excelファイルは閉じておくこと diff --git a/csv_maker_vba.txt b/csv_maker_vba.txt new file mode 100644 index 0000000..74e7356 --- /dev/null +++ b/csv_maker_vba.txt @@ -0,0 +1,489 @@ +Option Explicit + +' ============================================ +' CSV取り込み + ヘッダ/ドロップダウン設定(列挙メタは改行不問で検知) +' 実行時に新規シートを作成して結果を出力 +' ============================================ + +' ===== 設定定数 ===== +Private Const DV_USE_CODE_ONLY As Boolean = True ' True: ドロップダウンをコードのみ(例: 0,1)にする +Private Const APPLY_DV_TO_ROW As Long = 50 ' データがない場合にDVを適用する最終行 +Private Const CSV_ENCODING As String = "auto" ' "auto"|"utf8"|"cp932" +Private Const SHEET_NAME_PREFIX As String = "Import_" ' 作成シート名の接頭辞 + +'==== ボタンに割り当てるエントリーポイント ==== +Public Sub Run_ImportCsv_CreateNewSheet() + Dim ctrlSheet As Worksheet + Set ctrlSheet = ActiveSheet ' ボタンを置いたシート(操作用) + + ' CSVパスを選択 + Dim fpath As String + fpath = PickCsvFilePath() + If Len(fpath) = 0 Then + MsgBox "CSVの選択がキャンセルされました。", vbInformation + Exit Sub + End If + + ' 出力シート名 + Dim outSheetName As String + outSheetName = GetUniqueSheetName(BuildOutputSheetName(fpath)) + + ' 新規シート作成(操作用シートの後ろ) + Dim wsTarget As Worksheet + Set wsTarget = ThisWorkbook.Worksheets.Add(After:=ctrlSheet) + wsTarget.Name = outSheetName + + ' 取り込み実行(結果は wsTarget に出力) + ImportCsvToTargetSheet fpath, wsTarget + + ' 完了メッセージ + MsgBox "取り込み完了: " & wsTarget.Name, vbInformation +End Sub + +'==== メイン処理(指定のターゲットシートに出力) ==== +Private Sub ImportCsvToTargetSheet(ByVal fpath As String, ByVal wsTarget As Worksheet) + Dim wsTemp As Worksheet + Set wsTemp = ThisWorkbook.Worksheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.count)) + wsTemp.Name = GetUniqueTempSheetName("CSV_RAW") + + On Error GoTo CLEANUP_ON_ERROR + + ' CSVを一時シートに読み込み(Power Query) + If Not ImportCsvToTempSheet(fpath, CSV_ENCODING, wsTemp) Then + Err.Raise vbObjectError + 101, , "CSVの取り込みに失敗しました。" + End If + + ' 使用範囲特定 + Dim lastRow As Long, lastCol As Long + lastRow = GetLastRow(wsTemp) + lastCol = GetLastCol(wsTemp) + If lastCol = 0 Or lastRow = 0 Then + Err.Raise vbObjectError + 102, , "CSVが空のようです。" + End If + + Application.ScreenUpdating = False + Application.EnableEvents = False + + ' ターゲットシートをクリア + wsTarget.Cells.Clear + + ' ------- 1) ヘッダ処理(列挙メタはセル内に含まれていれば検知) ------- + Dim colHasEnum() As Boolean + Dim colEnumList() As String + ReDim colHasEnum(1 To lastCol) + ReDim colEnumList(1 To lastCol) + + Dim c As Long + For c = 1 To lastCol + Dim headerRaw As String + headerRaw = Nz(wsTemp.Cells(1, c).Value, "") + + ' ヘッダはそのまま出力(列挙メタ文字列も残す) + wsTarget.Cells(1, c).Value = headerRaw + + ' ヘッダに列挙メタ [n:ラベル] が1つでも含まれていれば検知 + If ContainsEnumeration(headerRaw) Then + colHasEnum(c) = True + ' ヘッダ全体から [n:ラベル] を抽出し、DV用のCSV文字列を構築 + colEnumList(c) = BuildValidationListFromEnum(headerRaw, DV_USE_CODE_ONLY) + Else + colHasEnum(c) = False + colEnumList(c) = "" + End If + Next c + + ' ------- 2) データの貼付(CSV 2行目→Excel 2行目) ------- + Dim tgtDataStartRow As Long: tgtDataStartRow = 2 + Dim tgtLastRow As Long: tgtLastRow = 1 + + If lastRow >= 2 Then + Dim dataRowCount As Long + dataRowCount = lastRow - 1 ' 1行目はヘッダ + wsTemp.Range(wsTemp.Cells(2, 1), wsTemp.Cells(lastRow, lastCol)).Copy + wsTarget.Cells(tgtDataStartRow, 1).PasteSpecial xlPasteValues + tgtLastRow = tgtDataStartRow + dataRowCount - 1 + Else + tgtLastRow = 1 ' データ無し + End If + + ' ------- 3) ドロップダウン(入力規則)設定 ------- + Dim applyToLast As Long + applyToLast = IIf(tgtLastRow >= 2, tgtLastRow, APPLY_DV_TO_ROW) + + For c = 1 To lastCol + If colHasEnum(c) And Len(colEnumList(c)) > 0 Then + Dim dvRange As Range + Set dvRange = wsTarget.Range(wsTarget.Cells(2, c), wsTarget.Cells(applyToLast, c)) + ApplyValidationList dvRange, colEnumList(c) + End If + Next c + + ' ------- 4) 調整(見やすく) ------- + wsTarget.Columns("A:" & ColLetter(lastCol)).AutoFit + + Application.CutCopyMode = False + Application.ScreenUpdating = True + Application.EnableEvents = True + + ' 一時シート削除 + Application.DisplayAlerts = False + wsTemp.Delete + Application.DisplayAlerts = True + Exit Sub + +CLEANUP_ON_ERROR: + Application.CutCopyMode = False + Application.ScreenUpdating = True + Application.EnableEvents = True + On Error Resume Next + Application.DisplayAlerts = False + If Not wsTemp Is Nothing Then wsTemp.Delete + Application.DisplayAlerts = True + On Error GoTo 0 + MsgBox "エラー: " & Err.Description, vbExclamation +End Sub + +'======================================================== +' 出力シート名をファイル名から生成(長すぎる・使用不可文字は除去) +'======================================================== +Private Function BuildOutputSheetName(ByVal fpath As String) As String + Dim fname As String + fname = fpath + Dim i As Long + + ' パスからファイル名抽出 + For i = Len(fpath) To 1 Step -1 + If Mid$(fpath, i, 1) = "\" Or Mid$(fpath, i, 1) = "/" Then + fname = Mid$(fpath, i + 1) + Exit For + End If + Next i + + ' 拡張子除去 + Dim dotPos As Long + dotPos = InStrRev(fname, ".") + If dotPos > 1 Then fname = Left$(fname, dotPos - 1) + + ' シート名禁止文字の除去 / の代替 + fname = Replace(fname, ":", " ") + fname = Replace(fname, "/", " ") + fname = Replace(fname, "\", " ") + fname = Replace(fname, "?", " ") + fname = Replace(fname, "*", " ") + fname = Replace(fname, "[", " ") + fname = Replace(fname, "]", " ") + + ' タイムスタンプ付与 + BuildOutputSheetName = SHEET_NAME_PREFIX & fname & "_" & Format(Now, "yymmdd_hhnnss") + ' 31文字制限対応(必要なら切り詰め) + If Len(BuildOutputSheetName) > 31 Then + BuildOutputSheetName = Left$(BuildOutputSheetName, 31) + End If +End Function + +'======================================================== +' シート名の一意化 +'======================================================== +Private Function GetUniqueSheetName(ByVal baseName As String) As String + Dim nameTry As String, n As Long + nameTry = baseName + n = 1 + Do While SheetExists(nameTry) + n = n + 1 + nameTry = Left$(baseName, 28) & "_" & CStr(n) ' 31文字制限に配慮 + Loop + GetUniqueSheetName = nameTry +End Function + +'======================================================== +' ファイルダイアログでCSVファイルを取得 +'======================================================== +Private Function PickCsvFilePath() As String + Dim fd As FileDialog + Set fd = Application.FileDialog(msoFileDialogFilePicker) + + With fd + .Title = "CSVファイルを選択" + .Filters.Clear + .Filters.Add "CSV ファイル", "*.csv", 1 + .AllowMultiSelect = False + If .Show = -1 Then + PickCsvFilePath = .SelectedItems(1) + Else + PickCsvFilePath = "" + End If + End With +End Function + +'======================================================== +' 一時シート名をユニーク化 +'======================================================== +Private Function GetUniqueTempSheetName(ByVal baseName As String) As String + Dim n As Long + Dim nameTry As String + n = 0 + Do + n = n + 1 + nameTry = baseName & "_" & Format(Now, "yymmdd_hhnnss") & "_" & n + If SheetExists(nameTry) = False Then + GetUniqueTempSheetName = nameTry + Exit Do + End If + Loop +End Function + +Private Function SheetExists(ByVal sName As String) As Boolean + Dim ws As Worksheet + On Error Resume Next + Set ws = ThisWorkbook.Worksheets(sName) + SheetExists = Not ws Is Nothing + On Error GoTo 0 +End Function + +'======================================================== +' CSVを一時シートに読み込み(Power Query エンジン版) +' encoding: "auto" | "utf8" | "cp932" +'======================================================== +Private Function ImportCsvToTempSheet(ByVal fpath As String, ByVal encoding As String, ByVal wsTemp As Worksheet) As Boolean + On Error GoTo ERRH + wsTemp.Cells.Clear + + Dim encCode As Long + Select Case LCase$(encoding) + Case "utf8": encCode = 65001 + Case "cp932": encCode = 932 + Case Else: encCode = 65001 ' autoはまずUTF-8で試行→失敗時に932で再試行 + End Select + + If ImportCsvViaPowerQuery(fpath, encCode, wsTemp) Then + ImportCsvToTempSheet = True + Exit Function + End If + + ' autoのときはフォールバック(UTF-8失敗→CP932) + If LCase$(encoding) = "auto" Then + If ImportCsvViaPowerQuery(fpath, 932, wsTemp) Then + ImportCsvToTempSheet = True + Exit Function + End If + End If + + ImportCsvToTempSheet = False + Exit Function +ERRH: + ImportCsvToTempSheet = False +End Function + +Private Function ImportCsvViaPowerQuery(ByVal fpath As String, ByVal encCode As Long, ByVal wsTemp As Worksheet) As Boolean + On Error GoTo ERRH + + ' クエリ名をユニークに + Randomize + Dim pqName As String + pqName = "PQ_CSV_" & Format(Now, "yymmdd_hhnnss_") & CStr(Int(Rnd() * 100000)) + + ' --- Power Query (M) 式を定義 --- + Dim m As String + + + m = _ + "let" & vbCrLf & _ + " Source = Csv.Document(File.Contents(""" & Replace(fpath, """", """""") & """)," & _ + " [Delimiter="","", Columns=null, Encoding=" & CStr(encCode) & ", QuoteStyle=QuoteStyle.Csv])," & vbCrLf & _ + " Promote = Table.PromoteHeaders(Source, [PromoteAllScalars=true])," & vbCrLf & _ + " AsText = Table.TransformColumnTypes(Promote, List.Transform(Table.ColumnNames(Promote), each {_, type text}))," & vbCrLf & _ + " AllCols = Table.ColumnNames(AsText)," & vbCrLf & _ + " // ""Column1"" のような自動列名 かつ 列内の全値が Null または 空白のみ なら削除対象にする" & vbCrLf & _ + " Trim = (v as any) as text => Text.Trim(Text.From(v, Culture=null))," & vbCrLf & _ + " IsEmptyCol = (tbl as table, col as text) as logical =>" & vbCrLf & _ + " List.AllTrue(List.Transform(Table.Column(tbl, col), (v) => v = null or Trim(v) = """" ))," & vbCrLf & _ + " ToRemove = List.Select(AllCols, (cn) => Text.StartsWith(cn, ""Column"") and IsEmptyCol(AsText, cn))," & vbCrLf & _ + " Cleaned = if List.Count(ToRemove) > 0 then Table.RemoveColumns(AsText, ToRemove) else AsText" & vbCrLf & _ + "in" & vbCrLf & _ + " Cleaned" + + ' 既存の内容を一旦クリア + wsTemp.Cells.Clear + + ' --- クエリの追加(ブック内に定義) --- + Dim q As WorkbookQuery + Set q = ThisWorkbook.Queries.Add(Name:=pqName, Formula:=m) + + ' --- OLEDB 接続文字列(Power Query への橋渡し) --- + Dim connStr As String + connStr = "OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=" & pqName & ";Extended Properties=""""" + + ' --- QueryTable を使ってシートにロード(SQL 明示)--- + Dim qt As QueryTable + Set qt = wsTemp.QueryTables.Add(Connection:=connStr, Destination:=wsTemp.Range("A1")) + With qt + .CommandType = xlCmdSql + .CommandText = "SELECT * FROM [" & pqName & "]" + .PreserveFormatting = True + .AdjustColumnWidth = False + .RefreshStyle = xlOverwriteCells + .BackgroundQuery = False + .Refresh ' ← ここで実行 + End With + + ' 値として固定(再解釈を防ぐ) + wsTemp.UsedRange.Value = wsTemp.UsedRange.Value + + ' --- 後片付け(接続・クエリの削除) --- + On Error Resume Next + Dim cn As WorkbookConnection + For Each cn In ThisWorkbook.Connections + If InStr(1, cn.Name, pqName, vbTextCompare) > 0 Then cn.Delete + If Not cn.OLEDBConnection Is Nothing Then + If InStr(1, cn.OLEDBConnection.Connection, "Location=" & pqName, vbTextCompare) > 0 Then cn.Delete + End If + Next + ThisWorkbook.Queries(pqName).Delete + On Error GoTo 0 + + ImportCsvViaPowerQuery = True + Exit Function + +ERRH: + ' クリーンアップ + On Error Resume Next + Dim cn2 As WorkbookConnection + For Each cn2 In ThisWorkbook.Connections + If InStr(1, cn2.Name, pqName, vbTextCompare) > 0 Then cn2.Delete + If Not cn2.OLEDBConnection Is Nothing Then + If InStr(1, cn2.OLEDBConnection.Connection, "Location=" & pqName, vbTextCompare) > 0 Then cn2.Delete + End If + Next + ThisWorkbook.Queries(pqName).Delete + On Error GoTo 0 + + ImportCsvViaPowerQuery = False +End Function + +'======================================================== +' 行・列の最終位置 +'======================================================== +Private Function GetLastRow(ByVal ws As Worksheet) As Long + Dim f As Range + On Error Resume Next + Set f = ws.Cells.Find(What:="*", After:=ws.Range("A1"), LookAt:=xlPart, _ + LookIn:=xlFormulas, SearchOrder:=xlByRows, _ + SearchDirection:=xlPrevious, MatchCase:=False) + On Error GoTo 0 + If f Is Nothing Then + GetLastRow = 0 + Else + GetLastRow = f.Row + End If +End Function + +Private Function GetLastCol(ByVal ws As Worksheet) As Long + Dim f As Range + On Error Resume Next + Set f = ws.Cells.Find(What:="*", After:=ws.Range("A1"), LookAt:=xlPart, _ + LookIn:=xlFormulas, SearchOrder:=xlByColumns, _ + SearchDirection:=xlPrevious, MatchCase:=False) + On Error GoTo 0 + If f Is Nothing Then + GetLastCol = 0 + Else + GetLastCol = f.Column + End If +End Function + +'======================================================== +' ヘッダ文字列の中に [n:ラベル] パターンが含まれるか判定(改行不問) +'======================================================== +Private Function ContainsEnumeration(ByVal s As String) As Boolean + Dim re As Object + Set re = CreateObject("VBScript.RegExp") + re.Pattern = "\[\d+:[^\]]+\]" + re.IgnoreCase = True + re.Global = True + ContainsEnumeration = re.Test(s) +End Function + +'======================================================== +' ヘッダ全体から [n:ラベル] を抽出して、入力規則のリスト文字列を生成 +' DV_USE_CODE_ONLY=False: "0:無効,1:有効" +' DV_USE_CODE_ONLY=True : "0,1" +'======================================================== +Private Function BuildValidationListFromEnum(ByVal s As String, ByVal codeOnly As Boolean) As String + Dim re As Object, mc As Object, m As Object + Set re = CreateObject("VBScript.RegExp") + re.Pattern = "\[(\d+):([^\]]+)\]" + re.IgnoreCase = True + re.Global = True + + Dim parts As Collection + Set parts = New Collection + + If re.Test(s) Then + Set mc = re.Execute(s) + Dim code As String, label As String + For Each m In mc + code = m.SubMatches(0) + label = m.SubMatches(1) + If codeOnly Then + parts.Add code + Else + parts.Add code & ":" & label + End If + Next m + End If + + Dim i As Long, buf As String + For i = 1 To parts.count + buf = buf & IIf(Len(buf) > 0, ",", "") & CStr(parts(i)) + Next i + + BuildValidationListFromEnum = buf +End Function + +'======================================================== +' 入力規則(リスト)を範囲に適用 +'======================================================== +Private Sub ApplyValidationList(ByVal rng As Range, ByVal listCsv As String) + On Error Resume Next + rng.Validation.Delete + On Error GoTo 0 + + If Len(listCsv) = 0 Then Exit Sub + + With rng.Validation + .Add Type:=xlValidateList, AlertStyle:=xlValidAlertStop, Operator:=xlBetween, _ + Formula1:=listCsv + .IgnoreBlank = True + .InCellDropdown = True + .ErrorTitle = "入力値エラー" + .ErrorMessage = "リストから選択してください。" + .ShowError = True + End With +End Sub + +'======================================================== +' Null/Emptyを空文字にする補助 +'======================================================== +Private Function Nz(ByVal v As Variant, Optional ByVal defaultValue As String = "") As String + If IsError(v) Then + Nz = defaultValue + ElseIf IsNull(v) Or IsEmpty(v) Then + Nz = defaultValue + Else + Nz = CStr(v) + End If +End Function + +'======================================================== +' 列番号 → 列記号 +'======================================================== +Private Function ColLetter(ByVal colNum As Long) As String + Dim s As String + Do While colNum > 0 + s = Chr(((colNum - 1) Mod 26) + 65) & s + colNum = (colNum - 1) \ 26 + Loop + ColLetter = s +End Function + + diff --git a/csv_output_vba.txt b/csv_output_vba.txt new file mode 100644 index 0000000..c63404f --- /dev/null +++ b/csv_output_vba.txt @@ -0,0 +1,411 @@ +Option Explicit + +' ============================ +' CSV エクスポート(UTF-8 BOM付き) +' H6セルのシート名を対象に出力 +' 1行目に [code:label] メタがある列は code で出力 +' ============================ + +' === 設定 === +Private Const EXPORT_UTF8_WITH_BOM As Boolean = True +Private Const STRIP_META_FROM_HEADER As Boolean = False ' Trueにするとヘッダから [x:ラベル] を削除して出力 + +' 入口:ボタンに割り当て +Public Sub Run_ExportCsv_FromH6() + Dim ctrl As Worksheet + Set ctrl = ActiveSheet + + Dim targetName As String + targetName = Trim$(Nz(ctrl.Range("H6").value, "")) + If Len(targetName) = 0 Then + MsgBox "H6セルに出力対象のシート名がありません。", vbExclamation + Exit Sub + End If + If Not SheetExists(targetName) Then + MsgBox "シート """ & targetName & """ が見つかりません。", vbExclamation + Exit Sub + End If + + Dim ws As Worksheet + Set ws = ThisWorkbook.Worksheets(targetName) + + ExportCsvWithMeta ws +End Sub + +' === 本体 === +Private Sub ExportCsvWithMeta(ByVal ws As Worksheet) + Dim lastRow As Long, lastCol As Long + lastRow = GetLastRow(ws) + lastCol = GetLastCol(ws) + If lastRow = 0 Or lastCol = 0 Then + MsgBox "対象シートが空です。", vbExclamation + Exit Sub + End If + + ' ヘッダ行(1行目)を読み、列ごとにメタ([code:label])を辞書化 + Dim colHasEnum() As Boolean + Dim mapLabelToCode() As Object ' Scripting.Dictionary(label→code) + Dim setValidCode() As Object ' Scripting.Dictionary(code→True) + ReDim colHasEnum(1 To lastCol) + ReDim mapLabelToCode(1 To lastCol) + ReDim setValidCode(1 To lastCol) + + Dim c As Long + For c = 1 To lastCol + Dim headerRaw As String + headerRaw = Nz(ws.Cells(1, c).value, "") + BuildEnumMaps headerRaw, mapLabelToCode(c), setValidCode(c), colHasEnum(c) + Next c + + ' 一括読み + Dim rng As Range + Set rng = ws.Range("A1").Resize(lastRow, lastCol) + Dim data As Variant + data = rng.value ' 1-based + + ' CSV行を構築 + Dim sb As String + Dim r As Long + + ' 1行目(ヘッダ) + Dim line As String + line = "" + For c = 1 To lastCol + Dim h As String + h = Nz(data(1, c), "") + If STRIP_META_FROM_HEADER Then + h = StripEnumFromHeader(h) + End If + AppendCsvField line, h + Next c + sb = line + + ' データ行 + For r = 2 To lastRow + ' 行全体が空ならスキップ(全列空 or 空白のみ) + If RowIsAllEmpty(data, r, lastCol) Then + ' スキップ + Else + line = "" + For c = 1 To lastCol + Dim v As String + v = Nz(data(r, c), "") + + If colHasEnum(c) Then + Dim codeOut As String, ok As Boolean + ok = TryResolveToCode(v, mapLabelToCode(c), setValidCode(c), codeOut) + If Not ok Then + ' エラー表示&該当セルを選択して中断 + Dim addr As String + addr = ws.Cells(r, c).Address(False, False) + MsgBox "列挙メタに一致しない値が見つかりました。" & vbCrLf & _ + "セル: " & addr & vbCrLf & _ + "値 : " & v & vbCrLf & _ + "ヘッダ: " & Nz(data(1, c), ""), vbCritical + ws.Activate + ws.Cells(r, c).Select + Exit Sub + End If + AppendCsvField line, codeOut + Else + AppendCsvField line, v + End If + Next c + sb = sb & vbCrLf & line + End If + Next r + + ' 保存ダイアログ + Dim fpath As String + fpath = PickSavePathCsv(ws.Name) + If Len(fpath) = 0 Then + MsgBox "保存がキャンセルされました。", vbInformation + Exit Sub + End If + + ' UTF-8で書き込み + If EXPORT_UTF8_WITH_BOM Then + WriteUtf8WithBom fpath, sb + Else + WriteUtf8NoBom fpath, sb + End If + + MsgBox "CSVの書き出しが完了しました。" & vbCrLf & fpath, vbInformation +End Sub + +' === メタ解析([code:label] または [code:label] を辞書化) === +Private Sub BuildEnumMaps(ByVal headerRaw As String, _ + ByRef dictLabelToCode As Object, _ + ByRef dictValidCode As Object, _ + ByRef hasEnum As Boolean) + Dim re As Object, mc As Object, m As Object + Set re = CreateObject("VBScript.RegExp") + re.Pattern = "\[(\d+)[::]([^\]]+)\]" + re.IgnoreCase = True + re.Global = True + + hasEnum = False + Set dictLabelToCode = CreateObject("Scripting.Dictionary") + Set dictValidCode = CreateObject("Scripting.Dictionary") + + If re.Test(headerRaw) Then + Set mc = re.Execute(headerRaw) + Dim code As String, label As String, key As String + For Each m In mc + code = CStr(m.SubMatches(0)) + label = CStr(m.SubMatches(1)) + ' 有効コードセット + If Not dictValidCode.Exists(code) Then dictValidCode.Add code, True + ' ラベル→コード(トリム&半角化キーも登録) + key = NormalizeKey(label) + If Not dictLabelToCode.Exists(key) Then dictLabelToCode.Add key, code + + ' ラベルのバリエーション(両端空白除去のみ) + key = NormalizeKeyBasic(label) + If Not dictLabelToCode.Exists(key) Then dictLabelToCode.Add key, code + + hasEnum = True + Next m + End If +End Sub + +' === 値 → code への正規化 === +' 空は許容(空文字を返す)。一致しなければ False +Private Function TryResolveToCode(ByVal v As String, _ + ByVal dictLabelToCode As Object, _ + ByVal dictValidCode As Object, _ + ByRef outCode As String) As Boolean + Dim s As String + s = TrimBoth(v) + If Len(s) = 0 Then + outCode = "" + TryResolveToCode = True + Exit Function + End If + + ' 1) "code:label" / "code:label" 形式 + Dim re As Object, m As Object + Set re = CreateObject("VBScript.RegExp") + re.Pattern = "^\s*(\d+)\s*[::]\s*.+$" + re.IgnoreCase = True + re.Global = False + If re.Test(s) Then + Set m = re.Execute(s)(0) + If dictValidCode.Exists(m.SubMatches(0)) Then + outCode = CStr(m.SubMatches(0)) + TryResolveToCode = True + Exit Function + Else + TryResolveToCode = False + Exit Function + End If + End If + + ' 2) code そのもの + ' If dictValidCode.Exists(s) Then + ' outCode = s + ' TryResolveToCode = True + ' Exit Function + ' End If + + ' 2b) 全角→半角にして code 判定 + ' Dim sN As String + ' On Error Resume Next + ' sN = StrConv(s, vbNarrow) + ' On Error GoTo 0 + ' If Len(sN) > 0 Then + ' If dictValidCode.Exists(sN) Then + ' outCode = sN + ' TryResolveToCode = True + ' Exit Function + ' End If + ' End If + + ' 3) label として一致(正規化キーで判定) + Dim key As String + key = NormalizeKey(s) + If dictLabelToCode.Exists(key) Then + outCode = CStr(dictLabelToCode(key)) + TryResolveToCode = True + Exit Function + End If + + ' 3b) ラベル・最小正規化 + key = NormalizeKeyBasic(s) + If dictLabelToCode.Exists(key) Then + outCode = CStr(dictLabelToCode(key)) + TryResolveToCode = True + Exit Function + End If + + TryResolveToCode = False +End Function + +' === ヘッダから [x:ラベル] を削除 === +Private Function StripEnumFromHeader(ByVal headerRaw As String) As String + Dim re As Object + Set re = CreateObject("VBScript.RegExp") + re.Pattern = "\[[^\]]+\]" + re.Global = True + StripEnumFromHeader = Trim$(re.Replace(headerRaw, "")) +End Function + +' === CSVフィールド追加(RFC準拠の引用) === +Private Sub AppendCsvField(ByRef line As String, ByVal value As String) + Dim s As String + s = CStr(value) + ' 改行はCRLFに正規化 + s = Replace(s, vbCrLf, vbLf) + s = Replace(s, vbCr, vbLf) + s = Replace(s, vbLf, vbCrLf) + + ' ダブルクォートは二重化 + If InStr(1, s, """") > 0 Then s = Replace(s, """", """""""") + ' 区切り・改行・引用符を含む場合は引用 + If (InStr(1, s, ",") > 0) Or (InStr(1, s, vbCrLf) > 0) Or (InStr(1, s, """") > 0) Then + s = """" & s & """" + End If + + If Len(line) = 0 Then + line = s + Else + line = line & "," & s + End If +End Sub + +' === 保存ダイアログ === +Private Function PickSavePathCsv(ByVal baseName As String) As String + Dim fd As FileDialog + Set fd = Application.FileDialog(msoFileDialogSaveAs) + With fd + .Title = "CSVファイルの保存先を選択" + .FilterIndex = 1 + .InitialFileName = baseName & "_" & Format(Now, "yymmdd_hhnnss") & ".csv" + If .Show = -1 Then + PickSavePathCsv = .SelectedItems(1) + ' 拡張子補完 + If LCase$(Right$(PickSavePathCsv, 4)) <> ".csv" Then + PickSavePathCsv = PickSavePathCsv & ".csv" + End If + Else + PickSavePathCsv = "" + End If + End With +End Function + +' === UTF-8(BOM付き)で書き出し === +Private Sub WriteUtf8WithBom(ByVal fpath As String, ByVal content As String) + Dim stmText As Object, stmBin As Object + Dim bom(2) As Byte + bom(0) = &HEF: bom(1) = &HBB: bom(2) = &HBF + + ' テキスト→UTF-8 バイトへ + Set stmText = CreateObject("ADODB.Stream") + stmText.Type = 2 ' adTypeText + stmText.Charset = "utf-8" + stmText.Open + stmText.WriteText content + stmText.Position = 0 + + ' BOM + 本文をバイナリで保存 + Set stmBin = CreateObject("ADODB.Stream") + stmBin.Type = 1 ' adTypeBinary + stmBin.Open + stmBin.Write bom + stmText.CopyTo stmBin + stmBin.SaveToFile fpath, 2 ' adSaveCreateOverWrite + + stmText.Close: stmBin.Close +End Sub + +' === UTF-8(BOMなし)で書き出し === +Private Sub WriteUtf8NoBom(ByVal fpath As String, ByVal content As String) + Dim stm As Object + Set stm = CreateObject("ADODB.Stream") + stm.Type = 2 ' adTypeText + stm.Charset = "utf-8" + stm.Open + stm.WriteText content + stm.SaveToFile fpath, 2 ' adSaveCreateOverWrite + stm.Close +End Sub + +' === 行が全空か === +Private Function RowIsAllEmpty(ByRef data As Variant, ByVal r As Long, ByVal lastCol As Long) As Boolean + Dim c As Long, s As String + For c = 1 To lastCol + s = TrimBoth(Nz(data(r, c), "")) + If Len(s) > 0 Then + RowIsAllEmpty = False + Exit Function + End If + Next c + RowIsAllEmpty = True +End Function + +' === 便利関数群 === +Private Function GetLastRow(ByVal ws As Worksheet) As Long + Dim f As Range + On Error Resume Next + Set f = ws.Cells.Find(What:="*", After:=ws.Range("A1"), LookAt:=xlPart, _ + LookIn:=xlFormulas, SearchOrder:=xlByRows, _ + SearchDirection:=xlPrevious, MatchCase:=False) + On Error GoTo 0 + If f Is Nothing Then GetLastRow = 0 Else GetLastRow = f.Row +End Function + +Private Function GetLastCol(ByVal ws As Worksheet) As Long + Dim f As Range + On Error Resume Next + Set f = ws.Cells.Find(What:="*", After:=ws.Range("A1"), LookAt:=xlPart, _ + LookIn:=xlFormulas, SearchOrder:=xlByColumns, _ + SearchDirection:=xlPrevious, MatchCase:=False) + On Error GoTo 0 + If f Is Nothing Then GetLastCol = 0 Else GetLastCol = f.Column +End Function + +Private Function SheetExists(ByVal sName As String) As Boolean + Dim ws As Worksheet + On Error Resume Next + Set ws = ThisWorkbook.Worksheets(sName) + SheetExists = Not ws Is Nothing + On Error GoTo 0 +End Function + +Private Function Nz(ByVal v As Variant, Optional ByVal defaultValue As String = "") As String + If IsError(v) Then + Nz = defaultValue + ElseIf IsNull(v) Or IsEmpty(v) Then + Nz = defaultValue + Else + Nz = CStr(v) + End If +End Function + +' 半角・全角スペースを両方トリム +Private Function TrimBoth(ByVal s As String) As String + Dim t As String + t = s + t = Replace(t, ChrW(&H3000), " ") ' 全角スペース→半角スペース + TrimBoth = Trim$(t) +End Function + +' ラベル用 正規化キー(両端空白除去 + 全角→半角 + 連続空白縮約) +Private Function NormalizeKey(ByVal s As String) As String + Dim t As String + t = TrimBoth(s) + On Error Resume Next + t = StrConv(t, vbNarrow) + On Error GoTo 0 + ' 空白の縮約 + Do While InStr(t, " ") > 0 + t = Replace(t, " ", " ") + Loop + NormalizeKey = t +End Function + +' ラベル用 簡易キー(両端トリムのみ) +Private Function NormalizeKeyBasic(ByVal s As String) As String + NormalizeKeyBasic = TrimBoth(s) +End Function + diff --git a/form.xlsx b/form.xlsx new file mode 100644 index 0000000..d7aa723 Binary files /dev/null and b/form.xlsx differ diff --git a/hensu.py b/hensu.py new file mode 100644 index 0000000..3d5787b --- /dev/null +++ b/hensu.py @@ -0,0 +1,24 @@ +import chardet +import re + +def detect_encoding(file_path): + with open(file_path, 'rb') as f: + raw_data = f.read() + result = chardet.detect(raw_data) + return result['encoding'] + +def extract_php_variables(file_path): + encoding = detect_encoding(file_path) + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + + variables = re.findall(r'\$[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*', content) + unique_vars = sorted(set(variables)) + return unique_vars + +# 使用例 +php_file = 'OfficeChusenTourakuKakutei.php' +variables = extract_php_variables(php_file) +print("PHPファイル内の変数一覧:") +for var in variables: + print(var) diff --git a/makeFomat.py b/makeFomat.py new file mode 100644 index 0000000..a56ee6d --- /dev/null +++ b/makeFomat.py @@ -0,0 +1,132 @@ + +# -*- coding: utf-8 -*- +import json +import re +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter + +PAT_H = re.compile(r'^\{\{([A-Za-z0-9_\.]+)\[\]\}\}$') # {{key[]}} +PAT_V = re.compile(r'^\{\{([A-Za-z0-9_\.]+)\[\]\[\]\}\}$') # {{key[][]}} +PAT_S = re.compile(r'^\{\{([A-Za-z0-9_\.]+)\}\}$') # {{key}} + +def excel_col_width_to_px(width_char): + if width_char is None: + width_char = 8.43 + return int(round(width_char * 7)) + +def excel_row_height_to_px(height_pt): + if height_pt is None: + height_pt = 15.0 + return int(round(height_pt * 96 / 72)) + +def export_schema_from_excel(xlsx_path: str, sheet_name: str | None, out_json: str = "schema.json"): + wb = load_workbook(xlsx_path, data_only=True) + ws = wb[sheet_name] if sheet_name else wb.active + + # 使用範囲 + dim = ws.calculate_dimension() + from openpyxl.worksheet.dimensions import SheetDimension + dims = SheetDimension(dim) + min_col, min_row, max_col, max_row = dims.min_col, dims.min_row, dims.max_col, dims.max_row + + cols_px = [] + for c in range(min_col, max_col+1): + width_char = ws.column_dimensions[get_column_letter(c)].width + cols_px.append(excel_col_width_to_px(width_char)) + + rows_px = [] + for r in range(min_row, max_row+1): + height_pt = ws.row_dimensions[r].height + rows_px.append(excel_row_height_to_px(height_pt)) + + # 結合セル + covered = set() + merged_top = {} + for mr in ws.merged_cells.ranges: + r0, r1 = mr.min_row, mr.max_row + c0, c1 = mr.min_col, mr.max_col + merged_top[(r0, c0)] = {"rowspan": r1 - r0 + 1, "colspan": c1 - c0 + 1} + for rr in range(r0, r1+1): + for cc in range(c0, c1+1): + if not (rr == r0 and cc == c0): + covered.add((rr, cc)) + + cells = [] + for r in range(min_row, max_row+1): + for c in range(min_col, max_col+1): + if (r, c) in covered: + continue + cell = ws.cell(row=r, column=c) + raw = cell.value + s = str(raw) if raw is not None else "" + + entry = { + "r": r - min_row + 1, + "c": c - min_col + 1, + "rowspan": 1, + "colspan": 1, + "style": {}, + "type": "text", # 既定はラベル/テキスト + } + if (r, c) in merged_top: + entry["rowspan"] = merged_top[(r, c)]["rowspan"] + entry["colspan"] = merged_top[(r, c)]["colspan"] + + # スタイル(簡易) + if cell.font and cell.font.bold: + entry["style"]["bold"] = True + hal = cell.alignment.horizontal + if hal: + entry["style"]["align_h"] = hal + val = cell.alignment.vertical + if val: + entry["style"]["align_v"] = val + if cell.alignment.wrap_text: + entry["style"]["wrap"] = True + + # マーカー判定 + m = PAT_H.match(s) + if m: + entry["type"] = "repeat_h" + entry["repeat"] = {"key": m.group(1)} + cells.append(entry) + continue + + m = PAT_V.match(s) + if m: + entry["type"] = "repeat_v" + entry["repeat"] = {"key": m.group(1)} + cells.append(entry) + continue + + m = PAT_S.match(s) + if m: + entry["type"] = "value" + entry["key"] = m.group(1) + entry["label"] = None + else: + entry["label"] = s # ラベル扱い + + cells.append(entry) + + schema = { + "page": {"size": "A4", "margin_mm": 12}, + "cols": cols_px, + "rows": rows_px, + "cells": cells, + # リピータの既定動作(必要に応じて上書き可能) + "repeat_defaults": { + "gap_px": 0, # 項目間の隙間(横/縦) + "border": None, # 項目間の区切り線 + "item_wrap": True, # 項目内の折り返し + "max_cols": None # 横方向の最大列数(Noneなら均等に1行で詰める) + } + } + + with open(out_json, "w", encoding="utf-8") as f: + json.dump(schema, f, ensure_ascii=False, indent=2) + + print(f"書き出し: {out_json}") + +if __name__ == "__main__": + export_schema_from_excel("form.xlsx", sheet_name="A4_帳票", out_json="schema.json") diff --git a/op_config.ini b/op_config.ini new file mode 100644 index 0000000..d2a4613 --- /dev/null +++ b/op_config.ini @@ -0,0 +1,4 @@ +[openproject] +base_url = http://49.212.212.94:8181 +api_key = 4b67557dd9a5c7da8f1c18baa4bd41263b08345eda4cc23857731c49c60dcd69 +project_identifier = rmsisutemukai-fa diff --git a/op_wp_hours_to_excel.py b/op_wp_hours_to_excel.py new file mode 100644 index 0000000..8661e04 --- /dev/null +++ b/op_wp_hours_to_excel.py @@ -0,0 +1,301 @@ + +# -*- coding: utf-8 -*- +""" +OpenProject: 指定期間のTime entriesをWork Packageごと + ユーザー別に合計し、階層付きでExcel出力 +- プロジェクト識別子: rmsisutemukai-fa +- 認証: Basic + APIキー +""" + +import json +import re +import sys +import datetime as dt +from typing import Dict, List, Tuple, Optional, Set +import requests +from requests.auth import HTTPBasicAuth +import pandas as pd + + +# ====== 設定(config.ini / 環境変数 両対応) ====== +import os, configparser + +CONFIG_FILE = "op_config.ini" +config = configparser.ConfigParser() +read_ok = config.read(CONFIG_FILE) + +# まず config.ini を試し、無ければ環境変数にフォールバック +OP_BASE_URL = None +API_KEY = None +PROJECT_IDENTIFIER = None +API_BASE = None + +if read_ok and "openproject" in config: + section = config["openproject"] + OP_BASE_URL = section.get("base_url", "").strip().rstrip("/") + API_KEY = section.get("api_key", "").strip() + PROJECT_IDENTIFIER = section.get("project_identifier", "").strip() + +# 環境変数(OP_BASE_URL / OP_API_KEY / OP_PROJECT_IDENTIFIER)があれば上書き +OP_BASE_URL = os.getenv("OP_BASE_URL", OP_BASE_URL or "").strip().rstrip("/") +API_KEY = os.getenv("OP_API_KEY", API_KEY or "").strip() +PROJECT_IDENTIFIER = os.getenv("OP_PROJECT_IDENTIFIER", PROJECT_IDENTIFIER or "rmsisutemukai-fa").strip() +API_BASE = f"{OP_BASE_URL}/api/v3" + +if not OP_BASE_URL or not API_KEY: + raise RuntimeError( + "API接続設定が不足しています。config.ini または環境変数(OP_BASE_URL / OP_API_KEY)を設定してください。" + ) + + +OUTPUT_PREFIX = "openproject_hours" +TIMEOUT = 15 + +# ====== セッション(Basic + APIキー) ====== +session = requests.Session() +session.auth = HTTPBasicAuth("apikey", API_KEY) +session.headers.update({ + "Accept": "application/hal+json", + "Content-Type": "application/json" +}) + +# ====== ユーティリティ ====== +def iso8601_duration_to_hours(value) -> float: + """'PT#H#M#S' または小数文字列/数値を時間(float)に変換""" + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + return 0.0 + s = value.strip() + if re.match(r"^[0-9.]+$", s): + try: + return float(s) + except Exception: + return 0.0 + m = re.match(r"^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", s) + if not m: + return 0.0 + h = float(m.group(1) or 0) + m_ = float(m.group(2) or 0) + s_ = float(m.group(3) or 0) + return h + m_/60.0 + s_/3600.0 + +def http_get(url: str, params: Optional[dict] = None) -> dict: + r = session.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + return r.json() + +def http_get_all_pages(url: str, base_params: Optional[dict] = None, page_size: int = 200) -> List[dict]: + params = dict(base_params or {}) + params["pageSize"] = page_size + all_elements = [] + offset = 1 + while True: + params["offset"] = offset + data = http_get(url, params=params) + elements = data.get("_embedded", {}).get("elements", []) + all_elements.extend(elements) + total = data.get("total", None) + if total is None: + if not elements or len(elements) < page_size: + break + else: + if len(all_elements) >= total: + break + offset += 1 + return all_elements + +# ====== Projects: 識別子→ID ====== +def resolve_project_id_by_identifier(identifier: str) -> Optional[int]: + url = f"{API_BASE}/projects" + elements = http_get_all_pages(url, {}) + for p in elements: + if p.get("identifier") == identifier: + return int(p.get("id")) + return None + +# ====== Users: ID→表示名(name) ====== +_user_name_cache: Dict[int, str] = {} +def get_user_name(user_id: int) -> str: + if user_id in _user_name_cache: + return _user_name_cache[user_id] + data = http_get(f"{API_BASE}/users/{user_id}") + # Usersのローカルプロパティ: name(フルネーム)を利用 + name = data.get("name") or f"User#{user_id}" + _user_name_cache[user_id] = name + return name + +# ====== Time entries取得(期間) ====== +def get_time_entries_in_project_between(project_id: int, start_date: str, end_date: str) -> List[dict]: + url = f"{API_BASE}/time_entries" + filters = [ + {"project": {"operator": "=", "values": [str(project_id)]}}, + {"spentOn": {"operator": "><", "values": [start_date, end_date]}} + ] + params = {"filters": json.dumps(filters)} + try: + return http_get_all_pages(url, base_params=params) + except requests.HTTPError: + # spentOn演算子が未対応の場合はprojectのみで取得→Python側で日付フィルタ + params = {"filters": json.dumps([{"project": {"operator": "=", "values": [str(project_id)]}}])} + entries = http_get_all_pages(url, base_params=params) + sd = dt.date.fromisoformat(start_date) + ed = dt.date.fromisoformat(end_date) + return [te for te in entries + if te.get("spentOn") and sd <= dt.date.fromisoformat(te["spentOn"]) <= ed] + +# ====== WP情報(件名+先祖) ====== +def get_work_package_with_ancestors(wp_id: int) -> Tuple[str, List[str]]: + wp = http_get(f"{API_BASE}/work_packages/{wp_id}") + subject = wp.get("subject") or "" + ancestors = [] + for anc in wp.get("_links", {}).get("ancestors", []): + title = anc.get("title") + href = anc.get("href") + if title: + ancestors.append(title) + elif href: + anc_id = int(href.rstrip("/").split("/")[-1]) + anc_wp = http_get(f"{API_BASE}/work_packages/{anc_id}") + ancestors.append(anc_wp.get("subject") or f"WP#{anc_id}") + return subject, ancestors + +# ====== 集計:WP×ユーザー ====== +def aggregate_hours_by_wp_and_user(entries: List[dict]) -> Tuple[Dict[int, Dict[str, float]], Dict[int, float], Set[str]]: + """ + 戻り値: + - per_wp_user: {wp_id: {user_name: hours}} + - per_wp_total: {wp_id: total_hours} + - all_users: {user_name, ...} 期間内に登場した全ユーザー名 + """ + per_wp_user: Dict[int, Dict[str, float]] = {} + per_wp_total: Dict[int, float] = {} + all_users: Set[str] = set() + + for te in entries: + # entityがWorkPackageのみ対象 + entity = te.get("_links", {}).get("entity") or te.get("_links", {}).get("workPackage") + if not entity: + continue + m = re.search(r"/api/v3/work_packages/(\d+)$", entity.get("href", "")) + if not m: + continue + wp_id = int(m.group(1)) + + # ユーザーID→名前 + ulink = te.get("_links", {}).get("user") + if not ulink or not ulink.get("href"): + continue + um = re.search(r"/api/v3/users/(\d+)$", ulink["href"]) + if not um: + continue + user_id = int(um.group(1)) + user_name = get_user_name(user_id) + + hours = iso8601_duration_to_hours(te.get("hours")) + if hours <= 0: + continue + + # 集計 + per_wp_user.setdefault(wp_id, {}) + per_wp_user[wp_id][user_name] = per_wp_user[wp_id].get(user_name, 0.0) + hours + per_wp_total[wp_id] = per_wp_total.get(wp_id, 0.0) + hours + all_users.add(user_name) + + return per_wp_user, per_wp_total, all_users + +# ====== 行データ(階層+ユーザー列) ====== +def build_rows_with_users(project_identifier: str, + per_wp_user: Dict[int, Dict[str, float]], + per_wp_total: Dict[int, float], + all_users: Set[str], + start_date: str, end_date: str) -> List[dict]: + rows = [] + max_depth = 0 + cache_wp: Dict[int, Tuple[str, List[str]]] = {} + + # ユーザー列の順序(名前で昇順) + user_cols = sorted(all_users) + + for wp_id, user_map in per_wp_user.items(): + if wp_id in cache_wp: + subject, ancestors = cache_wp[wp_id] + else: + subject, ancestors = get_work_package_with_ancestors(wp_id) + cache_wp[wp_id] = (subject, ancestors) + max_depth = max(max_depth, len(ancestors)) + + path = " / ".join(ancestors + [subject]) + + row = { + "プロジェクト識別子": project_identifier, + "WP ID": wp_id, + "階層パス": path, + "件名": subject, + "総計[時間]": round(per_wp_total.get(wp_id, 0.0), 2), + "期間(開始)": start_date, + "期間(終了)": end_date, + "_ancestors": ancestors + } + # ユーザー別列 + for uname in user_cols: + row[f"工数[時間] - {uname}"] = round(user_map.get(uname, 0.0), 2) + rows.append(row) + + # 祖先列(親1, 親2, …) + for i in range(max_depth): + col = f"親{i+1}" + for row in rows: + row[col] = (row["_ancestors"][i] if i < len(row["_ancestors"]) else "") + for row in rows: + row.pop("_ancestors", None) + + # ソート:階層パス→件名→WP ID + rows.sort(key=lambda r: (r["階層パス"], r["件名"], r["WP ID"])) + return rows + +# ====== Excel出力 ====== +def to_excel(rows: List[dict], outfile: str): + df = pd.DataFrame(rows) + base_cols = ["プロジェクト識別子", "WP ID", "階層パス", "件名"] + parent_cols = [c for c in df.columns if c.startswith("親")] + user_cols = [c for c in df.columns if c.startswith("工数[時間] - ")] + summary_cols = ["総計[時間]", "期間(開始)", "期間(終了)"] + ordered = base_cols + parent_cols + ["総計[時間]"] + user_cols + ["期間(開始)", "期間(終了)"] + df = df[ordered] + df.to_excel(outfile, index=False, engine="openpyxl") + print(f"[OK] Excelを書き出しました: {outfile}") + +# ====== メイン ====== +def main(project_identifier: str, start_date: str, end_date: str): + # 期間validate + sd = dt.date.fromisoformat(start_date); ed = dt.date.fromisoformat(end_date) + if sd > ed: + raise ValueError("開始日は終了日以前である必要があります。") + + # プロジェクトID解決 + project_id = resolve_project_id_by_identifier(project_identifier) + if project_id is None: + print(f"[ERROR] プロジェクト識別子 '{project_identifier}' が見つかりません。") + sys.exit(1) + print(f"[INFO] プロジェクトID = {project_id}") + + # 期間のTime entries取得 + entries = get_time_entries_in_project_between(project_id, start_date, end_date) + print(f"[INFO] 取得Time entries件数 = {len(entries)}") + + # 集計:WP×ユーザー + per_wp_user, per_wp_total, all_users = aggregate_hours_by_wp_and_user(entries) + print(f"[INFO] 作業時間>0 のWP件数 = {len(per_wp_total)} / ユーザー数 = {len(all_users)}") + + if not per_wp_total: + print("[INFO] 対象期間に合計>0のWork Packageがありません。Excelは出力しません。") + return + + rows = build_rows_with_users(project_identifier, per_wp_user, per_wp_total, all_users, start_date, end_date) + outfile = f"{OUTPUT_PREFIX}_{project_identifier}_{start_date}_{end_date}_users.xlsx" + to_excel(rows, outfile) + +if __name__ == "__main__": + START_DATE = "2026-03-01" + END_DATE = "2026-03-31" + main(PROJECT_IDENTIFIER, START_DATE, END_DATE) diff --git a/機能仕様書作成.xlsm b/機能仕様書作成.xlsm new file mode 100644 index 0000000..c9ffb9c Binary files /dev/null and b/機能仕様書作成.xlsm differ