From df89e76f73dca398887411010d305b48cff19b25 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:59:12 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A8=8A=E6=81=AF?= =?UTF-8?q?=E9=96=B1=E8=AE=80=E5=99=A8=E5=8A=9F=E8=83=BD=20(NVDA+Windows+J?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NVDA+Windows+J 自動點擊更多選項 → 儲存聊天 → 解析聊天記錄 → 開啟訊息閱讀器 - 重用現有 _activateMoreOptionsMenu + ChatMoreOptions 虛擬視窗,確保點擊位置正確 - 新增 _chatParser.py:解析 LINE 聊天匯出格式(時間、姓名、內容,支援收回訊息) - 新增 _messageReader.py:wx 對話框,上/下方向鍵逐則閱讀,格式為「姓名 內容 時間」 - 自動操作存檔對話框:以 EnumChildWindows 遞迴找到 Edit 控制項並設定暫存路徑 - 以 BM_CLICK 點擊存檔按鈕,更可靠地觸發儲存動作 - 在 NVDA 工具 > LINE Desktop 選單新增「訊息閱讀器」項目 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_chatParser.py | 37 ++++ addon/appModules/_messageReader.py | 119 +++++++++++ addon/appModules/line.py | 261 ++++++++++++++++++++++- addon/globalPlugins/lineDesktopHelper.py | 41 ++++ 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 addon/appModules/_chatParser.py create mode 100644 addon/appModules/_messageReader.py diff --git a/addon/appModules/_chatParser.py b/addon/appModules/_chatParser.py new file mode 100644 index 0000000..53db17e --- /dev/null +++ b/addon/appModules/_chatParser.py @@ -0,0 +1,37 @@ +import re + +_DATE_RE = re.compile(r'^\d{4}\.\d{2}\.\d{2} .+$') +_MSG_RE = re.compile(r'^(\d{2}:\d{2}) (\S+?) (.+)$') +_RECALL_RE = re.compile(r'^(\d{2}:\d{2}) (\S+?)已收回訊息$') + + +def parseChatFile(filePath): + """Parse a LINE chat export text file. + + Returns a list of dicts with keys: name, content, time. + Display format: name content time + """ + messages = [] + with open(filePath, 'r', encoding='utf-8') as f: + for line in f: + line = line.rstrip('\n').rstrip('\r') + if not line: + continue + if _DATE_RE.match(line): + continue + m = _RECALL_RE.match(line) + if m: + messages.append({ + 'time': m.group(1), + 'name': m.group(2), + 'content': '已收回訊息', + }) + continue + m = _MSG_RE.match(line) + if m: + messages.append({ + 'time': m.group(1), + 'name': m.group(2), + 'content': m.group(3), + }) + return messages diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py new file mode 100644 index 0000000..9cf2dd5 --- /dev/null +++ b/addon/appModules/_messageReader.py @@ -0,0 +1,119 @@ +import wx +import gui +from logHandler import log + + +class MessageReaderDialog(wx.Dialog): + """A dialog for reading LINE chat messages with up/down arrow navigation. + + Each message is displayed as: name content time + Up arrow moves to the previous message, down arrow moves to the next. + """ + + def __init__(self, messages, title="訊息閱讀器"): + super().__init__( + gui.mainFrame, + title=title, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self._messages = messages + self._pos = len(messages) - 1 if messages else -1 + + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + + self._totalLabel = wx.StaticText(panel, label="") + sizer.Add(self._totalLabel, 0, wx.ALL, 5) + + self._textCtrl = wx.TextCtrl( + panel, + style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2, + size=(500, 100), + ) + sizer.Add(self._textCtrl, 1, wx.EXPAND | wx.ALL, 5) + + closeBtn = wx.Button(panel, wx.ID_CLOSE, "關閉(&C)") + sizer.Add(closeBtn, 0, wx.ALIGN_CENTER | wx.ALL, 5) + + panel.SetSizer(sizer) + sizer.Fit(self) + + self._textCtrl.Bind(wx.EVT_KEY_DOWN, self._onKeyDown) + closeBtn.Bind(wx.EVT_BUTTON, self._onClose) + self.Bind(wx.EVT_CLOSE, self._onClose) + self.Bind(wx.EVT_CHAR_HOOK, self._onCharHook) + + self._updateDisplay() + self._textCtrl.SetFocus() + + def _formatMessage(self, msg): + return f"{msg['name']} {msg['content']} {msg['time']}" + + def _updateDisplay(self): + if not self._messages or self._pos < 0: + self._textCtrl.SetValue("沒有訊息") + self._totalLabel.SetLabel("") + return + msg = self._messages[self._pos] + text = self._formatMessage(msg) + self._textCtrl.SetValue(text) + self._totalLabel.SetLabel( + f"{self._pos + 1} / {len(self._messages)}" + ) + self._speakMessage(text) + + def _speakMessage(self, text): + try: + import speech + speech.cancelSpeech() + speech.speakMessage(text) + except Exception: + pass + + def _onKeyDown(self, evt): + keyCode = evt.GetKeyCode() + if keyCode == wx.WXK_UP: + self._movePrevious() + elif keyCode == wx.WXK_DOWN: + self._moveNext() + elif keyCode == wx.WXK_ESCAPE: + self.Close() + else: + evt.Skip() + + def _onCharHook(self, evt): + keyCode = evt.GetKeyCode() + if keyCode == wx.WXK_ESCAPE: + self.Close() + return + evt.Skip() + + def _movePrevious(self): + if not self._messages: + return + if self._pos > 0: + self._pos -= 1 + self._updateDisplay() + else: + self._speakMessage("已經是第一則訊息") + + def _moveNext(self): + if not self._messages: + return + if self._pos < len(self._messages) - 1: + self._pos += 1 + self._updateDisplay() + else: + self._speakMessage("已經是最後一則訊息") + + def _onClose(self, evt): + self.Destroy() + + +def openMessageReader(messages, title="訊息閱讀器"): + """Open the message reader dialog on the main GUI thread.""" + def _show(): + dlg = MessageReaderDialog(messages, title=title) + dlg.Show() + dlg.Raise() + wx.CallAfter(_show) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 2d6f1e8..ca8a6ee 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5885,7 +5885,266 @@ def _suppressAddonForFileDialog(self, reason): def _handleChatMoreOptionsAction(self, actionName): """Handle post-click actions from the chat more-options virtual window.""" if actionName == "儲存聊天": - self._suppressAddonForFileDialog("Save chat selected") + if getattr(self, '_messageReaderPending', False): + self._messageReaderPending = False + core.callLater(800, self._messageReaderHandleSaveDialog) + else: + self._suppressAddonForFileDialog("Save chat selected") + + def script_openMessageReader(self, gesture): + """Open message reader: click more options, auto-click save chat, parse, and display.""" + ui.message(_("正在開啟訊息閱讀器…")) + try: + if not self._clickMoreOptionsButton(): + ui.message(_("找不到 LINE 視窗,請先開啟聊天室")) + return + self._messageReaderPending = True + core.callLater(500, self._activateMoreOptionsMenu) + # Poll until virtual window is ready, then auto-click 儲存聊天 + core.callLater(1500, self._messageReaderAutoClickSaveChat) + except Exception as e: + log.warning(f"LINE openMessageReader error: {e}", exc_info=True) + self._messageReaderPending = False + ui.message(_("訊息閱讀器功能錯誤")) + + def _messageReaderAutoClickSaveChat(self, retriesLeft=15): + """Poll the ChatMoreOptions virtual window until 儲存聊天 is found, then click it.""" + if not getattr(self, '_messageReaderPending', False): + return + + from ._virtualWindows.chatMoreOptions import ChatMoreOptions + window = VirtualWindow.currentWindow + if not isinstance(window, ChatMoreOptions) or not window.elements: + if retriesLeft > 0: + core.callLater(300, lambda: self._messageReaderAutoClickSaveChat(retriesLeft - 1)) + else: + self._messageReaderPending = False + ui.message(_("找不到儲存聊天選項")) + return + + for i, elem in enumerate(window.elements): + if elem.get('name') == '儲存聊天': + window.pos = i + window.click() + log.info("LINE: message reader auto-clicked 儲存聊天") + return + + if retriesLeft > 0: + core.callLater(300, lambda: self._messageReaderAutoClickSaveChat(retriesLeft - 1)) + else: + self._messageReaderPending = False + ui.message(_("找不到儲存聊天選項")) + + def _messageReaderHandleSaveDialog(self, retriesLeft=10): + """Find the Save As dialog, set filename to temp folder, and save.""" + import ctypes + import ctypes.wintypes as wintypes + import tempfile + + lineProcessId = self.processID + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + dialogHwnd = None + + def _enumCallback(hwnd, lParam): + nonlocal dialogHwnd + buf = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buf, 256) + if buf.value == "#32770": + pid = wintypes.DWORD() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value == lineProcessId: + if ctypes.windll.user32.IsWindowVisible(hwnd): + dialogHwnd = hwnd + return False + return True + + ctypes.windll.user32.EnumWindows(WNDENUMPROC(_enumCallback), 0) + + if not dialogHwnd: + if retriesLeft > 0: + core.callLater(300, lambda: self._messageReaderHandleSaveDialog(retriesLeft - 1)) + else: + ui.message(_("未偵測到儲存對話框")) + return + + # Build temp file path + addonDir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + tempDir = os.path.join(addonDir, "temp") + os.makedirs(tempDir, exist_ok=True) + savePath = os.path.join(tempDir, "chat_export.txt") + self._messageReaderSavePath = savePath + + # Find the filename edit control in the Save dialog + # The file dialog has a ComboBoxEx32 > ComboBox > Edit hierarchy + editHwnd = self._findSaveDialogEdit(dialogHwnd) + if not editHwnd: + ui.message(_("無法操作儲存對話框")) + return + + # Set the filename + WM_SETTEXT = 0x000C + pathBuffer = ctypes.create_unicode_buffer(savePath) + ctypes.windll.user32.SendMessageW( + editHwnd, WM_SETTEXT, 0, pathBuffer + ) + + # Press Enter to save (send BN_CLICKED to Save button, or press Enter) + core.callLater(200, lambda: self._messageReaderPressSave(dialogHwnd)) + + def _findSaveDialogEdit(self, dialogHwnd): + """Find the filename Edit control inside a standard Windows Save dialog. + + Uses EnumChildWindows to recursively search all descendants, looking for + an Edit inside a ComboBoxEx32 > ComboBox chain (the filename field). + Falls back to the first visible+enabled Edit control found. + """ + import ctypes + import ctypes.wintypes as wintypes + + WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + foundEdit = [None] + + # Primary: find Edit inside any ComboBoxEx32 > ComboBox (filename field) + def _searchComboBoxEx(hwnd, lParam): + buf = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buf, 256) + if buf.value == "ComboBoxEx32": + combo = ctypes.windll.user32.FindWindowExW(hwnd, None, "ComboBox", None) + if combo: + edit = ctypes.windll.user32.FindWindowExW(combo, None, "Edit", None) + if edit and ctypes.windll.user32.IsWindowVisible(edit): + foundEdit[0] = edit + return False # stop enumeration + return True + + ctypes.windll.user32.EnumChildWindows( + dialogHwnd, WNDENUMPROC(_searchComboBoxEx), 0 + ) + if foundEdit[0]: + log.debug(f"LINE: found save dialog Edit in ComboBoxEx32: {foundEdit[0]}") + return foundEdit[0] + + # Fallback: first visible+enabled Edit control in the dialog + def _searchFirstEdit(hwnd, lParam): + buf = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buf, 256) + if buf.value == "Edit": + if (ctypes.windll.user32.IsWindowVisible(hwnd) + and ctypes.windll.user32.IsWindowEnabled(hwnd)): + foundEdit[0] = hwnd + return False + return True + + ctypes.windll.user32.EnumChildWindows( + dialogHwnd, WNDENUMPROC(_searchFirstEdit), 0 + ) + if foundEdit[0]: + log.debug(f"LINE: found save dialog Edit (fallback): {foundEdit[0]}") + else: + log.debug("LINE: could not find Edit in save dialog") + return foundEdit[0] + + def _messageReaderPressSave(self, dialogHwnd): + """Press Enter in the Save dialog to trigger save, then wait for file.""" + import ctypes + import ctypes.wintypes as wintypes + + # Find the Save/存檔 button and click it + WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + saveBtn = [None] + + def _findSaveBtn(hwnd, lParam): + buf = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buf, 256) + if buf.value == "Button": + titleBuf = ctypes.create_unicode_buffer(64) + ctypes.windll.user32.GetWindowTextW(hwnd, titleBuf, 64) + title = titleBuf.value + if any(k in title for k in ("存", "Save", "儲存")): + if ctypes.windll.user32.IsWindowVisible(hwnd): + saveBtn[0] = hwnd + return False + return True + + ctypes.windll.user32.EnumChildWindows(dialogHwnd, WNDENUMPROC(_findSaveBtn), 0) + + BM_CLICK = 0x00F5 + if saveBtn[0]: + log.debug(f"LINE: clicking Save button hwnd={saveBtn[0]}") + ctypes.windll.user32.SendMessageW(saveBtn[0], BM_CLICK, 0, 0) + else: + # Fallback: send Enter to the dialog + log.debug("LINE: Save button not found, sending Enter to dialog") + WM_KEYDOWN = 0x0100 + WM_KEYUP = 0x0101 + VK_RETURN = 0x0D + ctypes.windll.user32.SendMessageW(dialogHwnd, WM_KEYDOWN, VK_RETURN, 0) + ctypes.windll.user32.SendMessageW(dialogHwnd, WM_KEYUP, VK_RETURN, 0) + + # Handle possible "overwrite?" confirmation dialog + core.callLater(500, self._messageReaderHandleOverwrite) + + def _messageReaderHandleOverwrite(self, retriesLeft=5): + """Handle the overwrite confirmation dialog if it appears, then read the file.""" + import ctypes + import ctypes.wintypes as wintypes + + lineProcessId = self.processID + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + dialogHwnd = None + + def _enumCallback(hwnd, lParam): + nonlocal dialogHwnd + buf = ctypes.create_unicode_buffer(256) + ctypes.windll.user32.GetClassNameW(hwnd, buf, 256) + if buf.value == "#32770": + pid = wintypes.DWORD() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value == lineProcessId: + if ctypes.windll.user32.IsWindowVisible(hwnd): + dialogHwnd = hwnd + return False + return True + + ctypes.windll.user32.EnumWindows(WNDENUMPROC(_enumCallback), 0) + + if dialogHwnd: + # There's still a dialog — might be overwrite confirmation + # Try clicking Yes / pressing Enter + WM_KEYDOWN = 0x0100 + WM_KEYUP = 0x0101 + VK_RETURN = 0x0D + ctypes.windll.user32.SendMessageW(dialogHwnd, WM_KEYDOWN, VK_RETURN, 0) + ctypes.windll.user32.SendMessageW(dialogHwnd, WM_KEYUP, VK_RETURN, 0) + # Check again after a delay + if retriesLeft > 0: + core.callLater(500, lambda: self._messageReaderHandleOverwrite(retriesLeft - 1)) + return + + # No dialog — file should be saved, read it + core.callLater(300, self._messageReaderOpenFile) + + def _messageReaderOpenFile(self): + """Read the saved chat file and open the message reader dialog.""" + savePath = getattr(self, '_messageReaderSavePath', None) + if not savePath or not os.path.isfile(savePath): + ui.message(_("找不到儲存的聊天紀錄檔案")) + return + + try: + from ._chatParser import parseChatFile + from ._messageReader import openMessageReader + + messages = parseChatFile(savePath) + if not messages: + ui.message(_("聊天紀錄中沒有訊息")) + return + + log.info(f"LINE: message reader parsed {len(messages)} messages from {savePath}") + openMessageReader(messages) + except Exception as e: + log.warning(f"LINE: message reader parse error: {e}", exc_info=True) + ui.message(_("訊息閱讀器開啟錯誤")) def script_openFileDialog(self, gesture): """Pass Ctrl+O to LINE, suppress addon while file dialog is open.""" diff --git a/addon/globalPlugins/lineDesktopHelper.py b/addon/globalPlugins/lineDesktopHelper.py index cba6db4..03ea34d 100644 --- a/addon/globalPlugins/lineDesktopHelper.py +++ b/addon/globalPlugins/lineDesktopHelper.py @@ -109,6 +109,11 @@ def _createToolsMenu(self): # Translators: Menu item for clicking more options button _("更多選項(&O)") + "\tNVDA+Windows+O", ) + self._messageReaderItem = self._lineSubMenu.Append( + wx.ID_ANY, + # Translators: Menu item for opening the message reader + _("訊息閱讀器(&J)") + "\tNVDA+Windows+J", + ) self._readChatNameItem = self._lineSubMenu.Append( wx.ID_ANY, # Translators: Menu item for reading chat room name @@ -164,6 +169,9 @@ def _createToolsMenu(self): gui.mainFrame.sysTrayIcon.Bind( wx.EVT_MENU, self._onMoreOptions, self._moreOptionsItem ) + gui.mainFrame.sysTrayIcon.Bind( + wx.EVT_MENU, self._onMessageReader, self._messageReaderItem + ) gui.mainFrame.sysTrayIcon.Bind( wx.EVT_MENU, self._onReadChatName, self._readChatNameItem ) @@ -277,6 +285,21 @@ def _doVideoCall(self): log.warning(f"LINE makeVideoCall error: {e}", exc_info=True) ui.message(_("視訊通話功能錯誤: {error}").format(error=e)) + def _onMessageReader(self, evt): + wx.CallAfter(self._doMessageReader) + + def _doMessageReader(self): + import ui + lineApp = _getLineAppModule() + if not lineApp: + ui.message(_("LINE 未執行")) + return + try: + lineApp.script_openMessageReader(None) + except Exception as e: + log.warning(f"LINE openMessageReader error: {e}", exc_info=True) + ui.message(_("訊息閱讀器功能錯誤: {error}").format(error=e)) + def _onMoreOptions(self, evt): wx.CallAfter(self._doMoreOptions) @@ -541,6 +564,24 @@ def script_readChatRoomName(self, gesture): log.warning(f"LINE readChatRoomName error: {e}", exc_info=True) ui.message(_("讀取聊天室名稱錯誤: {error}").format(error=e)) + @script( + # Translators: Description of a script to open the message reader + description=_("LINE: 開啟訊息閱讀器"), + gesture="kb:NVDA+windows+j", + category="LINE Desktop", + ) + def script_openMessageReader(self, gesture): + import ui + lineApp = _getLineAppModule() + if not lineApp: + ui.message(_("LINE 未執行")) + return + try: + lineApp.script_openMessageReader(gesture) + except Exception as e: + log.warning(f"LINE openMessageReader error: {e}", exc_info=True) + ui.message(_("訊息閱讀器功能錯誤: {error}").format(error=e)) + @script( # Translators: Description of a script to click the more options button description=_("LINE: 點擊更多選項按鈕"), From a74fe36f5cca46924ac27906002f9dd68b86430d Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:17:04 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=5FchatParser:=20=E6=94=AF=E6=8F=B4?= =?UTF-8?q?=E5=A4=9A=E8=A1=8C=E8=A8=8A=E6=81=AF=EF=BC=88Shift+Enter=20?= =?UTF-8?q?=E6=8F=9B=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接續行(不符合日期/訊息格式的行)累積至上一則訊息的 content。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_chatParser.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/addon/appModules/_chatParser.py b/addon/appModules/_chatParser.py index 53db17e..52a2572 100644 --- a/addon/appModules/_chatParser.py +++ b/addon/appModules/_chatParser.py @@ -10,11 +10,13 @@ def parseChatFile(filePath): Returns a list of dicts with keys: name, content, time. Display format: name content time + Continuation lines (Shift+Enter multi-line messages) are appended to the + previous message's content. """ messages = [] with open(filePath, 'r', encoding='utf-8') as f: for line in f: - line = line.rstrip('\n').rstrip('\r') + line = line.rstrip('\r\n') if not line: continue if _DATE_RE.match(line): @@ -34,4 +36,8 @@ def parseChatFile(filePath): 'name': m.group(2), 'content': m.group(3), }) + continue + # Continuation line (Shift+Enter multi-line message) + if messages: + messages[-1]['content'] += '\n' + line return messages From e75d6ea4d3a148583dbdbce04bdb27949558c302 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:54:38 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=A8=8A=E6=81=AF?= =?UTF-8?q?=E9=96=B1=E8=AE=80=E5=99=A8=E6=9A=AB=E5=AD=98=E6=AA=94=E8=B7=AF?= =?UTF-8?q?=E5=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D=E9=8E=96=E5=AE=9A=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=B3=87=E6=96=99=E5=A4=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將聊天匯出暫存檔從插件目錄內 (addons/lineDesktop/temp/) 改為 系統暫存目錄 (%TEMP%/lineDesktop_chat_export.txt), 防止 NVDA 更新插件時因資料夾被鎖定而發生 PermissionError (WinError 5)。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index ca8a6ee..52042ae 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5967,11 +5967,9 @@ def _enumCallback(hwnd, lParam): ui.message(_("未偵測到儲存對話框")) return - # Build temp file path - addonDir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - tempDir = os.path.join(addonDir, "temp") - os.makedirs(tempDir, exist_ok=True) - savePath = os.path.join(tempDir, "chat_export.txt") + # Build temp file path (use system temp dir to avoid locking the addon folder) + import tempfile + savePath = os.path.join(tempfile.gettempdir(), "lineDesktop_chat_export.txt") self._messageReaderSavePath = savePath # Find the filename edit control in the Save dialog From d8c4ac21180e0c5dbfa0d20cdedddbb17e0e0c42 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:27:02 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E8=A8=8A=E6=81=AF=E9=96=B1=E8=AE=80?= =?UTF-8?q?=E5=99=A8=E9=97=9C=E9=96=89=E6=99=82=E8=87=AA=E5=8B=95=E5=88=AA?= =?UTF-8?q?=E9=99=A4=E6=9A=AB=E5=AD=98=E6=AA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 cleanupPath 參數到訊息閱讀器,當對話框關閉時 自動刪除 %TEMP% 中的聊天匯出暫存檔,避免累積臨時檔案。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 24 ++++++++++++++++++++---- addon/appModules/line.py | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index 9cf2dd5..6e8dbcd 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -10,7 +10,7 @@ class MessageReaderDialog(wx.Dialog): Up arrow moves to the previous message, down arrow moves to the next. """ - def __init__(self, messages, title="訊息閱讀器"): + def __init__(self, messages, title="訊息閱讀器", cleanupPath=None): super().__init__( gui.mainFrame, title=title, @@ -18,6 +18,7 @@ def __init__(self, messages, title="訊息閱讀器"): ) self._messages = messages self._pos = len(messages) - 1 if messages else -1 + self._cleanupPath = cleanupPath panel = wx.Panel(self) sizer = wx.BoxSizer(wx.VERTICAL) @@ -107,13 +108,28 @@ def _moveNext(self): self._speakMessage("已經是最後一則訊息") def _onClose(self, evt): + # Clean up temp file if specified + if self._cleanupPath: + try: + import os + if os.path.isfile(self._cleanupPath): + os.remove(self._cleanupPath) + log.debug(f"Deleted temp chat export: {self._cleanupPath}") + except Exception as e: + log.warning(f"Failed to delete temp chat export: {e}") self.Destroy() -def openMessageReader(messages, title="訊息閱讀器"): - """Open the message reader dialog on the main GUI thread.""" +def openMessageReader(messages, title="訊息閱讀器", cleanupPath=None): + """Open the message reader dialog on the main GUI thread. + + Args: + messages: List of parsed message dicts + title: Dialog window title + cleanupPath: Optional file path to delete when dialog closes + """ def _show(): - dlg = MessageReaderDialog(messages, title=title) + dlg = MessageReaderDialog(messages, title=title, cleanupPath=cleanupPath) dlg.Show() dlg.Raise() wx.CallAfter(_show) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 52042ae..4e3b9ac 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -6139,7 +6139,7 @@ def _messageReaderOpenFile(self): return log.info(f"LINE: message reader parsed {len(messages)} messages from {savePath}") - openMessageReader(messages) + openMessageReader(messages, cleanupPath=savePath) except Exception as e: log.warning(f"LINE: message reader parse error: {e}", exc_info=True) ui.message(_("訊息閱讀器開啟錯誤")) From 26fc287b2075e46a1d3279f91f87185d0cb1f850 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:35:44 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E5=84=B2=E5=AD=98=E5=89=8D=E9=A0=90?= =?UTF-8?q?=E5=85=88=E5=88=AA=E9=99=A4=E6=9A=AB=E5=AD=98=E6=AA=94=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=87=BA=E7=8F=BE=E5=8F=96=E4=BB=A3=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E5=B0=8D=E8=A9=B1=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在設定存檔路徑前先刪除已存在的 lineDesktop_chat_export.txt, 讓 LINE 的另存新檔對話框不會出現「是否取代」確認視窗。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 4e3b9ac..2a69f6e 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5972,6 +5972,13 @@ def _enumCallback(hwnd, lParam): savePath = os.path.join(tempfile.gettempdir(), "lineDesktop_chat_export.txt") self._messageReaderSavePath = savePath + # Pre-delete existing file to suppress the overwrite confirmation dialog + try: + if os.path.isfile(savePath): + os.remove(savePath) + except Exception as e: + log.warning(f"LINE: could not pre-delete chat export: {e}") + # Find the filename edit control in the Save dialog # The file dialog has a ComboBoxEx32 > ComboBox > Edit hierarchy editHwnd = self._findSaveDialogEdit(dialogHwnd) From 47265ebcd572c94f416c157eb4974d0fbd3895c5 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:48:04 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E6=94=B9=E5=96=84=E8=A8=8A=E6=81=AF?= =?UTF-8?q?=E9=96=B1=E8=AE=80=E5=99=A8=EF=BC=9A=E4=B8=A6=E7=99=BC=E4=BF=9D?= =?UTF-8?q?=E8=AD=B7=E3=80=81=E8=B5=B7=E5=A7=8B=E4=BD=8D=E7=BD=AE=E3=80=81?= =?UTF-8?q?Escape=20=E8=99=95=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - script_openMessageReader 加入 _messageReaderPending 檢查,防止重複觸發時覆寫共享狀態 - 訊息起始位置改為第一則(pos=0),符合閱讀直覺 - 移除 _onKeyDown 中重複的 Escape 處理,統一交由 _onCharHook 負責 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 4 +--- addon/appModules/line.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index 6e8dbcd..c87a73f 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -17,7 +17,7 @@ def __init__(self, messages, title="訊息閱讀器", cleanupPath=None): style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, ) self._messages = messages - self._pos = len(messages) - 1 if messages else -1 + self._pos = 0 if messages else -1 self._cleanupPath = cleanupPath panel = wx.Panel(self) @@ -77,8 +77,6 @@ def _onKeyDown(self, evt): self._movePrevious() elif keyCode == wx.WXK_DOWN: self._moveNext() - elif keyCode == wx.WXK_ESCAPE: - self.Close() else: evt.Skip() diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 2a69f6e..687eaab 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5893,6 +5893,8 @@ def _handleChatMoreOptionsAction(self, actionName): def script_openMessageReader(self, gesture): """Open message reader: click more options, auto-click save chat, parse, and display.""" + if getattr(self, '_messageReaderPending', False): + return ui.message(_("正在開啟訊息閱讀器…")) try: if not self._clickMoreOptionsButton(): From cb3509f4b3998e769ca202891df3bf3353e4de19 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:58:37 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E8=A8=8A=E6=81=AF=E9=96=B1=E8=AE=80?= =?UTF-8?q?=E5=99=A8=E9=87=8D=E8=A4=87=E8=A7=B8=E7=99=BC=E6=99=82=E7=B5=A6?= =?UTF-8?q?=E4=BA=88=E4=BD=BF=E7=94=A8=E8=80=85=E5=9B=9E=E9=A5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改為朗讀「訊息閱讀器正在執行中,請稍候」而非靜默返回。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 1 + 1 file changed, 1 insertion(+) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 687eaab..0204cd1 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5894,6 +5894,7 @@ def _handleChatMoreOptionsAction(self, actionName): def script_openMessageReader(self, gesture): """Open message reader: click more options, auto-click save chat, parse, and display.""" if getattr(self, '_messageReaderPending', False): + ui.message(_("訊息閱讀器正在執行中,請稍候")) return ui.message(_("正在開啟訊息閱讀器…")) try: From 1ea2f51f6db379da26a258e4e374848e3c9dae4c Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:03:42 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E8=A8=8A=E6=81=AF=E9=96=B1=E8=AE=80?= =?UTF-8?q?=E5=99=A8=E5=8A=A0=E5=85=A5=E5=96=AE=E4=BE=8B=E9=98=B2=E8=AD=B7?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E9=96=8B=E5=95=9F=E5=A4=9A=E5=80=8B?= =?UTF-8?q?=E8=A6=96=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加入模組層級的 _readerDlg sentinel,若視窗已開啟則直接 Raise 聚焦而非再建立新的 MessageReaderDialog 實例。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index c87a73f..ba61eca 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -118,6 +118,9 @@ def _onClose(self, evt): self.Destroy() +_readerDlg = None # module-level singleton sentinel + + def openMessageReader(messages, title="訊息閱讀器", cleanupPath=None): """Open the message reader dialog on the main GUI thread. @@ -127,7 +130,11 @@ def openMessageReader(messages, title="訊息閱讀器", cleanupPath=None): cleanupPath: Optional file path to delete when dialog closes """ def _show(): - dlg = MessageReaderDialog(messages, title=title, cleanupPath=cleanupPath) - dlg.Show() - dlg.Raise() + global _readerDlg + if _readerDlg and _readerDlg.IsShown(): + _readerDlg.Raise() + return + _readerDlg = MessageReaderDialog(messages, title=title, cleanupPath=cleanupPath) + _readerDlg.Show() + _readerDlg.Raise() wx.CallAfter(_show) From f4f64350ec39c7648feeae47308fb76c92e86872 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:15:32 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E5=B0=87=20=5FmessageReader.py=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E7=A1=AC=E7=B7=A8=E7=A2=BC=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=AD=97=E4=B8=B2=E6=94=B9=E7=82=BA=20=5F()=20=E5=8C=85?= =?UTF-8?q?=E8=A3=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所有 UI 字串(視窗標題、按鈕、提示訊息)改用 _() 包裹, 支援 NVDA 多語系翻譯機制。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index ba61eca..578d2cd 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -1,6 +1,7 @@ import wx import gui from logHandler import log +from languageHandler import gettext as _ class MessageReaderDialog(wx.Dialog): @@ -10,7 +11,9 @@ class MessageReaderDialog(wx.Dialog): Up arrow moves to the previous message, down arrow moves to the next. """ - def __init__(self, messages, title="訊息閱讀器", cleanupPath=None): + def __init__(self, messages, title=None, cleanupPath=None): + if title is None: + title = _("訊息閱讀器") super().__init__( gui.mainFrame, title=title, @@ -33,7 +36,7 @@ def __init__(self, messages, title="訊息閱讀器", cleanupPath=None): ) sizer.Add(self._textCtrl, 1, wx.EXPAND | wx.ALL, 5) - closeBtn = wx.Button(panel, wx.ID_CLOSE, "關閉(&C)") + closeBtn = wx.Button(panel, wx.ID_CLOSE, _("關閉(&C)")) sizer.Add(closeBtn, 0, wx.ALIGN_CENTER | wx.ALL, 5) panel.SetSizer(sizer) @@ -52,7 +55,7 @@ def _formatMessage(self, msg): def _updateDisplay(self): if not self._messages or self._pos < 0: - self._textCtrl.SetValue("沒有訊息") + self._textCtrl.SetValue(_("沒有訊息")) self._totalLabel.SetLabel("") return msg = self._messages[self._pos] @@ -94,7 +97,7 @@ def _movePrevious(self): self._pos -= 1 self._updateDisplay() else: - self._speakMessage("已經是第一則訊息") + self._speakMessage(_("已經是第一則訊息")) def _moveNext(self): if not self._messages: @@ -103,7 +106,7 @@ def _moveNext(self): self._pos += 1 self._updateDisplay() else: - self._speakMessage("已經是最後一則訊息") + self._speakMessage(_("已經是最後一則訊息")) def _onClose(self, evt): # Clean up temp file if specified @@ -121,7 +124,7 @@ def _onClose(self, evt): _readerDlg = None # module-level singleton sentinel -def openMessageReader(messages, title="訊息閱讀器", cleanupPath=None): +def openMessageReader(messages, title=None, cleanupPath=None): """Open the message reader dialog on the main GUI thread. Args: From 2387fbb249640f65ca7523391a351f8a11cc70fd Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:25:06 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=97=9C=E9=96=89?= =?UTF-8?q?=E5=BE=8C=E5=86=8D=E6=AC=A1=E9=96=8B=E5=95=9F=E8=A8=8A=E6=81=AF?= =?UTF-8?q?=E9=96=B1=E8=AE=80=E5=99=A8=E6=9C=83=E5=BC=95=E7=99=BC=20Runtim?= =?UTF-8?q?eError=20=E7=9A=84=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _onClose 關閉前將 _readerDlg 重設為 None,避免持有已銷毀物件的 wrapper - 移除 _handleChatMoreOptionsAction 中過早清除 _messageReaderPending 的程式碼 - 在 _messageReaderOpenFile 的所有出口(return 及 except)加上 _messageReaderPending = False Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 2 ++ addon/appModules/line.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index 578d2cd..ece5106 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -109,6 +109,8 @@ def _moveNext(self): self._speakMessage(_("已經是最後一則訊息")) def _onClose(self, evt): + global _readerDlg + _readerDlg = None # Allow future invocations to create a new instance # Clean up temp file if specified if self._cleanupPath: try: diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 0204cd1..b4e9dd7 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5886,7 +5886,6 @@ def _handleChatMoreOptionsAction(self, actionName): """Handle post-click actions from the chat more-options virtual window.""" if actionName == "儲存聊天": if getattr(self, '_messageReaderPending', False): - self._messageReaderPending = False core.callLater(800, self._messageReaderHandleSaveDialog) else: self._suppressAddonForFileDialog("Save chat selected") @@ -6137,6 +6136,7 @@ def _messageReaderOpenFile(self): savePath = getattr(self, '_messageReaderSavePath', None) if not savePath or not os.path.isfile(savePath): ui.message(_("找不到儲存的聊天紀錄檔案")) + self._messageReaderPending = False return try: @@ -6146,13 +6146,16 @@ def _messageReaderOpenFile(self): messages = parseChatFile(savePath) if not messages: ui.message(_("聊天紀錄中沒有訊息")) + self._messageReaderPending = False return log.info(f"LINE: message reader parsed {len(messages)} messages from {savePath}") openMessageReader(messages, cleanupPath=savePath) + self._messageReaderPending = False except Exception as e: log.warning(f"LINE: message reader parse error: {e}", exc_info=True) ui.message(_("訊息閱讀器開啟錯誤")) + self._messageReaderPending = False def script_openFileDialog(self, gesture): """Pass Ctrl+O to LINE, suppress addon while file dialog is open.""" From 69d8b97ad53ad620f833040a5bd72449207243f2 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:28:45 +0800 Subject: [PATCH 11/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20=5FmessageReader.py?= =?UTF-8?q?=20=E4=B8=AD=20TypeError:=20'module'=20object=20is=20not=20call?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除錯誤的 `from languageHandler import gettext as _`, _() 已由 line.py 呼叫 addonHandler.initTranslation() 注入為 builtin, 子模組直接使用即可。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/_messageReader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py index ece5106..2ec4edf 100644 --- a/addon/appModules/_messageReader.py +++ b/addon/appModules/_messageReader.py @@ -1,7 +1,6 @@ import wx import gui from logHandler import log -from languageHandler import gettext as _ class MessageReaderDialog(wx.Dialog): From d9b94a0dc3dcaaab6f7f3446e14023c6789966f4 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:43:28 +0800 Subject: [PATCH 12/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20line.py=20=E7=AC=AC?= =?UTF-8?q?=205969=20=E8=A1=8C=E7=B8=AE=E6=8E=92=E9=8C=AF=E8=AA=A4=20(Inde?= =?UTF-8?q?ntationError)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self._messageReaderPending = False 被意外寫成空格縮排, 導致 Python 解析失敗,改回正確的 tab 縮排。 Co-Authored-By: Claude Sonnet 4.6 --- addon/appModules/line.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index b4e9dd7..a244224 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5966,6 +5966,7 @@ def _enumCallback(hwnd, lParam): if retriesLeft > 0: core.callLater(300, lambda: self._messageReaderHandleSaveDialog(retriesLeft - 1)) else: + self._messageReaderPending = False ui.message(_("未偵測到儲存對話框")) return @@ -5985,6 +5986,7 @@ def _enumCallback(hwnd, lParam): # The file dialog has a ComboBoxEx32 > ComboBox > Edit hierarchy editHwnd = self._findSaveDialogEdit(dialogHwnd) if not editHwnd: + self._messageReaderPending = False ui.message(_("無法操作儲存對話框")) return @@ -6008,7 +6010,7 @@ def _findSaveDialogEdit(self, dialogHwnd): import ctypes import ctypes.wintypes as wintypes - WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) foundEdit = [None] # Primary: find Edit inside any ComboBoxEx32 > ComboBox (filename field) From ab8406da18cd8c60dc6e3c6403c067c1ac535772 Mon Sep 17 00:00:00 2001 From: Keyang556 <65295310+keyang556@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:11:59 +0800 Subject: [PATCH 13/13] Update addon/appModules/line.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- addon/appModules/line.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index a244224..312bc74 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5986,7 +5986,7 @@ def _enumCallback(hwnd, lParam): # The file dialog has a ComboBoxEx32 > ComboBox > Edit hierarchy editHwnd = self._findSaveDialogEdit(dialogHwnd) if not editHwnd: - self._messageReaderPending = False + self._messageReaderPending = False ui.message(_("無法操作儲存對話框")) return