workpytools 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workpytools/__init__.py +1 -0
- workpytools/cli.py +76 -0
- workpytools/common/__init__.py +0 -0
- workpytools/common/browser_preview.py +40 -0
- workpytools/common/clipboard.py +318 -0
- workpytools/common/clustering.py +85 -0
- workpytools/common/config.py +44 -0
- workpytools/common/embedding.py +101 -0
- workpytools/common/help_gen.py +542 -0
- workpytools/common/logging.py +12 -0
- workpytools/common/markdown_html.py +15 -0
- workpytools/common/outline_parse.py +81 -0
- workpytools/common/output.py +71 -0
- workpytools/common/powerpoint.py +43 -0
- workpytools/common/shape_cluster.py +75 -0
- workpytools/common/table_shapes.py +157 -0
- workpytools/common/tabular.py +248 -0
- workpytools/common/textfile.py +90 -0
- workpytools/common/walk.py +133 -0
- workpytools/data/__init__.py +0 -0
- workpytools/data/synonym.tsv +3 -0
- workpytools/data/user.dic +2 -0
- workpytools/processing/__init__.py +0 -0
- workpytools/processing/base.py +23 -0
- workpytools/processing/clipfmt.py +143 -0
- workpytools/processing/clipmd.py +237 -0
- workpytools/processing/clipview.py +194 -0
- workpytools/processing/cwc.py +427 -0
- workpytools/processing/denoise.py +81 -0
- workpytools/processing/help.py +47 -0
- workpytools/processing/ikko.py +205 -0
- workpytools/processing/kukiri.py +94 -0
- workpytools/processing/lsdir.py +218 -0
- workpytools/processing/mdtsv.py +177 -0
- workpytools/processing/mokuji.py +91 -0
- workpytools/processing/nagasa.py +94 -0
- workpytools/processing/outline.py +85 -0
- workpytools/processing/profiler.py +277 -0
- workpytools/processing/seiretsu.py +111 -0
- workpytools/processing/tbl.py +358 -0
- workpytools/processing/touka.py +52 -0
- workpytools/processing/vv.py +147 -0
- workpytools-0.1.4.dist-info/METADATA +458 -0
- workpytools-0.1.4.dist-info/RECORD +48 -0
- workpytools-0.1.4.dist-info/WHEEL +5 -0
- workpytools-0.1.4.dist-info/entry_points.txt +20 -0
- workpytools-0.1.4.dist-info/licenses/LICENSE +21 -0
- workpytools-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from workpytools.processing.base import Processor
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# リポジトリ直下の doc/help.html。commit時のpre-commitフックで最新化される
|
|
13
|
+
# (scripts/gen_help.py 参照)。このコマンド自体は生成せず、既存ファイルを開くだけ。
|
|
14
|
+
_HELP_HTML_PATH = Path(__file__).resolve().parents[3] / "doc" / "help.html"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HelpProcessor(Processor):
|
|
18
|
+
"""Open doc/help.html (a one-liner summary + full --help for every command)
|
|
19
|
+
in the default browser.
|
|
20
|
+
|
|
21
|
+
doc/help.html is regenerated by a pre-commit hook (scripts/gen_help.py)
|
|
22
|
+
whenever a command's help text changes, so it always reflects the last
|
|
23
|
+
committed state, not necessarily uncommitted local edits.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name = "help"
|
|
27
|
+
help = "全コマンドのヘルプ一覧(doc/help.html)をブラウザで開く"
|
|
28
|
+
|
|
29
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--no-open", action="store_true", help="ブラウザを開かず、パスの表示のみ行う"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def run(self, args: argparse.Namespace) -> int:
|
|
35
|
+
if not _HELP_HTML_PATH.exists():
|
|
36
|
+
raise SystemExit(
|
|
37
|
+
f"help.htmlが見つかりません: {_HELP_HTML_PATH}\n"
|
|
38
|
+
"python scripts/gen_help.py を実行して生成してください。"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger.info("ヘルプページを開きます: %s", _HELP_HTML_PATH)
|
|
42
|
+
print(_HELP_HTML_PATH)
|
|
43
|
+
|
|
44
|
+
if not args.no_open:
|
|
45
|
+
webbrowser.open(_HELP_HTML_PATH.as_uri())
|
|
46
|
+
|
|
47
|
+
return 0
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from workpytools.common.powerpoint import (
|
|
7
|
+
NoActivePresentationError,
|
|
8
|
+
PowerPointNotRunningError,
|
|
9
|
+
get_active_presentation,
|
|
10
|
+
get_running_powerpoint,
|
|
11
|
+
)
|
|
12
|
+
from workpytools.common.shape_cluster import (
|
|
13
|
+
LEFT_TOLERANCE,
|
|
14
|
+
LINE_STEP_MAX_RATIO,
|
|
15
|
+
LINE_STEP_MIN_RATIO,
|
|
16
|
+
ShapeInfo,
|
|
17
|
+
cluster_shapes,
|
|
18
|
+
)
|
|
19
|
+
from workpytools.processing.base import Processor
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_SELECTION_SHAPES = 2 # ppSelectionShapes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IkkoProcessor(Processor):
|
|
27
|
+
"""Merge adjacent single-line text boxes on the current slide (or the
|
|
28
|
+
current selection) into one multi-paragraph text box. Meant for
|
|
29
|
+
PowerPoint's "convert to shapes" output, which splits each line of a
|
|
30
|
+
paragraph into its own text box.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name = "ikko"
|
|
34
|
+
help = "スライド上のバラバラなテキストボックスを1つに合体する"
|
|
35
|
+
|
|
36
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--dry-run",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="実際には変更せず、合体対象になるクラスタを表示するだけにする",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--left-tolerance",
|
|
44
|
+
type=float,
|
|
45
|
+
default=LEFT_TOLERANCE,
|
|
46
|
+
help=f"左端座標の許容誤差(ポイント、既定{LEFT_TOLERANCE})",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--line-step-min",
|
|
50
|
+
type=float,
|
|
51
|
+
default=LINE_STEP_MIN_RATIO,
|
|
52
|
+
help=f"行送りの下限(フォントサイズ比、既定{LINE_STEP_MIN_RATIO})",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--line-step-max",
|
|
56
|
+
type=float,
|
|
57
|
+
default=LINE_STEP_MAX_RATIO,
|
|
58
|
+
help=f"行送りの上限(フォントサイズ比、既定{LINE_STEP_MAX_RATIO})",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def run(self, args: argparse.Namespace) -> int:
|
|
62
|
+
try:
|
|
63
|
+
app = get_running_powerpoint()
|
|
64
|
+
except PowerPointNotRunningError as exc:
|
|
65
|
+
raise SystemExit(
|
|
66
|
+
"PowerPointでプレゼンテーションを開いた状態で実行してください"
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
get_active_presentation(app)
|
|
71
|
+
except NoActivePresentationError as exc:
|
|
72
|
+
raise SystemExit(
|
|
73
|
+
"PowerPointでプレゼンテーションを開いた状態で実行してください"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
com_shapes = self._resolve_target_shapes(app)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise SystemExit(f"対象範囲の取得中にエラーが発生しました: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
shape_infos = [
|
|
82
|
+
info for info in (self._to_shape_info(s) for s in com_shapes) if info is not None
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
if not shape_infos:
|
|
86
|
+
print("対象になるテキストボックスがありません")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
clusters = [c for c in cluster_shapes(
|
|
90
|
+
shape_infos,
|
|
91
|
+
left_tolerance=args.left_tolerance,
|
|
92
|
+
line_step_min_ratio=args.line_step_min,
|
|
93
|
+
line_step_max_ratio=args.line_step_max,
|
|
94
|
+
) if len(c) >= 2]
|
|
95
|
+
|
|
96
|
+
if not clusters:
|
|
97
|
+
print("合体できる組み合わせが見つかりませんでした(--left-tolerance等のオプションで閾値を調整できます)")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
if args.dry_run:
|
|
101
|
+
self._print_dry_run(clusters)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
app.StartNewUndoEntry()
|
|
106
|
+
merged_count = 0
|
|
107
|
+
removed_count = 0
|
|
108
|
+
for cluster in clusters:
|
|
109
|
+
self._merge_cluster(cluster)
|
|
110
|
+
merged_count += 1
|
|
111
|
+
removed_count += len(cluster) - 1
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise SystemExit(
|
|
114
|
+
f"PowerPointの操作中にエラーが発生しました(Ctrl+Zで開始前の状態に戻せます): {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
logger.info("合体したクラスタ数=%d 削除したシェイプ数=%d", merged_count, removed_count)
|
|
118
|
+
total_before = removed_count + merged_count
|
|
119
|
+
print(f"{merged_count}個のクラスタを合体しました({total_before}個のシェイプを{merged_count}個に)")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
def _resolve_target_shapes(self, app: object) -> list[object]:
|
|
123
|
+
selection = app.ActiveWindow.Selection # type: ignore[attr-defined]
|
|
124
|
+
if selection.Type == _SELECTION_SHAPES:
|
|
125
|
+
shape_range = selection.ShapeRange
|
|
126
|
+
return [shape_range.Item(i) for i in range(1, shape_range.Count + 1)]
|
|
127
|
+
|
|
128
|
+
slide = app.ActiveWindow.View.Slide # type: ignore[attr-defined]
|
|
129
|
+
return [slide.Shapes.Item(i) for i in range(1, slide.Shapes.Count + 1)]
|
|
130
|
+
|
|
131
|
+
def _to_shape_info(self, shape: object) -> ShapeInfo | None:
|
|
132
|
+
if not getattr(shape, "HasTextFrame", False):
|
|
133
|
+
return None
|
|
134
|
+
text_range = shape.TextFrame.TextRange # type: ignore[attr-defined]
|
|
135
|
+
text = text_range.Text
|
|
136
|
+
if not text or not text.strip():
|
|
137
|
+
return None
|
|
138
|
+
if text_range.Paragraphs().Count != 1:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
font = text_range.Font
|
|
142
|
+
try:
|
|
143
|
+
color = font.Color.RGB
|
|
144
|
+
except Exception:
|
|
145
|
+
color = None
|
|
146
|
+
|
|
147
|
+
if font.Name is None or font.Size is None:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
return ShapeInfo(
|
|
151
|
+
left=shape.Left, # type: ignore[attr-defined]
|
|
152
|
+
top=shape.Top, # type: ignore[attr-defined]
|
|
153
|
+
width=shape.Width, # type: ignore[attr-defined]
|
|
154
|
+
height=shape.Height, # type: ignore[attr-defined]
|
|
155
|
+
text=text,
|
|
156
|
+
font_name=font.Name,
|
|
157
|
+
font_size=font.Size,
|
|
158
|
+
bold=font.Bold,
|
|
159
|
+
color=color,
|
|
160
|
+
alignment=text_range.ParagraphFormat.Alignment,
|
|
161
|
+
ref=shape,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _print_dry_run(self, clusters: list[list[ShapeInfo]]) -> None:
|
|
165
|
+
for i, cluster in enumerate(clusters, start=1):
|
|
166
|
+
print(f"クラスタ{i}: {len(cluster)}個のシェイプ")
|
|
167
|
+
for info in cluster:
|
|
168
|
+
print(f" Left={info.left:.1f} Top={info.top:.1f} Text={info.text!r}")
|
|
169
|
+
|
|
170
|
+
def _merge_cluster(self, cluster: list[ShapeInfo]) -> None:
|
|
171
|
+
lefts = [c.left for c in cluster]
|
|
172
|
+
tops = [c.top for c in cluster]
|
|
173
|
+
rights = [c.left + c.width for c in cluster]
|
|
174
|
+
bottoms = [c.top + c.height for c in cluster]
|
|
175
|
+
|
|
176
|
+
new_left = min(lefts)
|
|
177
|
+
new_top = min(tops)
|
|
178
|
+
new_width = max(rights) - new_left
|
|
179
|
+
new_height = max(bottoms) - new_top
|
|
180
|
+
|
|
181
|
+
style = cluster[0]
|
|
182
|
+
base_shape = cluster[0].ref
|
|
183
|
+
lines = [c.text for c in cluster]
|
|
184
|
+
|
|
185
|
+
text_range = base_shape.TextFrame.TextRange # type: ignore[attr-defined]
|
|
186
|
+
text_range.Text = "\r".join(lines)
|
|
187
|
+
|
|
188
|
+
if style.font_name is not None:
|
|
189
|
+
text_range.Font.Name = style.font_name
|
|
190
|
+
if style.font_size is not None:
|
|
191
|
+
text_range.Font.Size = style.font_size
|
|
192
|
+
if style.bold is not None:
|
|
193
|
+
text_range.Font.Bold = style.bold
|
|
194
|
+
if style.color is not None:
|
|
195
|
+
text_range.Font.Color.RGB = style.color
|
|
196
|
+
if style.alignment is not None:
|
|
197
|
+
text_range.ParagraphFormat.Alignment = style.alignment
|
|
198
|
+
|
|
199
|
+
base_shape.Left = new_left # type: ignore[attr-defined]
|
|
200
|
+
base_shape.Top = new_top # type: ignore[attr-defined]
|
|
201
|
+
base_shape.Width = new_width # type: ignore[attr-defined]
|
|
202
|
+
base_shape.Height = new_height # type: ignore[attr-defined]
|
|
203
|
+
|
|
204
|
+
for info in cluster[1:]:
|
|
205
|
+
info.ref.Delete() # type: ignore[attr-defined]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from workpytools.common.clipboard import load_image
|
|
11
|
+
from workpytools.common.output import describe_output, save_result
|
|
12
|
+
from workpytools.processing.base import Processor
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_BILATERAL_DIAMETER = 9
|
|
17
|
+
_UNSHARP_BLUR_SIGMA = 3.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KukiriProcessor(Processor):
|
|
21
|
+
"""Clean up JPEG edge bleeding/ringing and sharpen boundaries.
|
|
22
|
+
|
|
23
|
+
Intended for flat-design illustrations where JPEG compression has
|
|
24
|
+
blurred/smudged the boundary between flat color regions. Applies an
|
|
25
|
+
edge-preserving bilateral filter (smooths within regions, leaves edges
|
|
26
|
+
intact) followed by an unsharp mask (boosts edge contrast).
|
|
27
|
+
|
|
28
|
+
Input source and default output location follow the same convention as
|
|
29
|
+
touka/denoise: explicit `path` or a file copied in Explorer saves next
|
|
30
|
+
to (or, for the clipboard-file case, into the OS temp dir and back onto
|
|
31
|
+
the clipboard as) `{stem}_kukiri.png`; raw clipboard image data skips
|
|
32
|
+
the file and is placed directly on the clipboard as image data.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name = "kukiri"
|
|
36
|
+
help = "JPEGの輪郭滲みを除去し境界をくっきりさせる(フラットイラスト向け)"
|
|
37
|
+
|
|
38
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"path",
|
|
41
|
+
nargs="?",
|
|
42
|
+
default=None,
|
|
43
|
+
help="画像ファイルパス。省略時はクリップボードの画像/コピーしたファイルを使用",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-o", "--output", default=None, help="出力先パス(省略時は自動生成、拡張子はpng)"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--smooth",
|
|
50
|
+
type=float,
|
|
51
|
+
default=75.0,
|
|
52
|
+
help="バイラテラルフィルタの強さ(デフォルト: 75.0)。"
|
|
53
|
+
"大きいほど滲みは消えるが細部も失われる",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--sharpen",
|
|
57
|
+
type=float,
|
|
58
|
+
default=0.5,
|
|
59
|
+
help="アンシャープマスクの強さ(デフォルト: 0.5、0で無効)。"
|
|
60
|
+
"大きいほど境界のコントラストが強調される",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def run(self, args: argparse.Namespace) -> int:
|
|
64
|
+
loaded = load_image(args.path)
|
|
65
|
+
logger.info(
|
|
66
|
+
"kukiri starting (size=%s, smooth=%s, sharpen=%s)",
|
|
67
|
+
loaded.image.size,
|
|
68
|
+
args.smooth,
|
|
69
|
+
args.sharpen,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result = self._process(loaded.image, smooth=args.smooth, sharpen=args.sharpen)
|
|
73
|
+
|
|
74
|
+
output_path = save_result(loaded, result, "kukiri", args.output)
|
|
75
|
+
print(describe_output(output_path))
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _process(image: Image.Image, smooth: float, sharpen: float) -> Image.Image:
|
|
80
|
+
"""Bilateral-filter then unsharp-mask, preserving the alpha channel untouched."""
|
|
81
|
+
rgba = np.array(image)
|
|
82
|
+
rgb = rgba[:, :, :3]
|
|
83
|
+
alpha = rgba[:, :, 3]
|
|
84
|
+
|
|
85
|
+
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
|
86
|
+
smoothed = cv2.bilateralFilter(
|
|
87
|
+
bgr, d=_BILATERAL_DIAMETER, sigmaColor=smooth, sigmaSpace=smooth
|
|
88
|
+
)
|
|
89
|
+
blurred = cv2.GaussianBlur(smoothed, (0, 0), sigmaX=_UNSHARP_BLUR_SIGMA)
|
|
90
|
+
sharpened = cv2.addWeighted(smoothed, 1 + sharpen, blurred, -sharpen, 0)
|
|
91
|
+
|
|
92
|
+
result_rgb = cv2.cvtColor(sharpened, cv2.COLOR_BGR2RGB)
|
|
93
|
+
out = np.dstack([result_rgb, alpha])
|
|
94
|
+
return Image.fromarray(out, mode="RGBA")
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from workpytools.common.clipboard import copy_text_to_clipboard
|
|
9
|
+
from workpytools.common.walk import Entry, compute_total_sizes, dedupe_by_fullpath, walk
|
|
10
|
+
from workpytools.processing.base import Processor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_DEFAULT_EXCLUDE = (".git", ".svn", "node_modules", "__pycache__")
|
|
15
|
+
_UNIT_DIVISORS = {"b": 1, "kb": 1024, "mb": 1024**2, "gb": 1024**3}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_size(size: int | None, unit: str) -> str:
|
|
19
|
+
if size is None:
|
|
20
|
+
return ""
|
|
21
|
+
if unit == "b":
|
|
22
|
+
return str(size)
|
|
23
|
+
value = size / _UNIT_DIVISORS[unit]
|
|
24
|
+
return f"{value:.2f}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_link(fullpath: str) -> str:
|
|
28
|
+
"""Resolve a .lnk shortcut target via WScript.Shell (a public COM API),
|
|
29
|
+
not by parsing the .lnk binary format ourselves.
|
|
30
|
+
"""
|
|
31
|
+
import win32com.client
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
shell = win32com.client.Dispatch("WScript.Shell")
|
|
35
|
+
shortcut = shell.CreateShortCut(fullpath)
|
|
36
|
+
return str(shortcut.TargetPath)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
logger.warning("ショートカットの解決に失敗しました: %s (%s)", fullpath, exc)
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LsdirProcessor(Processor):
|
|
43
|
+
"""List files and directories under one or more roots as a flat table,
|
|
44
|
+
suitable for sorting/filtering in Excel. Column count is fixed for a
|
|
45
|
+
given invocation (no variable-length directory nesting columns).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name = "lsdir"
|
|
49
|
+
help = "フォルダ配下をExcelで集計できる表形式で一覧化する"
|
|
50
|
+
|
|
51
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
52
|
+
parser.add_argument("path", nargs="+", help="対象フォルダ(複数・グロブ可)")
|
|
53
|
+
parser.add_argument("-o", "--output", default=None, help="出力先パス(.tsv / .xlsx)")
|
|
54
|
+
parser.add_argument("--clip", action="store_true", help="結果をクリップボードにコピーする")
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--files-only", action="store_true", help="ファイルのみ出力する"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--dirs-only", action="store_true", help="フォルダのみ出力する"
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--exclude",
|
|
63
|
+
nargs="+",
|
|
64
|
+
default=list(_DEFAULT_EXCLUDE),
|
|
65
|
+
help="除外するフォルダ名(既定: %(default)s)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--include-temp",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="'~$'で始まる一時ファイルも含める",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--total-size",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="フォルダ配下の合計サイズを計算する(全走査後に出力するため待ち時間が発生する)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--unit",
|
|
79
|
+
choices=["b", "kb", "mb", "gb"],
|
|
80
|
+
default="kb",
|
|
81
|
+
help="サイズの単位(既定: kb)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--resolve-link",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help=".lnkのリンク先を解決する(WScript.Shell経由、大量にあると遅くなる)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--encoding",
|
|
90
|
+
default="cp932" if os.name == "nt" else "utf-8",
|
|
91
|
+
help="出力エンコーディング",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def run(self, args: argparse.Namespace) -> int:
|
|
95
|
+
roots = self._resolve_roots(args.path)
|
|
96
|
+
|
|
97
|
+
all_entries: list[Entry] = []
|
|
98
|
+
total_skipped_dirs = 0
|
|
99
|
+
for root, source_label in roots:
|
|
100
|
+
entries, skipped = walk(
|
|
101
|
+
root,
|
|
102
|
+
source_label,
|
|
103
|
+
exclude=frozenset(args.exclude),
|
|
104
|
+
include_temp=args.include_temp,
|
|
105
|
+
)
|
|
106
|
+
all_entries.extend(entries)
|
|
107
|
+
total_skipped_dirs += skipped
|
|
108
|
+
|
|
109
|
+
if total_skipped_dirs:
|
|
110
|
+
logger.warning("アクセスできず読み飛ばしたフォルダ: %d件", total_skipped_dirs)
|
|
111
|
+
|
|
112
|
+
all_entries, dup_skipped = dedupe_by_fullpath(all_entries)
|
|
113
|
+
if dup_skipped:
|
|
114
|
+
logger.info("起点の重複により除外したエントリ: %d件", dup_skipped)
|
|
115
|
+
|
|
116
|
+
# ファイル行を落とす前に合計サイズを計算する必要がある
|
|
117
|
+
# (--dirs-only 指定時、ファイルのサイズ情報がないと配下の合計が出せない)。
|
|
118
|
+
total_sizes = compute_total_sizes(all_entries) if args.total_size else {}
|
|
119
|
+
|
|
120
|
+
if args.files_only:
|
|
121
|
+
all_entries = [e for e in all_entries if e.type == "file"]
|
|
122
|
+
elif args.dirs_only:
|
|
123
|
+
all_entries = [e for e in all_entries if e.type == "dir"]
|
|
124
|
+
|
|
125
|
+
link_by_path: dict[str, str] = {}
|
|
126
|
+
if args.resolve_link:
|
|
127
|
+
for entry in all_entries:
|
|
128
|
+
if entry.ext.lower() == ".lnk":
|
|
129
|
+
link_by_path[entry.fullpath] = _resolve_link(entry.fullpath)
|
|
130
|
+
|
|
131
|
+
header = ["source", "type", "name", "fullpath", "parent", "ext", "size", "mtime", "depth"]
|
|
132
|
+
if args.resolve_link:
|
|
133
|
+
header.append("link")
|
|
134
|
+
|
|
135
|
+
rows = [header]
|
|
136
|
+
for entry in all_entries:
|
|
137
|
+
if args.total_size and entry.type == "dir":
|
|
138
|
+
size_value: int | None = total_sizes.get(entry.fullpath, 0)
|
|
139
|
+
else:
|
|
140
|
+
size_value = entry.size
|
|
141
|
+
|
|
142
|
+
row = [
|
|
143
|
+
entry.source,
|
|
144
|
+
entry.type,
|
|
145
|
+
entry.name,
|
|
146
|
+
entry.fullpath,
|
|
147
|
+
entry.parent,
|
|
148
|
+
entry.ext,
|
|
149
|
+
_format_size(size_value, args.unit),
|
|
150
|
+
entry.mtime,
|
|
151
|
+
str(entry.depth),
|
|
152
|
+
]
|
|
153
|
+
if args.resolve_link:
|
|
154
|
+
row.append(link_by_path.get(entry.fullpath, ""))
|
|
155
|
+
rows.append(row)
|
|
156
|
+
|
|
157
|
+
self._emit(rows, args)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
def _resolve_roots(self, patterns: list[str]) -> list[tuple[str, str]]:
|
|
161
|
+
from glob import glob
|
|
162
|
+
|
|
163
|
+
roots = []
|
|
164
|
+
for pattern in patterns:
|
|
165
|
+
matches = glob(pattern) or [pattern]
|
|
166
|
+
for match in matches:
|
|
167
|
+
path = Path(match)
|
|
168
|
+
if not path.exists():
|
|
169
|
+
raise SystemExit(f"指定したパスが存在しません: {path}")
|
|
170
|
+
roots.append((str(path), match))
|
|
171
|
+
return roots
|
|
172
|
+
|
|
173
|
+
def _emit(self, rows: list[list[str]], args: argparse.Namespace) -> None:
|
|
174
|
+
tsv_text = "\n".join("\t".join(row) for row in rows)
|
|
175
|
+
|
|
176
|
+
if args.clip:
|
|
177
|
+
copy_text_to_clipboard(tsv_text)
|
|
178
|
+
print("一覧をコピーしました")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if args.output:
|
|
182
|
+
self._write_output(rows, tsv_text, args.output, args.encoding)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
print(tsv_text)
|
|
186
|
+
|
|
187
|
+
def _write_output(
|
|
188
|
+
self, rows: list[list[str]], tsv_text: str, output: str, encoding: str
|
|
189
|
+
) -> None:
|
|
190
|
+
output_path = Path(output)
|
|
191
|
+
if output_path.suffix.lower() == ".xlsx":
|
|
192
|
+
self._write_excel(rows, output_path)
|
|
193
|
+
else:
|
|
194
|
+
output_path.write_bytes(
|
|
195
|
+
tsv_text.encode(encoding, errors="backslashreplace")
|
|
196
|
+
)
|
|
197
|
+
logger.info("一覧を書き出しました: %s", output_path)
|
|
198
|
+
print(output_path)
|
|
199
|
+
|
|
200
|
+
def _write_excel(self, rows: list[list[str]], output_path: Path) -> None:
|
|
201
|
+
import xlsxwriter
|
|
202
|
+
|
|
203
|
+
header = rows[0]
|
|
204
|
+
size_col = header.index("size") if "size" in header else None
|
|
205
|
+
|
|
206
|
+
with xlsxwriter.Workbook(str(output_path)) as workbook:
|
|
207
|
+
worksheet = workbook.add_worksheet("lsdir")
|
|
208
|
+
header_format = workbook.add_format({"bold": True, "border": 1})
|
|
209
|
+
worksheet.write_row(0, 0, header, header_format)
|
|
210
|
+
|
|
211
|
+
for r, row in enumerate(rows[1:], start=1):
|
|
212
|
+
for c, value in enumerate(row):
|
|
213
|
+
if c == size_col and value:
|
|
214
|
+
worksheet.write_number(r, c, float(value))
|
|
215
|
+
else:
|
|
216
|
+
worksheet.write(r, c, value)
|
|
217
|
+
|
|
218
|
+
worksheet.autofilter(0, 0, len(rows) - 1, len(header) - 1)
|