スクリプトファイルを追加

This commit is contained in:
2026-04-03 18:22:33 +09:00
commit 809d3bc3e4
14 changed files with 1956 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/old

214
TicketSend.py Normal file
View File

@@ -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("<api_key>: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の詳細URLselfリンク
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()

View File

@@ -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()

26
compare_php/folders.txt Normal file
View File

@@ -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

Binary file not shown.

30
compare_php/readme.md Normal file
View File

@@ -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ファイルは閉じておくこと

489
csv_maker_vba.txt Normal file
View File

@@ -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

411
csv_output_vba.txt Normal file
View File

@@ -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.Dictionarylabel→code
Dim setValidCode() As Object ' Scripting.Dictionarycode→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] または [codelabel] を辞書化) ===
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" / "codelabel" 形式
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-8BOM付きで書き出し ===
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-8BOMなしで書き出し ===
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

BIN
form.xlsx Normal file

Binary file not shown.

24
hensu.py Normal file
View File

@@ -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)

132
makeFomat.py Normal file
View File

@@ -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")

4
op_config.ini Normal file
View File

@@ -0,0 +1,4 @@
[openproject]
base_url = http://49.212.212.94:8181
api_key = 4b67557dd9a5c7da8f1c18baa4bd41263b08345eda4cc23857731c49c60dcd69
project_identifier = rmsisutemukai-fa

301
op_wp_hours_to_excel.py Normal file
View File

@@ -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)

BIN
機能仕様書作成.xlsm Normal file

Binary file not shown.