launchan
launchan つくり中
launchan 作成中
ps1
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
function Main {
# $table =
Import-CSVToDataTable
Write-Host $table.Rows.Count
Write-Host $table.GetType().FullName
if ($null -eq $table.DefaultView) {
Write-Host "table.DefaultView is null"
return
}
$size = Get-Size 690 280
# Form
$form = New-Object System.Windows.Forms.Form
$form.Text = "launchan.ps1"
$form.Size = $size.FormSize
# 画面サイズを取得
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$screenBounds = $screen.WorkingArea # タスクバー除いた領域
# 左下に配置
$form.StartPosition = 'Manual'
$form.Location = New-Object System.Drawing.Point(((0 - 10), ($screenBounds.Height - $form.Height + 10)))
# Search TextBox
$textBox = New-Object System.Windows.Forms.TextBox
$textBox.Location = $size.textBoxLocation
$textBox.Width = $size.TextBoxWidth
$textBox.Anchor = "Top, Left, Right"
$form.Controls.Add($textBox)
<#
$clearButton = New-Object System.Windows.Forms.Button
$clearButton.Text = "×"
$clearButton.Size = New-Object System.Drawing.Size(25, 23)
$clearButton.Location = New-Object System.Drawing.Point(190, 10)
$clearButton.Add_Click({ $textBox.Text = "" })
$form.Controls.Add($clearButton)
# $form.Controls.AddRange(@($textBox, $clearButton))
#>
# Category ComboBox
$comboBox = New-Object System.Windows.Forms.ComboBox
$comboBox.DropDownStyle = 'DropDownList'
$comboBox.Location = $size.ComboBoxLocation
$comboBox.Width = $size.ComboBoxWidth
$comboBox.Anchor = "Top, Right"
# set ComboBox items
Write-Host $table.DefaultView # -> null
$distinctFiles = $table.DefaultView.ToTable($true, "Category") | ForEach-Object { $_.Category }
$comboBox.Items.Add("All") | Out-Null
$distinctFiles | Sort-Object -Unique | ForEach-Object { $comboBox.Items.Add($_) | Out-Null }
$comboBox.SelectedIndex = 0
$form.Controls.Add($comboBox)
# Result Grid
$grid = New-Object System.Windows.Forms.DataGridView
$grid.Location = $size.GridLocation
$grid.Size = $size.GridSize
$grid.Anchor = "Top, Left, Right, Bottom"
$grid.ReadOnly = $true
$grid.SelectionMode = 'FullRowSelect'
# $grid.AutoSizeColumnsMode = 'Fill'
$grid.AutoSizeColumnsMode = 'None'
$grid.DataSource = $table.DefaultView
$grid.RowHeadersVisible = $false
$grid.AllowUserToAddRows = $false
$grid.AllowUserToResizeRows = $false
$form.Controls.Add($grid)
# ▼ 検索フィルタ処理
function ApplyFilter {
$query = $textBox.Text
$category = $comboBox.SelectedItem
$filters = @()
if (-not [string]::IsNullOrWhiteSpace($query)) {
$escapedQuery = $query -replace "'", "''"
$filters += "Title LIKE '%$escapedQuery%'"
}
if ($category -ne "All" -and $category) {
$escapedFile = $category -replace "'", "''"
$filters += "Category = '$escapedFile'"
}
$table.DefaultView.RowFilter = ($filters -join " AND ")
}
$textBox.Add_TextChanged({ ApplyFilter })
$comboBox.Add_SelectedIndexChanged({ ApplyFilter })
$textBox.Add_KeyDown({
if ($_.Control -and $_.KeyCode -eq 'A') {
$textBox.SelectAll()
$_.Handled = $true
}
})
$grid.add_CellClick({
$row = $grid.CurrentRow
if ($row) { ShowRowDetails $row }
})
$grid.add_KeyDown({
param($_sender, $e)
if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
$row = $grid.CurrentRow
if ($row) {
ShowRowDetails $row
$e.SuppressKeyPress = $true
}
}
})
$grid.Add_CellMouseEnter({
param($sender, $e)
if ($e.ColumnIndex -ge 0 -and $e.RowIndex -ge 0) {
$colName = $grid.Columns[$e.ColumnIndex].Name
# if ($colName -eq 'Title') {
$grid.Cursor = [System.Windows.Forms.Cursors]::Hand
#}
}
if ($e.RowIndex -ge 0) {
if ($script:lastHoverRowIndex -ge 0 -and $script:lastHoverRowIndex -ne $e.RowIndex) {
$grid.Rows[$script:lastHoverRowIndex].DefaultCellStyle.BackColor = [System.Drawing.Color]::White
}
$grid.Rows[$e.RowIndex].DefaultCellStyle.BackColor = [System.Drawing.Color]::AliceBlue
$script:lastHoverRowIndex = $e.RowIndex
}
})
$grid.Add_CellMouseLeave({
param($sender, $e)
$grid.Cursor = [System.Windows.Forms.Cursors]::Default
if ($script:lastHoverRowIndex -ge 0) {
$grid.Rows[$script:lastHoverRowIndex].DefaultCellStyle.BackColor = [System.Drawing.Color]::White
$script:lastHoverRowIndex = -1
}
})
$grid.Add_CellFormatting({
param($sender, $e)
if ($e.RowIndex -ge 0 -and $e.ColumnIndex -ge 0) {
$cell = $grid.Rows[$e.RowIndex].Cells[$e.ColumnIndex]
$text = $cell.Value
if ($null -ne $text -and $text -is [string]) {
# セルの表示範囲を取得
$cellRectangle = $grid.GetCellDisplayRectangle($e.ColumnIndex, $e.RowIndex, $false)
# フォントとサイズから描画サイズを見積もる
$textSize = [System.Windows.Forms.TextRenderer]::MeasureText($text, $grid.Font)
# 文字がはみ出していたらツールチップを設定
if ($textSize.Width -gt $cellRectangle.Width) {
$cell.ToolTipText = $text
} else {
$cell.ToolTipText = ""
}
}
}
})
$grid.Add_PreviewKeyDown({
param($sender, $e)
if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Tab) {
$form.SelectNextControl($grid, $true, $true, $true, $true)
$e.IsInputKey = $false # ← Tab キーを通常キーとして処理しないよう明示 (タブストップをほかのコントロールに移動するため)
}
})
# コンテキストメニューの生成
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$grid.Add_MouseDown({
param($sender, $e)
if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Right) {
# セルの位置を取得
$hit = $grid.HitTest($e.X, $e.Y)
if ($hit.RowIndex -ge 0) {
$grid.ClearSelection()
$grid.Rows[$hit.RowIndex].Selected = $true
$script:selectedFileName = $grid.Rows[$hit.RowIndex].Cells["Command"].Value
# 拡張子が .xls or .xlsx のときだけメニュー追加
$menu.Items.Clear()
if ($selectedFileName -match '\.xlsx?$') {
$item = New-Object System.Windows.Forms.ToolStripMenuItem
$item.Text = "読み取り専用で開く"
# クリック時の処理
$item.Add_Click({
Write-Host $selectedFileName
Start-Process "excel.exe" "/r `"$selectedFileName`""
})
$menu.Items.Add($item)
}
if ($menu.Items.Count -gt 0) {
$menu.Show($grid, $e.Location)
}
}
}
})
$form.Add_Shown({
# $form.Activate()
try {
$grid.Columns["Title"].DisplayIndex = 0
$grid.Columns["Command"].DisplayIndex = 1
$grid.Columns["Params"].DisplayIndex = 2
$grid.Columns["Tags"].DisplayIndex = 3
$grid.Columns["Category"].DisplayIndex = 4
$grid.AutoSizeColumnsMode = 'None'
$grid.ScrollBars = 'Both'
$grid.Columns["Title"].Width = 200
$grid.Columns["Command"].Width = 150
# $grid.Columns["Params"].AutoSizeMode = 'Fill'
$grid.Columns["Params"].Width = 100
$grid.Columns["Tags"].Width = 100
$grid.Columns["Category"].Width = 80
# $grid.Columns["Title"].Frozen = $true
# $grid.Columns["Command"].Frozen = $true
foreach ($col in $grid.Columns) {
$col.Resizable = [System.Windows.Forms.DataGridViewTriState]::True
}
} catch {}
})
[void]$form.ShowDialog()
}
# ------------------------------------------------------------------------------------------
# functions
# ------------------------------------------------------------------------------------------
function Import-CSVToDataTable {
$csvDir = Join-Path $PSScriptRoot "samples"
$csvFiles = Get-ChildItem -Path $csvDir -Filter *.csv
$script:table = New-Object System.Data.DataTable
$isColumnsAdded = $false
foreach ($csvFile in $csvFiles) {
$rows = Import-Csv -Path $csvFile.FullName -Encoding UTF8
if ($rows.Count -eq 0) {
continue
}
# Define column with first file
if (-not $isColumnsAdded) {
$rows[0].PSObject.Properties.Name | ForEach-Object {
[void]$table.Columns.Add($_)
}
[void]$table.Columns.Add("Category")
$isColumnsAdded = $true
}
foreach ($row in $rows) {
$datarow = $table.NewRow()
foreach ($col in $row.PSObject.Properties) {
$datarow[$col.Name] = $col.Value
}
$datarow["Category"] = $csvFile.BaseName
$table.Rows.Add($datarow)
}
}
if ($table.Rows.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show("CSVファイルにデータがありません", "launchan", 'OK', 'Error')
exit 1
}
# return $table
}
function Get-Size([int]$w, [int]$h) {
return @{
FormSize = [System.Drawing.Size]::new( $w , $h )
TextBoxWidth = $w - 150
ComboBoxWidth = 100
GridSize = [System.Drawing.Size]::new( $w - 40 , $h - 90)
TextBoxLocation = [System.Drawing.Point]::new( 10 , 10 )
ComboBoxLocation = [System.Drawing.Point]::new( $w - 130 , 10 )
GridLocation = [System.Drawing.Point]::new( 10 , 40 )
}
}
function ShowRowDetails($row) {
$rawCommand = $row.Cells["Command"].Value
$rawParams = $row.Cells["Params"].Value
$command = [Environment]::ExpandEnvironmentVariables($rawCommand)
$params = [Environment]::ExpandEnvironmentVariables($rawParams)
Write-Host "Command: $command"
Write-Host "Params: $params"
if ($params -eq "") {
Start-Process -FilePath $command
} else {
Start-Process -FilePath $command -ArgumentList $params
}
}
Main
launchan.vbs
Set fso = CreateObject("Scripting.FileSystemObject")
Set shell = CreateObject("WScript.Shell")
ps1Path = fso.GetParentFolderName(WScript.ScriptFullName) & "\launchan.ps1"
shell.Run "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & ps1Path & """", 0, False
関連ツール
ショートカットのCSV化
ps1
$folderPath = "C:\Path\To\Your\Folder" # ← 適宜変更
$outputCsv = "$folderPath\shortcuts_summary.csv"
$results = @()
# .lnk ファイルの処理(ショートカット)
$lnkFiles = Get-ChildItem -Path $folderPath -Filter *.lnk
foreach ($lnkFile in $lnkFiles) {
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($lnkFile.FullName)
$targetPath = $shortcut.TargetPath
$exists = if (Test-Path $targetPath) { "Exists" } else { "Not Found" }
$results += [PSCustomObject]@{
Name = $lnkFile.BaseName
Target = $targetPath
Status = $exists
Type = "shortcut"
}
}
# .url ファイルの処理(URLリンク)
$urlFiles = Get-ChildItem -Path $folderPath -Filter *.url
foreach ($urlFile in $urlFiles) {
$lines = Get-Content $urlFile.FullName
$url = ($lines | Where-Object { $_ -match "^URL=" }) -replace "^URL=", ""
$status = if ($url -match '^https?://') { "URL" } else { "Invalid" }
$results += [PSCustomObject]@{
Name = $urlFile.BaseName
Target = $url
Status = $status
Type = "url"
}
}
# CSVに書き出し
$results | Export-Csv -Path $outputCsv -Encoding UTF8 -NoTypeInformation
py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from string import Template
from typing import Iterable
METADATA_FILENAME = "_toc-metadata.md"
TREE_MODES = {"tree-md", "tree-md-dirs-only"}
FLAT_MODES = {"flat-csv", "flat-xlsx"}
DEFAULT_FLAT_COLUMNS = "sequence-separated,dirnames-title,filename-title,description"
DEFAULT_TREE_FORMAT = "${indent}- [${title}](${fullpath}) : ${description}"
RESERVED_METADATA_KEYS = {"title", "description", "order", "hidden"}
LINE_METADATA_RE = re.compile(r"^\s*-\s*(?P<key>[^:]+?)\s*:\s*(?P<value>.*?)\s*$")
FILE_METADATA_BLOCK_RE = re.compile(r"<!--\s*toc-metadata\s*\n(?P<body>.*?)\n-->", re.DOTALL)
H1_RE = re.compile(r"^# (.+?)\s*$")
H2_RE = re.compile(r"^## (.+?)\s*$")
class TocError(Exception):
pass
@dataclass
class ColumnSpec:
key: str
label: str
@dataclass
class Metadata:
values: dict[str, str] = field(default_factory=dict)
order: int | None = None
hidden: bool = False
def get(self, key: str) -> str:
if key == "order":
return "" if self.order is None else str(self.order)
if key == "hidden":
return "true" if self.hidden else "false"
return self.values.get(key, "")
@dataclass
class Node:
path: Path
rel_path: str
is_dir: bool
depth: int
parent: Node | None = None
dir_metadata: Metadata = field(default_factory=Metadata)
file_metadata: Metadata = field(default_factory=Metadata)
h1_title: str = ""
h2_values: dict[str, str] = field(default_factory=dict)
children: list[Node] = field(default_factory=list)
sequence: int = 0
sequence_separated: str = ""
@property
def basename(self) -> str:
if not self.rel_path:
return "/"
return self.rel_path.rsplit("/", 1)[-1]
@property
def dirname(self) -> str:
if not self.rel_path or "/" not in self.rel_path:
return ""
return self.rel_path.rsplit("/", 1)[0]
@property
def fullpath(self) -> str:
return self.rel_path
@property
def active_metadata(self) -> Metadata:
return self.dir_metadata if self.is_dir else self.file_metadata
@property
def title(self) -> str:
if self.is_dir:
return self.dir_metadata.values.get("title", self.basename)
return self.h1_title or self.basename
@property
def description(self) -> str:
return self.active_metadata.values.get("description", "")
def key_value(self, key_name: str) -> str:
return self.active_metadata.values.get(key_name, "")
@dataclass
class DirGroupLayout:
column_indexes: list[int]
raw_rows: list[list[str]]
@dataclass
class FlatLayout:
header: list[str]
rows: list[list[str]]
dir_groups: list[DirGroupLayout]
def normalize_slashes(value: str) -> str:
return value.replace("\\", "/")
def normalize_rel_path(path: Path, base_dir: Path) -> str:
rel = normalize_slashes(str(path.relative_to(base_dir)))
return "" if rel == "." else rel
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("base_dir")
parser.add_argument(
"--out-mode",
default="tree-md",
choices=sorted(TREE_MODES | FLAT_MODES),
)
parser.add_argument("--out-file-path")
parser.add_argument("--flat-columns", default=DEFAULT_FLAT_COLUMNS)
parser.add_argument("--tree-node-format", default=DEFAULT_TREE_FORMAT)
parser.add_argument("--with-root-node", action="store_true")
parser.add_argument("--max-dir-columns", type=int)
parser.add_argument("--exclude-path-pattern", nargs="+", default=[])
parser.add_argument("--excel-start-cell", default="B2")
return parser.parse_args()
def validate_args(args: argparse.Namespace) -> None:
if args.out_mode in TREE_MODES:
if args.max_dir_columns is not None:
raise TocError("--max-dir-columns is available only in flat-* modes")
if args.out_mode in FLAT_MODES:
if args.with_root_node:
raise TocError("--with-root-node is available only in tree-* modes")
if args.out_mode in TREE_MODES and args.flat_columns != DEFAULT_FLAT_COLUMNS:
raise TocError("--flat-columns is available only in flat-* modes")
if args.out_mode in FLAT_MODES and args.tree_node_format != DEFAULT_TREE_FORMAT:
raise TocError("--tree-node-format is available only in tree-* modes")
if args.out_mode in TREE_MODES and args.excel_start_cell != "B2":
raise TocError("--excel-start-cell is available only in flat-* modes")
if args.out_mode in TREE_MODES and args.out_file_path is None:
return
if args.max_dir_columns is not None and args.max_dir_columns <= 0:
raise TocError("--max-dir-columns must be a positive integer")
def parse_flat_columns(value: str) -> list[ColumnSpec]:
specs: list[ColumnSpec] = []
if not value.strip():
raise TocError("--flat-columns must not be empty")
for raw_part in value.split(","):
part = raw_part.strip()
if not part:
raise TocError("empty column found in --flat-columns")
key, label = parse_key_label(part, "--flat-columns")
validate_placeholder_key(key, "--flat-columns")
specs.append(ColumnSpec(key=key, label=label or key))
return specs
def parse_key_label(value: str, source: str) -> tuple[str, str | None]:
if ":" not in value:
return value, None
key, label = value.split(":", 1)
key = key.strip()
label = label.strip()
if not key or not label:
raise TocError(f"invalid key/label pair in {source}: {value}")
return key, label
def validate_placeholder_key(key: str, source: str) -> None:
allowed = {
"sequence",
"sequence-separated",
"dirnames",
"dirnames-title",
"fullpath",
"dirname",
"filename",
"filename-title",
"depth",
"description",
"title",
"order",
"hidden",
"indent",
}
if key in allowed:
return
if key.startswith("key-") and len(key) > 4:
return
if key.startswith("h2-") and len(key) > 3:
return
raise TocError(f"unsupported key in {source}: {key}")
def collect_requested_h2_keys(flat_specs: list[ColumnSpec], tree_format: str) -> set[str]:
keys: set[str] = set()
for spec in flat_specs:
if spec.key.startswith("h2-"):
keys.add(spec.key[3:])
for placeholder in re.findall(r"\$\{([^}]+)\}", tree_format):
if placeholder.startswith("h2-"):
keys.add(placeholder[3:])
return keys
def parse_metadata_lines(text: str, source: str, is_dir: bool) -> Metadata:
metadata = Metadata()
keys_seen: dict[str, str] = {}
reserved_lower_seen: dict[str, str] = {}
for line in text.splitlines():
match = LINE_METADATA_RE.match(line)
if not match:
continue
key = match.group("key").strip()
value = match.group("value").strip()
if key in keys_seen:
raise TocError(f"duplicate metadata key '{key}' in {source}")
lowered = key.lower()
if lowered in {"title", "description"}:
existing = reserved_lower_seen.get(lowered)
if existing is not None and existing != key:
raise TocError(f"invalid key case '{key}' in {source}")
reserved_lower_seen[lowered] = key
keys_seen[key] = value
if key == "title":
if not is_dir:
raise TocError(f"file metadata must not contain 'title' in {source}")
metadata.values[key] = value
elif key == "description":
metadata.values[key] = value
elif key == "order":
try:
metadata.order = int(value)
except ValueError as exc:
raise TocError(f"invalid order value in {source}: {value}") from exc
elif key == "hidden":
if value not in {"true", "false"}:
raise TocError(f"invalid hidden value in {source}: {value}")
metadata.hidden = value == "true"
else:
if ":" in key or "," in key:
raise TocError(f"invalid metadata key '{key}' in {source}")
if key.lower() in RESERVED_METADATA_KEYS and key not in RESERVED_METADATA_KEYS:
raise TocError(f"invalid key case '{key}' in {source}")
metadata.values[key] = value
return metadata
def read_directory_metadata(dir_path: Path) -> Metadata:
entries = list(dir_path.iterdir())
matches = [entry.name for entry in entries if entry.name.lower() == METADATA_FILENAME.lower()]
if len(matches) > 1:
raise TocError(f"multiple metadata files found under {dir_path}")
if matches and matches[0] != METADATA_FILENAME:
raise TocError(f"invalid metadata filename case under {dir_path}: {matches[0]}")
metadata_path = dir_path / METADATA_FILENAME
if not metadata_path.exists():
return Metadata()
return parse_metadata_lines(metadata_path.read_text(encoding="utf-8"), str(metadata_path), is_dir=True)
def read_file_metadata(file_path: Path, requested_h2_keys: set[str]) -> tuple[Metadata, str, dict[str, str]]:
text = file_path.read_text(encoding="utf-8")
blocks = list(FILE_METADATA_BLOCK_RE.finditer(text))
if len(blocks) > 1:
raise TocError(f"multiple toc-metadata blocks found in {file_path}")
metadata = Metadata()
if blocks:
metadata = parse_metadata_lines(blocks[0].group("body"), str(file_path), is_dir=False)
markdown_body = strip_toc_metadata_block(text)
h1_title = parse_h1_title(markdown_body)
h2_values = parse_h2_values(markdown_body, requested_h2_keys)
return metadata, h1_title, h2_values
def strip_toc_metadata_block(text: str) -> str:
return FILE_METADATA_BLOCK_RE.sub("", text, count=1)
def parse_h1_title(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
match = H1_RE.match(stripped)
return match.group(1).strip() if match else ""
return ""
def parse_h2_values(text: str, requested_h2_keys: set[str]) -> dict[str, str]:
if not requested_h2_keys:
return {}
values: dict[str, str] = {}
current_key: str | None = None
waiting_for_body = False
for line in text.splitlines():
stripped = line.rstrip("\n")
h2_match = H2_RE.match(stripped.strip())
if h2_match:
key = h2_match.group(1).strip()
current_key = key if key in requested_h2_keys else None
waiting_for_body = current_key is not None and current_key not in values
continue
if re.match(r"^#\s+", stripped.strip()):
current_key = None
waiting_for_body = False
continue
if current_key is None or not waiting_for_body:
continue
if stripped.strip():
values[current_key] = stripped.strip()
waiting_for_body = False
return values
def should_exclude(rel_path: str, patterns: list[str]) -> bool:
return any(pattern in rel_path for pattern in patterns)
def build_tree(base_dir: Path, requested_h2_keys: set[str], exclude_patterns: list[str]) -> list[Node]:
def visit_dir(dir_path: Path, parent: Node | None) -> Node | None:
rel_path = normalize_rel_path(dir_path, base_dir)
if rel_path and should_exclude(rel_path, exclude_patterns):
return None
dir_metadata = read_directory_metadata(dir_path)
if dir_metadata.hidden:
return None
node = Node(
path=dir_path,
rel_path=rel_path,
is_dir=True,
depth=0 if not rel_path else rel_path.count("/"),
parent=parent,
dir_metadata=dir_metadata,
)
child_nodes: list[Node] = []
for entry in sorted(dir_path.iterdir(), key=lambda item: item.name.lower()):
if entry.name == METADATA_FILENAME:
continue
if entry.name.lower() == METADATA_FILENAME.lower() and entry.name != METADATA_FILENAME:
continue
child_rel_path = normalize_rel_path(entry, base_dir)
if should_exclude(child_rel_path, exclude_patterns):
continue
if entry.is_dir():
child = visit_dir(entry, node)
elif entry.is_file() and entry.suffix.lower() == ".md":
child = visit_file(entry, node)
else:
child = None
if child is not None:
child_nodes.append(child)
node.children = sort_nodes(child_nodes)
return node
def visit_file(file_path: Path, parent: Node) -> Node | None:
rel_path = normalize_rel_path(file_path, base_dir)
file_metadata, h1_title, h2_values = read_file_metadata(file_path, requested_h2_keys)
if file_metadata.hidden:
return None
return Node(
path=file_path,
rel_path=rel_path,
is_dir=False,
depth=rel_path.count("/"),
parent=parent,
file_metadata=file_metadata,
h1_title=h1_title,
h2_values=h2_values,
)
roots: list[Node] = []
for child in sorted(base_dir.iterdir(), key=lambda item: item.name.lower()):
if child.name.lower() == METADATA_FILENAME.lower():
if child.name != METADATA_FILENAME:
raise TocError(f"invalid metadata filename case under {base_dir}: {child.name}")
continue
rel_path = normalize_rel_path(child, base_dir)
if should_exclude(rel_path, exclude_patterns):
continue
if child.is_dir():
node = visit_dir(child, None)
elif child.is_file() and child.suffix.lower() == ".md":
node = visit_file(child, None)
else:
node = None
if node is not None:
roots.append(node)
roots = sort_nodes(roots)
assign_sequences(roots)
return roots
def sort_nodes(nodes: list[Node]) -> list[Node]:
return sorted(
nodes,
key=lambda node: (
1 if node.is_dir else 2,
0 if node.active_metadata.order is not None else 1,
node.active_metadata.order if node.active_metadata.order is not None else 0,
node.basename.lower(),
node.basename,
),
)
def assign_sequences(nodes: list[Node], prefix: list[int] | None = None) -> None:
prefix = prefix or []
for index, node in enumerate(nodes, start=1):
chain = prefix + [index]
node.sequence = index
node.sequence_separated = "-".join(str(value) for value in chain)
if node.children:
assign_sequences(node.children, chain)
def iter_nodes(nodes: Iterable[Node]) -> Iterable[Node]:
for node in nodes:
yield node
yield from iter_nodes(node.children)
def collect_dir_title_map(nodes: Iterable[Node]) -> dict[str, str]:
mapping: dict[str, str] = {}
for node in iter_nodes(nodes):
if node.is_dir:
mapping[node.fullpath] = node.title
return mapping
def node_value(node: Node, key: str, dir_title_map: dict[str, str]) -> str:
if key == "sequence":
return str(node.sequence)
if key == "sequence-separated":
return node.sequence_separated
if key == "fullpath":
return node.fullpath
if key == "dirname":
return node.dirname
if key == "filename":
return node.basename
if key == "filename-title":
return node.title if not node.is_dir else node.basename
if key == "depth":
return str(node.depth)
if key == "description":
return node.description
if key == "title":
return node.title
if key == "order":
return node.active_metadata.get("order")
if key == "hidden":
return node.active_metadata.get("hidden")
if key == "indent":
return " " * 4 * node.depth
if key.startswith("key-"):
return node.key_value(key[4:])
if key.startswith("h2-"):
return node.h2_values.get(key[3:], "")
if key in {"dirnames", "dirnames-title"}:
raise TocError(f"{key} requires flat row expansion")
raise TocError(f"unsupported key: {key}")
def dir_values(node: Node, title_mode: bool, dir_title_map: dict[str, str]) -> list[str]:
if not node.rel_path:
return []
segments = node.rel_path.split("/")
dir_paths = []
current = []
for segment in (segments if node.is_dir else segments[:-1]):
current.append(segment)
dir_paths.append("/".join(current))
if not title_mode:
return [path.rsplit("/", 1)[-1] for path in dir_paths]
return [dir_title_map.get(path, path.rsplit("/", 1)[-1]) for path in dir_paths]
def build_flat_layout(
nodes: list[Node],
specs: list[ColumnSpec],
max_dir_columns: int | None,
) -> FlatLayout:
flat_nodes = [node for node in iter_nodes(nodes) if not node.is_dir]
dir_title_map = collect_dir_title_map(nodes)
max_depth = max((len(dir_values(node, False, dir_title_map)) for node in flat_nodes), default=0)
dir_col_count = max_depth if max_dir_columns is None else max_dir_columns
if max_dir_columns is not None and max_depth > max_dir_columns:
raise TocError("row depth exceeds --max-dir-columns")
header: list[str] = []
rows: list[list[str]] = []
dir_groups: list[DirGroupLayout] = []
expanded_specs: list[tuple[str, ColumnSpec, int | None, int | None]] = []
for spec in specs:
if spec.key in {"dirnames", "dirnames-title"}:
column_indexes: list[int] = []
group_index = len(dir_groups)
for index in range(dir_col_count):
expanded_specs.append((spec.key, spec, index, group_index))
header.append(spec.label if index == 0 else "")
column_indexes.append(len(header) - 1)
dir_groups.append(DirGroupLayout(column_indexes=column_indexes, raw_rows=[]))
else:
expanded_specs.append((spec.key, spec, None, None))
header.append(spec.label)
for sequence_index, node in enumerate(flat_nodes, start=1):
row: list[str] = []
row_group_values: list[list[str] | None] = [None] * len(dir_groups)
for key, _spec, dir_index, group_index in expanded_specs:
if key in {"dirnames", "dirnames-title"}:
values = dir_values(node, key == "dirnames-title", dir_title_map)
if group_index is not None and row_group_values[group_index] is None:
row_group_values[group_index] = values
row.append(values[dir_index] if dir_index is not None and dir_index < len(values) else "")
elif key == "sequence":
row.append(str(sequence_index))
else:
row.append(node_value(node, key, dir_title_map))
rows.append(row)
for index, values in enumerate(row_group_values):
dir_groups[index].raw_rows.append(values or [])
collapse_repeated_dir_values(rows, dir_groups)
return FlatLayout(header=header, rows=rows, dir_groups=dir_groups)
def collapse_repeated_dir_values(rows: list[list[str]], dir_groups: list[DirGroupLayout]) -> None:
for group in dir_groups:
previous: list[list[str] | None] = [None] * len(group.column_indexes)
for row_index, row in enumerate(rows):
raw_values = group.raw_rows[row_index]
for pos, col_index in enumerate(group.column_indexes):
value = raw_values[pos] if pos < len(raw_values) else ""
if value and previous[pos] == raw_values[: pos + 1]:
row[col_index] = ""
elif value:
previous[pos] = raw_values[: pos + 1]
else:
previous[pos] = None
def render_tree(nodes: list[Node], tree_format: str, with_root_node: bool) -> str:
dir_title_map = collect_dir_title_map(nodes)
lines: list[str] = []
if with_root_node:
lines.append("- /")
for node in iter_nodes(nodes):
context = {key: node_value(node, key, dir_title_map) for key in collect_placeholders(tree_format)}
lines.append(Template(tree_format).safe_substitute(context))
return "\n".join(lines) + "\n"
def collect_placeholders(format_text: str) -> set[str]:
placeholders = set(re.findall(r"\$\{([^}]+)\}", format_text))
for key in placeholders:
validate_placeholder_key(key, "--tree-node-format")
if key in {"dirnames", "dirnames-title"}:
raise TocError(f"{key} is not available in --tree-node-format")
return placeholders
def write_csv(path: Path, layout: FlatLayout) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
writer.writerow(layout.header)
writer.writerows(layout.rows)
def write_xlsx(path: Path, layout: FlatLayout, start_cell: str) -> None:
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import column_index_from_string
from openpyxl.utils.cell import coordinate_from_string, get_column_letter
except ImportError as exc:
raise TocError("flat-xlsx mode requires openpyxl") from exc
try:
col_letters, start_row = coordinate_from_string(start_cell)
start_col = column_index_from_string(col_letters)
except ValueError as exc:
raise TocError(f"invalid --excel-start-cell: {start_cell}") from exc
workbook = Workbook()
sheet = workbook.active
sheet.sheet_view.showGridLines = False
font = Font(name="Meiryo UI", size=9)
header_fill = PatternFill(fill_type="solid", fgColor="F2F2F2")
header_alignment = Alignment(horizontal="center", vertical="center")
thin = Side(style="thin", color="000000")
no_side = Side(style=None)
for offset, value in enumerate(layout.header):
cell = sheet.cell(row=start_row, column=start_col + offset, value=value)
cell.font = font
cell.fill = header_fill
cell.alignment = header_alignment
for row_index, row_values in enumerate(layout.rows, start=start_row + 1):
for col_offset, value in enumerate(row_values):
cell = sheet.cell(row=row_index, column=start_col + col_offset, value=value)
cell.font = font
last_row = start_row + len(layout.rows)
last_col = start_col + len(layout.header) - 1
border_map: dict[tuple[int, int], dict[str, Side]] = {}
for row in range(start_row, last_row + 1):
for col in range(start_col, last_col + 1):
border_map[(row, col)] = {
"left": thin,
"right": thin,
"top": thin,
"bottom": thin,
}
for group in layout.dir_groups:
for row_offset, raw_values in enumerate(group.raw_rows):
sheet_row = start_row + 1 + row_offset
depth = len(raw_values)
if depth == 0:
for pos in range(1, len(group.column_indexes)):
left_col = start_col + group.column_indexes[pos - 1]
right_col = start_col + group.column_indexes[pos]
border_map[(sheet_row, left_col)]["right"] = no_side
border_map[(sheet_row, right_col)]["left"] = no_side
continue
if 0 < depth < len(group.column_indexes):
for pos in range(depth, len(group.column_indexes)):
left_col = start_col + group.column_indexes[pos - 1]
right_col = start_col + group.column_indexes[pos]
border_map[(sheet_row, left_col)]["right"] = no_side
border_map[(sheet_row, right_col)]["left"] = no_side
for pos, column_index in enumerate(group.column_indexes):
for row_offset in range(len(group.raw_rows) - 1):
current = group.raw_rows[row_offset]
following = group.raw_rows[row_offset + 1]
if pos >= len(current) or pos >= len(following):
continue
if not current[pos]:
continue
if current[: pos + 1] != following[: pos + 1]:
continue
top_row = start_row + 1 + row_offset
bottom_row = start_row + 2 + row_offset
sheet_col = start_col + column_index
border_map[(top_row, sheet_col)]["bottom"] = no_side
border_map[(bottom_row, sheet_col)]["top"] = no_side
for row in range(start_row, last_row + 1):
for col in range(start_col, last_col + 1):
sides = border_map[(row, col)]
sheet.cell(row=row, column=col).border = Border(
left=sides["left"],
right=sides["right"],
top=sides["top"],
bottom=sides["bottom"],
)
for col in range(start_col, last_col + 1):
values = [
str(sheet.cell(row=row, column=col).value or "")
for row in range(start_row, last_row + 1)
]
width = max((len(value) for value in values), default=0) + 2
sheet.column_dimensions[get_column_letter(col)].width = max(width, 4)
sheet.freeze_panes = sheet.cell(row=start_row + 1, column=start_col)
workbook.save(path)
def default_output_path(base_dir: Path, out_mode: str) -> Path:
if out_mode in TREE_MODES:
return base_dir / "TOC.md"
if out_mode == "flat-csv":
return base_dir / "TOC.csv"
return base_dir / "TOC.xlsx"
def main() -> int:
try:
args = parse_args()
validate_args(args)
base_dir = Path(normalize_slashes(args.base_dir)).expanduser().resolve()
if not base_dir.exists() or not base_dir.is_dir():
raise TocError(f"BASE_DIR is not a directory: {args.base_dir}")
flat_specs = parse_flat_columns(args.flat_columns)
requested_h2_keys = collect_requested_h2_keys(flat_specs, args.tree_node_format)
roots = build_tree(base_dir, requested_h2_keys, [normalize_slashes(value) for value in args.exclude_path_pattern])
if args.out_mode == "tree-md-dirs-only":
roots = filter_dirs_only(roots)
out_path = Path(args.out_file_path).expanduser() if args.out_file_path else default_output_path(base_dir, args.out_mode)
if args.out_mode in TREE_MODES:
out_path.write_text(render_tree(roots, args.tree_node_format, args.with_root_node), encoding="utf-8")
else:
layout = build_flat_layout(roots, flat_specs, args.max_dir_columns)
if args.out_mode == "flat-csv":
write_csv(out_path, layout)
else:
write_xlsx(out_path, layout, args.excel_start_cell)
return 0
except TocError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
def filter_dirs_only(nodes: list[Node]) -> list[Node]:
result: list[Node] = []
for node in nodes:
if not node.is_dir:
continue
copied = Node(
path=node.path,
rel_path=node.rel_path,
is_dir=True,
depth=node.depth,
parent=node.parent,
dir_metadata=node.dir_metadata,
children=filter_dirs_only(node.children),
sequence=node.sequence,
sequence_separated=node.sequence_separated,
)
result.append(copied)
return result
if __name__ == "__main__":
sys.exit(main())