diff --git a/addon/appModules/_chatParser.py b/addon/appModules/_chatParser.py new file mode 100644 index 0000000..52a2572 --- /dev/null +++ b/addon/appModules/_chatParser.py @@ -0,0 +1,43 @@ +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 + 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('\r\n') + 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), + }) + continue + # Continuation line (Shift+Enter multi-line message) + if messages: + messages[-1]['content'] += '\n' + line + return messages diff --git a/addon/appModules/_messageReader.py b/addon/appModules/_messageReader.py new file mode 100644 index 0000000..2ec4edf --- /dev/null +++ b/addon/appModules/_messageReader.py @@ -0,0 +1,144 @@ +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=None, cleanupPath=None): + if title is None: + title = _("訊息閱讀器") + super().__init__( + gui.mainFrame, + title=title, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self._messages = messages + self._pos = 0 if messages else -1 + self._cleanupPath = cleanupPath + + 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() + 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): + global _readerDlg + _readerDlg = None # Allow future invocations to create a new instance + # 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() + + +_readerDlg = None # module-level singleton sentinel + + +def openMessageReader(messages, title=None, 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(): + global _readerDlg + if _readerDlg and _readerDlg.IsShown(): + _readerDlg.Raise() + return + _readerDlg = MessageReaderDialog(messages, title=title, cleanupPath=cleanupPath) + _readerDlg.Show() + _readerDlg.Raise() + wx.CallAfter(_show) diff --git a/addon/appModules/line.py b/addon/appModules/line.py index 2d6f1e8..312bc74 100644 --- a/addon/appModules/line.py +++ b/addon/appModules/line.py @@ -5885,7 +5885,279 @@ 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): + 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.""" + if getattr(self, '_messageReaderPending', False): + ui.message(_("訊息閱讀器正在執行中,請稍候")) + return + 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: + self._messageReaderPending = False + ui.message(_("未偵測到儲存對話框")) + return + + # 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 + + # 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) + if not editHwnd: + self._messageReaderPending = False + 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(wintypes.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(_("找不到儲存的聊天紀錄檔案")) + self._messageReaderPending = False + return + + try: + from ._chatParser import parseChatFile + from ._messageReader import openMessageReader + + 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.""" 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: 點擊更多選項按鈕"),