fusenterm 0.1.0__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.
- fusenterm/__init__.py +3 -0
- fusenterm/__main__.py +12 -0
- fusenterm/app.py +319 -0
- fusenterm/clipboard.py +167 -0
- fusenterm/markdown.py +207 -0
- fusenterm/models.py +158 -0
- fusenterm/screens/__init__.py +1 -0
- fusenterm/screens/about.py +70 -0
- fusenterm/screens/confirm.py +53 -0
- fusenterm/screens/links.py +72 -0
- fusenterm/screens/note_edit.py +79 -0
- fusenterm/screens/overview.py +166 -0
- fusenterm/screens/settings.py +63 -0
- fusenterm/storage.py +120 -0
- fusenterm/styles/__init__.py +1 -0
- fusenterm/styles/app.tcss +268 -0
- fusenterm/widgets/__init__.py +1 -0
- fusenterm/widgets/board.py +569 -0
- fusenterm-0.1.0.dist-info/METADATA +178 -0
- fusenterm-0.1.0.dist-info/RECORD +23 -0
- fusenterm-0.1.0.dist-info/WHEEL +4 -0
- fusenterm-0.1.0.dist-info/entry_points.txt +2 -0
- fusenterm-0.1.0.dist-info/licenses/LICENSE +21 -0
fusenterm/__init__.py
ADDED
fusenterm/__main__.py
ADDED
fusenterm/app.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""fusenterm - メインアプリケーション"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.widgets import Footer, Header
|
|
12
|
+
|
|
13
|
+
from . import storage
|
|
14
|
+
from .models import DEFAULT_COLOR, Note, color_label, next_color, note_title
|
|
15
|
+
from .screens.about import AboutScreen
|
|
16
|
+
from .screens.confirm import ConfirmDeleteScreen
|
|
17
|
+
from .screens.links import LinksScreen
|
|
18
|
+
from .screens.note_edit import NoteEditScreen
|
|
19
|
+
from .screens.overview import OverviewScreen
|
|
20
|
+
from .screens.settings import SettingsScreen
|
|
21
|
+
from .widgets.board import Board, NoteCard
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
CSS_PATH = Path(__file__).parent / "styles" / "app.tcss"
|
|
26
|
+
|
|
27
|
+
# a キーで続けて足したときに、真上に重ならないようずらす量と周期
|
|
28
|
+
_CASCADE_STEP = (2, 1)
|
|
29
|
+
_CASCADE_COUNT = 6
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FusenApp(App):
|
|
33
|
+
"""fusenterm アプリケーション"""
|
|
34
|
+
|
|
35
|
+
TITLE = "fusenterm"
|
|
36
|
+
CSS_PATH = CSS_PATH
|
|
37
|
+
BINDING_GROUP_TITLE = "全体"
|
|
38
|
+
|
|
39
|
+
# 端末のANSIパレット (ユーザーのターミナル配色設定) をそのまま使うテーマ
|
|
40
|
+
THEME = "ansi-dark"
|
|
41
|
+
|
|
42
|
+
# 既定では先頭 (一番奥) の付箋が選ばれて手前に出てしまうので、選ぶのは on_mount に任せる
|
|
43
|
+
AUTO_FOCUS = None
|
|
44
|
+
|
|
45
|
+
# フッターに出すのは使用頻度の高いものだけ。show=False のキーも ? のヘルプには出る
|
|
46
|
+
BINDINGS = [
|
|
47
|
+
Binding("a", "add", "追加"),
|
|
48
|
+
Binding("e,enter", "edit", "編集"),
|
|
49
|
+
Binding("c", "cycle_color", "色"),
|
|
50
|
+
Binding("d", "delete", "剥がす"),
|
|
51
|
+
Binding("tab", "focus_next", "次の付箋"),
|
|
52
|
+
Binding("z", "overview", "全体"),
|
|
53
|
+
Binding("o", "links", "リンク"),
|
|
54
|
+
Binding("x", "hold", "持つ"),
|
|
55
|
+
Binding("p", "put", "ここに置く"),
|
|
56
|
+
Binding("comma", "settings", "設定"),
|
|
57
|
+
Binding("question_mark", "show_help_panel", "ヘルプ"),
|
|
58
|
+
Binding("q", "quit", "終了"),
|
|
59
|
+
Binding("shift+tab", "focus_previous", "前の付箋", show=False),
|
|
60
|
+
Binding("u", "undo", "剥がした付箋を戻す", show=False),
|
|
61
|
+
Binding("i", "about", "このアプリについて", show=False),
|
|
62
|
+
Binding("escape", "hide_help_panel", "ヘルプを閉じる", show=False),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self._notes = storage.load_notes()
|
|
68
|
+
self._settings = storage.load_settings()
|
|
69
|
+
self._deleted: Note | None = None # u キーで戻せる、直前に剥がした付箋
|
|
70
|
+
self._held: NoteCard | None = None # x で持っている、p で今見ている所へ運ぶ付箋
|
|
71
|
+
|
|
72
|
+
def compose(self) -> ComposeResult:
|
|
73
|
+
yield Header(show_clock=True)
|
|
74
|
+
board = Board(id="board")
|
|
75
|
+
board.border_subtitle = "hjkl 選ぶ m 移動モード r サイズ変更モード JK 本文スクロール ドラッグ 移動 右下の角 大きさ"
|
|
76
|
+
yield board
|
|
77
|
+
# コマンドパレットの案内はフッターの幅を食うので出さない
|
|
78
|
+
yield Footer(show_command_palette=False)
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
self.theme = self.THEME
|
|
82
|
+
board = self._board()
|
|
83
|
+
self._apply_settings()
|
|
84
|
+
board.mount_all(NoteCard(note) for note in self._notes)
|
|
85
|
+
# 一番手前の付箋から始める
|
|
86
|
+
if self._notes:
|
|
87
|
+
self.call_after_refresh(lambda: board.cards()[-1].focus())
|
|
88
|
+
self._update_title()
|
|
89
|
+
|
|
90
|
+
# ── 参照 ────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def _board(self) -> Board:
|
|
93
|
+
return self.query_one("#board", Board)
|
|
94
|
+
|
|
95
|
+
def _current(self) -> NoteCard | None:
|
|
96
|
+
"""選んでいる付箋 (無ければ None)"""
|
|
97
|
+
focused = self.focused
|
|
98
|
+
return focused if isinstance(focused, NoteCard) else None
|
|
99
|
+
|
|
100
|
+
# ── イベント ────────────────────────────
|
|
101
|
+
|
|
102
|
+
def on_note_card_changed(self, event: NoteCard.Changed) -> None:
|
|
103
|
+
self._save()
|
|
104
|
+
|
|
105
|
+
def on_note_card_edit_requested(self, event: NoteCard.EditRequested) -> None:
|
|
106
|
+
self._edit(event.card)
|
|
107
|
+
|
|
108
|
+
def on_note_card_delete_requested(self, event: NoteCard.DeleteRequested) -> None:
|
|
109
|
+
self._delete(event.card)
|
|
110
|
+
|
|
111
|
+
def on_board_add_requested(self, event: Board.AddRequested) -> None:
|
|
112
|
+
self._add_at(event.x, event.y)
|
|
113
|
+
|
|
114
|
+
def on_settings_screen_changed(self, event: SettingsScreen.Changed) -> None:
|
|
115
|
+
self._settings = event.settings
|
|
116
|
+
self._apply_settings()
|
|
117
|
+
try:
|
|
118
|
+
storage.save_settings(self._settings)
|
|
119
|
+
except OSError as e:
|
|
120
|
+
log.warning("設定の保存に失敗しました: %s", e)
|
|
121
|
+
self.notify(f"設定の保存に失敗しました: {e}", severity="error")
|
|
122
|
+
|
|
123
|
+
# ── 操作 ────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def action_add(self) -> None:
|
|
126
|
+
"""今見ている範囲の中央付近に付箋を足す (続けて足すと少しずつずらす)"""
|
|
127
|
+
board = self._board()
|
|
128
|
+
sample = Note(text="")
|
|
129
|
+
step = len(board.cards()) % _CASCADE_COUNT
|
|
130
|
+
camera_x, camera_y = board.camera
|
|
131
|
+
width, height = board.view_size()
|
|
132
|
+
x = camera_x + (width - sample.width) // 2 + _CASCADE_STEP[0] * step
|
|
133
|
+
y = camera_y + (height - sample.height) // 2 + _CASCADE_STEP[1] * step
|
|
134
|
+
self._add_at(x, y)
|
|
135
|
+
|
|
136
|
+
def _add_at(self, x: int, y: int) -> None:
|
|
137
|
+
"""位置を決めて新しい付箋の編集を開く (本文を書かずに閉じたら足さない)"""
|
|
138
|
+
|
|
139
|
+
def on_result(result: Optional[tuple[str, str]]) -> None:
|
|
140
|
+
if result is None or not result[0]:
|
|
141
|
+
return
|
|
142
|
+
text, color = result
|
|
143
|
+
self._attach(NoteCard(Note(text=text, x=x, y=y, color=color)))
|
|
144
|
+
|
|
145
|
+
self.push_screen(NoteEditScreen("", DEFAULT_COLOR, title="付箋を追加"), on_result)
|
|
146
|
+
|
|
147
|
+
def _attach(self, card: NoteCard) -> None:
|
|
148
|
+
"""付箋を一番手前に貼って選ぶ"""
|
|
149
|
+
self._board().mount(card)
|
|
150
|
+
self.call_after_refresh(card.focus)
|
|
151
|
+
self._save()
|
|
152
|
+
|
|
153
|
+
def action_edit(self) -> None:
|
|
154
|
+
card = self._current()
|
|
155
|
+
if card is not None:
|
|
156
|
+
self._edit(card)
|
|
157
|
+
|
|
158
|
+
def _edit(self, card: NoteCard) -> None:
|
|
159
|
+
note = card.note
|
|
160
|
+
|
|
161
|
+
def on_result(result: Optional[tuple[str, str]]) -> None:
|
|
162
|
+
if result is None:
|
|
163
|
+
return
|
|
164
|
+
note.text, note.color = result
|
|
165
|
+
card.sync()
|
|
166
|
+
self._save()
|
|
167
|
+
|
|
168
|
+
self.push_screen(NoteEditScreen(note.text, note.color), on_result)
|
|
169
|
+
|
|
170
|
+
def action_cycle_color(self) -> None:
|
|
171
|
+
card = self._current()
|
|
172
|
+
if card is None:
|
|
173
|
+
return
|
|
174
|
+
card.note.color = next_color(card.note.color)
|
|
175
|
+
card.sync()
|
|
176
|
+
self.notify(f"色: {color_label(card.note.color)}", timeout=1)
|
|
177
|
+
self._save()
|
|
178
|
+
|
|
179
|
+
def action_delete(self) -> None:
|
|
180
|
+
card = self._current()
|
|
181
|
+
if card is not None:
|
|
182
|
+
self._delete(card)
|
|
183
|
+
|
|
184
|
+
def _delete(self, card: NoteCard) -> None:
|
|
185
|
+
"""確認してから付箋を剥がす (u で戻せるよう覚えておく)"""
|
|
186
|
+
|
|
187
|
+
def on_result(confirmed: Optional[bool]) -> None:
|
|
188
|
+
if not confirmed:
|
|
189
|
+
return
|
|
190
|
+
self._deleted = card.note
|
|
191
|
+
if card is self._held:
|
|
192
|
+
self._set_held(None)
|
|
193
|
+
card.remove()
|
|
194
|
+
self._save(exclude=card)
|
|
195
|
+
self.notify("剥がしました (u で元に戻す)", timeout=3)
|
|
196
|
+
# 残りのうち一番手前の付箋を選び直す
|
|
197
|
+
rest = [c for c in self._board().cards() if c is not card]
|
|
198
|
+
if rest:
|
|
199
|
+
rest[-1].focus()
|
|
200
|
+
|
|
201
|
+
self.push_screen(ConfirmDeleteScreen(card.note.text), on_result)
|
|
202
|
+
|
|
203
|
+
def action_hold(self) -> None:
|
|
204
|
+
"""選んでいる付箋を持つ (持っている付箋でもう一度押すとやめる)
|
|
205
|
+
|
|
206
|
+
持つだけでボードからは外さない (持ったまま終了しても付箋を失わないように)。
|
|
207
|
+
"""
|
|
208
|
+
card = self._current()
|
|
209
|
+
if card is None:
|
|
210
|
+
return
|
|
211
|
+
if card is self._held:
|
|
212
|
+
self._set_held(None)
|
|
213
|
+
self.notify("持つのをやめました", timeout=2)
|
|
214
|
+
return
|
|
215
|
+
self._set_held(card)
|
|
216
|
+
self.notify("持ちました。運びたい所で p (やめるならもう一度 x)", timeout=3)
|
|
217
|
+
|
|
218
|
+
def action_put(self) -> None:
|
|
219
|
+
"""持っている付箋を、今見ている範囲の真ん中に置く"""
|
|
220
|
+
card = self._held
|
|
221
|
+
if card is None:
|
|
222
|
+
return
|
|
223
|
+
board = self._board()
|
|
224
|
+
camera_x, camera_y = board.camera
|
|
225
|
+
width, height = board.view_size()
|
|
226
|
+
note = card.note
|
|
227
|
+
note.x = camera_x + (width - note.width) // 2
|
|
228
|
+
note.y = camera_y + (height - note.height) // 2
|
|
229
|
+
card.sync()
|
|
230
|
+
self._set_held(None)
|
|
231
|
+
board.raise_card(card)
|
|
232
|
+
card.focus()
|
|
233
|
+
self._save()
|
|
234
|
+
|
|
235
|
+
def _set_held(self, card: NoteCard | None) -> None:
|
|
236
|
+
"""持っている付箋を変える (印と、ボードの題と、p キーの表示を揃える)"""
|
|
237
|
+
if self._held is not None:
|
|
238
|
+
self._held.remove_class("-held")
|
|
239
|
+
self._held = card
|
|
240
|
+
if card is not None:
|
|
241
|
+
card.add_class("-held")
|
|
242
|
+
self._update_title()
|
|
243
|
+
self.refresh_bindings()
|
|
244
|
+
|
|
245
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
246
|
+
# 何も持っていないときは p を出さない
|
|
247
|
+
if action == "put":
|
|
248
|
+
return self._held is not None
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
def action_overview(self) -> None:
|
|
252
|
+
"""縮小表示でボード全体を見て、選んだ付箋か場所へ飛ぶ"""
|
|
253
|
+
board = self._board()
|
|
254
|
+
|
|
255
|
+
def on_result(result: Optional[Union[str, tuple[int, int]]]) -> None:
|
|
256
|
+
if result is None:
|
|
257
|
+
return
|
|
258
|
+
if isinstance(result, tuple):
|
|
259
|
+
board.move_camera(*result)
|
|
260
|
+
return
|
|
261
|
+
card = next((c for c in board.cards() if c.note.id == result), None)
|
|
262
|
+
if card is None:
|
|
263
|
+
return
|
|
264
|
+
note = card.note
|
|
265
|
+
board.center_camera(note.x + note.width // 2, note.y + note.height // 2)
|
|
266
|
+
# 画面より大きい付箋は左上を見せる (選び直さない場合もあるのでここで呼ぶ)
|
|
267
|
+
board.reveal(card)
|
|
268
|
+
card.focus()
|
|
269
|
+
|
|
270
|
+
notes = [c.note for c in board.cards()]
|
|
271
|
+
self.push_screen(OverviewScreen(notes, board.camera, board.view_size()), on_result)
|
|
272
|
+
|
|
273
|
+
def action_links(self) -> None:
|
|
274
|
+
"""選んでいる付箋に書かれたリンクを一覧で見せる"""
|
|
275
|
+
card = self._current()
|
|
276
|
+
if card is not None:
|
|
277
|
+
self.push_screen(LinksScreen(card.note.text))
|
|
278
|
+
|
|
279
|
+
def action_settings(self) -> None:
|
|
280
|
+
self.push_screen(SettingsScreen(self._settings))
|
|
281
|
+
|
|
282
|
+
def action_about(self) -> None:
|
|
283
|
+
self.push_screen(AboutScreen())
|
|
284
|
+
|
|
285
|
+
def _apply_settings(self) -> None:
|
|
286
|
+
"""設定をボードの見た目に反映する"""
|
|
287
|
+
board = self._board()
|
|
288
|
+
board.set_shadow(self._settings.shadow)
|
|
289
|
+
board.set_background(self._settings.background)
|
|
290
|
+
|
|
291
|
+
def action_undo(self) -> None:
|
|
292
|
+
"""直前に剥がした付箋を、元の位置の一番手前に戻す"""
|
|
293
|
+
if self._deleted is None:
|
|
294
|
+
self.notify("戻せる付箋はありません", timeout=2)
|
|
295
|
+
return
|
|
296
|
+
note, self._deleted = self._deleted, None
|
|
297
|
+
self._attach(NoteCard(note))
|
|
298
|
+
|
|
299
|
+
# ── 保存 ────────────────────────────────
|
|
300
|
+
|
|
301
|
+
def _save(self, *, exclude: NoteCard | None = None) -> None:
|
|
302
|
+
"""ボードの並び (奥から手前) のまま保存する
|
|
303
|
+
|
|
304
|
+
``exclude`` は削除処理中でまだボードに残っている付箋。
|
|
305
|
+
"""
|
|
306
|
+
self._notes = [c.note for c in self._board().cards() if c is not exclude]
|
|
307
|
+
self._update_title()
|
|
308
|
+
try:
|
|
309
|
+
storage.save_notes(self._notes)
|
|
310
|
+
except OSError as e:
|
|
311
|
+
log.warning("付箋の保存に失敗しました: %s", e)
|
|
312
|
+
self.notify(f"保存に失敗しました: {e}", severity="error")
|
|
313
|
+
|
|
314
|
+
def _update_title(self) -> None:
|
|
315
|
+
title = f"ホワイトボード {len(self._notes)}枚"
|
|
316
|
+
# 持っている付箋が画面の外にあっても分かるように題にも出す
|
|
317
|
+
if self._held is not None:
|
|
318
|
+
title += f" 持っている: {note_title(self._held.note.text)}"
|
|
319
|
+
self._board().border_title = title
|
fusenterm/clipboard.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""リンク付きの文字列を貼り付けたときに markdown のリンクにする
|
|
2
|
+
|
|
3
|
+
端末の貼り付け (ペースト) で届くのはプレーンテキストだけなので、Teams やブラウザから
|
|
4
|
+
コピーしたリンク付きの文字列は URL か文字だけになってしまう。クリップボードには
|
|
5
|
+
同じ内容の HTML も入っているので、それを読んで ``[文字](URL)`` の形に直す。
|
|
6
|
+
|
|
7
|
+
クリップボードを直接読めるのは macOS だけ (osascript)。他の環境や、読めなかった
|
|
8
|
+
場合は端末から届いたテキストをそのまま貼る。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from html.parser import HTMLParser
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# クリップボードのプレーンテキストと HTML を JSON で返す (無い方は null)
|
|
23
|
+
_READ_CLIPBOARD = """
|
|
24
|
+
ObjC.import("AppKit");
|
|
25
|
+
const pb = $.NSPasteboard.generalPasteboard;
|
|
26
|
+
const read = (type) => { const s = pb.stringForType(type); return s.isNil() ? null : s.js; };
|
|
27
|
+
JSON.stringify({text: read("public.utf8-plain-text"), html: read("public.html")});
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# クリップボードの読み取りを待つ上限 (秒)。超えたら届いたテキストをそのまま貼る
|
|
31
|
+
_TIMEOUT = 2
|
|
32
|
+
|
|
33
|
+
# 改行として扱う (前後で行が変わる) タグ
|
|
34
|
+
_BLOCK_TAGS = {
|
|
35
|
+
"p", "div", "li", "tr", "ul", "ol", "table", "pre", "blockquote",
|
|
36
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# 中身を本文として扱わないタグ
|
|
40
|
+
_SKIP_TAGS = {"script", "style", "head", "title"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def markdown_from_paste(pasted: str) -> str | None:
|
|
44
|
+
"""貼り付けたテキストをリンク入りの markdown にしたもの (できなければ None)
|
|
45
|
+
|
|
46
|
+
クリップボードのプレーンテキストが ``pasted`` と一致するときだけ HTML を使う
|
|
47
|
+
(別の経路で貼られたテキストに、たまたま残っていた HTML を混ぜないため)。
|
|
48
|
+
"""
|
|
49
|
+
clip = await _read_clipboard()
|
|
50
|
+
if clip is None:
|
|
51
|
+
return None
|
|
52
|
+
text, html = clip
|
|
53
|
+
if not html or text is None or _normalize(text) != _normalize(pasted):
|
|
54
|
+
return None
|
|
55
|
+
return html_to_markdown(html)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def html_to_markdown(html: str) -> str | None:
|
|
59
|
+
"""HTML のリンクを ``[文字](URL)`` にしたテキスト (リンクが無ければ None)
|
|
60
|
+
|
|
61
|
+
リンク以外の装飾は落とし、段落・改行・箇条書きだけ残す。
|
|
62
|
+
"""
|
|
63
|
+
parser = _HtmlToMarkdown()
|
|
64
|
+
parser.feed(html)
|
|
65
|
+
parser.close()
|
|
66
|
+
if not parser.has_link:
|
|
67
|
+
return None
|
|
68
|
+
text = "".join(parser.out)
|
|
69
|
+
# 行頭・行末の空白を落とし、続いた空行は1つにまとめる
|
|
70
|
+
text = "\n".join(line.strip() for line in text.split("\n"))
|
|
71
|
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize(text: str) -> str:
|
|
75
|
+
"""改行コードと前後の空白の違いを無視して比べるための形"""
|
|
76
|
+
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _link(text: str, href: str) -> str:
|
|
80
|
+
"""markdown のリンク (文字が無いか URL そのものなら URL だけ)"""
|
|
81
|
+
text = " ".join(text.split())
|
|
82
|
+
if not text or text == href:
|
|
83
|
+
return href
|
|
84
|
+
text = text.replace("[", r"\[").replace("]", r"\]")
|
|
85
|
+
href = href.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
|
|
86
|
+
return f"[{text}]({href})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _HtmlToMarkdown(HTMLParser):
|
|
90
|
+
"""HTML を読みながら、リンクを markdown にしたテキストを out に貯める"""
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.out: list[str] = []
|
|
95
|
+
self.has_link = False
|
|
96
|
+
self._skip = 0 # script などの中にいる深さ
|
|
97
|
+
self._pre = 0 # pre の中 (空白をそのまま残す) にいる深さ
|
|
98
|
+
self._href: str | None = None # 開いているリンクの URL
|
|
99
|
+
self._link_text: list[str] = [] # 開いているリンクの文字
|
|
100
|
+
|
|
101
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
102
|
+
if tag in _SKIP_TAGS:
|
|
103
|
+
self._skip += 1
|
|
104
|
+
elif tag == "a":
|
|
105
|
+
href = dict(attrs).get("href")
|
|
106
|
+
if href and not href.startswith("#"):
|
|
107
|
+
self._href = href.strip()
|
|
108
|
+
self._link_text = []
|
|
109
|
+
elif tag == "br":
|
|
110
|
+
self._write("\n")
|
|
111
|
+
elif tag in _BLOCK_TAGS:
|
|
112
|
+
if tag == "pre":
|
|
113
|
+
self._pre += 1
|
|
114
|
+
self._write("\n- " if tag == "li" else "\n")
|
|
115
|
+
|
|
116
|
+
def handle_endtag(self, tag: str) -> None:
|
|
117
|
+
if tag in _SKIP_TAGS:
|
|
118
|
+
self._skip = max(0, self._skip - 1)
|
|
119
|
+
elif tag == "a" and self._href is not None:
|
|
120
|
+
href, self._href = self._href, None
|
|
121
|
+
self.out.append(_link("".join(self._link_text), href))
|
|
122
|
+
self.has_link = True
|
|
123
|
+
elif tag in _BLOCK_TAGS:
|
|
124
|
+
if tag == "pre":
|
|
125
|
+
self._pre = max(0, self._pre - 1)
|
|
126
|
+
# 項目の区切りは次の li の「- 」の前の改行で足りる (空行を挟まない)
|
|
127
|
+
if tag != "li":
|
|
128
|
+
self._write("\n")
|
|
129
|
+
|
|
130
|
+
def handle_data(self, data: str) -> None:
|
|
131
|
+
if self._skip:
|
|
132
|
+
return
|
|
133
|
+
# HTML では改行やタブも1つの空白として扱われる (pre の中は除く)
|
|
134
|
+
self._write(data if self._pre else re.sub(r"\s+", " ", data))
|
|
135
|
+
|
|
136
|
+
def _write(self, text: str) -> None:
|
|
137
|
+
if self._href is not None:
|
|
138
|
+
self._link_text.append(text)
|
|
139
|
+
else:
|
|
140
|
+
self.out.append(text)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _read_clipboard() -> tuple[str | None, str | None] | None:
|
|
144
|
+
"""クリップボードの (プレーンテキスト, HTML)。読めなければ None"""
|
|
145
|
+
if sys.platform != "darwin":
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
proc = await asyncio.create_subprocess_exec(
|
|
149
|
+
"osascript", "-l", "JavaScript", "-e", _READ_CLIPBOARD,
|
|
150
|
+
stdout=asyncio.subprocess.PIPE,
|
|
151
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
152
|
+
)
|
|
153
|
+
except OSError as e:
|
|
154
|
+
log.warning("クリップボードを読めません: %s", e)
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
out, _ = await asyncio.wait_for(proc.communicate(), _TIMEOUT)
|
|
158
|
+
except asyncio.TimeoutError:
|
|
159
|
+
proc.kill()
|
|
160
|
+
log.warning("クリップボードの読み取りが時間切れになりました")
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(out)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
log.warning("クリップボードの内容を解釈できません: %s", e)
|
|
166
|
+
return None
|
|
167
|
+
return data.get("text"), data.get("html")
|