Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/worktrees/gracious-wright
Submodule gracious-wright added at 687f36
1 change: 1 addition & 0 deletions .claude/worktrees/hopeful-northcutt
Submodule hopeful-northcutt added at 365fe8
14 changes: 11 additions & 3 deletions addon/appModules/_chatParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
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
Returns a list of dicts for message-reader navigation.
Message entries use keys: type, name, content, time.
Date separator entries use keys: type, content.
Display format keeps date separators in their original positions.
Continuation lines (Shift+Enter multi-line messages) are appended to the
previous message's content.
"""
Expand All @@ -20,10 +22,15 @@ def parseChatFile(filePath):
if not line:
continue
if _DATE_RE.match(line):
messages.append({
'type': 'date',
'content': line,
})
continue
m = _RECALL_RE.match(line)
if m:
messages.append({
'type': 'message',
'time': m.group(1),
'name': m.group(2),
'content': '已收回訊息',
Expand All @@ -32,12 +39,13 @@ def parseChatFile(filePath):
m = _MSG_RE.match(line)
if m:
messages.append({
'type': 'message',
'time': m.group(1),
'name': m.group(2),
'content': m.group(3),
})
continue
# Continuation line (Shift+Enter multi-line message)
if messages:
if messages and messages[-1].get('type') != 'date':
messages[-1]['content'] += '\n' + line
return messages
32 changes: 26 additions & 6 deletions addon/appModules/_messageReader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
class MessageReaderDialog(wx.Dialog):
"""A dialog for reading LINE chat messages with up/down arrow navigation.

Each message is displayed as: name content time
Messages are displayed as: name content time
Date separators are displayed in their original positions.
Up arrow moves to the previous message, down arrow moves to the next.
"""

Expand All @@ -21,6 +22,13 @@ def __init__(self, messages, title=None, cleanupPath=None):
self._messages = messages
self._pos = 0 if messages else -1
self._cleanupPath = cleanupPath
self._messageCount = sum(1 for msg in messages if msg.get('type') != 'date')
self._messageIndexMap = []
messageIndex = 0
for msg in messages:
if msg.get('type') != 'date':
messageIndex += 1
self._messageIndexMap.append(messageIndex)

panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
Expand Down Expand Up @@ -50,8 +58,22 @@ def __init__(self, messages, title=None, cleanupPath=None):
self._textCtrl.SetFocus()

def _formatMessage(self, msg):
if msg.get('type') == 'date':
return msg.get('content', '')
return f"{msg['name']} {msg['content']} {msg['time']}"

def _getProgressLabel(self):
"""Return progress text counting only actual messages."""
if self._messageCount <= 0 or self._pos < 0:
return ""
currentMessageIndex = self._messageIndexMap[self._pos]
if self._messages[self._pos].get('type') == 'date':
if currentMessageIndex < self._messageCount:
currentMessageIndex += 1
else:
return ""
return f"{currentMessageIndex} / {self._messageCount}"

def _updateDisplay(self):
if not self._messages or self._pos < 0:
self._textCtrl.SetValue(_("沒有訊息"))
Expand All @@ -60,9 +82,7 @@ def _updateDisplay(self):
msg = self._messages[self._pos]
text = self._formatMessage(msg)
self._textCtrl.SetValue(text)
self._totalLabel.SetLabel(
f"{self._pos + 1} / {len(self._messages)}"
)
self._totalLabel.SetLabel(self._getProgressLabel())
self._speakMessage(text)

def _speakMessage(self, text):
Expand Down Expand Up @@ -96,7 +116,7 @@ def _movePrevious(self):
self._pos -= 1
self._updateDisplay()
else:
self._speakMessage(_("已經是第一則訊息"))
self._speakMessage(_("已經是第一項"))

def _moveNext(self):
if not self._messages:
Expand All @@ -105,7 +125,7 @@ def _moveNext(self):
self._pos += 1
self._updateDisplay()
else:
self._speakMessage(_("已經是最後一則訊息"))
self._speakMessage(_("已經是最後一項"))

def _onClose(self, evt):
global _readerDlg
Expand Down
2 changes: 1 addition & 1 deletion addonTemplate.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ addonTemplate.egg-info/dependency_links.txt
addonTemplate.egg-info/requires.txt
addonTemplate.egg-info/top_level.txt
addon/appModules/line.py
addon/globalPlugins/lineDesktopHelper.py
addon/globalPlugins/lineDesktopHelper.py
162 changes: 162 additions & 0 deletions tests/test_message_reader_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from __future__ import annotations

import importlib.util
import sys
import types
from pathlib import Path

from addon.appModules._chatParser import parseChatFile


def _load_message_reader_module():
module_name = "addon.appModules._messageReader"
module_path = (
Path(__file__).resolve().parents[1]
/ "addon"
/ "appModules"
/ "_messageReader.py"
)

gui_mod = types.ModuleType("gui")
gui_mod.mainFrame = object()
sys.modules["gui"] = gui_mod

wx_mod = types.ModuleType("wx")
wx_mod.Dialog = type("Dialog", (), {})
sys.modules["wx"] = wx_mod

log_handler_mod = types.ModuleType("logHandler")

class _Log:
def debug(self, *args, **kwargs):
pass

def warning(self, *args, **kwargs):
pass

log_handler_mod.log = _Log()
sys.modules["logHandler"] = log_handler_mod

spec = importlib.util.spec_from_file_location(module_name, module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
module._ = lambda text: text
sys.modules[module_name] = module
spec.loader.exec_module(module)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return module


message_reader = _load_message_reader_module()


def test_parse_chat_file_keeps_date_rows_in_original_positions(tmp_path):
chat_file = tmp_path / "chat.txt"
chat_file.write_text(
"\n".join(
[
"2026.04.09 星期四",
"09:00 Alice 早安",
"2026.04.10 星期五",
"10:30 Bob 已收回訊息",
]
),
encoding="utf-8",
)

assert parseChatFile(chat_file) == [
{"type": "date", "content": "2026.04.09 星期四"},
{"type": "message", "time": "09:00", "name": "Alice", "content": "早安"},
{"type": "date", "content": "2026.04.10 星期五"},
{"type": "message", "time": "10:30", "name": "Bob", "content": "已收回訊息"},
]


def test_parse_chat_file_appends_continuation_lines_only_to_messages(tmp_path):
chat_file = tmp_path / "chat.txt"
chat_file.write_text(
"\n".join(
[
"2026.04.09 星期四",
"09:00 Alice 第一行",
"第二行",
"2026.04.10 星期五",
"日期後面的孤立文字",
"10:00 Bob 新的一天",
]
),
encoding="utf-8",
)

assert parseChatFile(chat_file) == [
{"type": "date", "content": "2026.04.09 星期四"},
{
"type": "message",
"time": "09:00",
"name": "Alice",
"content": "第一行\n第二行",
},
{"type": "date", "content": "2026.04.10 星期五"},
{"type": "message", "time": "10:00", "name": "Bob", "content": "新的一天"},
]


def test_message_reader_formats_date_rows_without_removing_original_text():
dialog = object.__new__(message_reader.MessageReaderDialog)

assert dialog._formatMessage({"type": "date", "content": "2026.04.09 星期四"}) == (
"2026.04.09 星期四"
)
assert dialog._formatMessage(
{"type": "message", "name": "Alice", "content": "早安", "time": "09:00"}
) == "Alice 早安 09:00"


def test_message_reader_progress_counts_only_real_messages():
dialog = object.__new__(message_reader.MessageReaderDialog)
dialog._messages = [
{"type": "date", "content": "2026.04.09 星期四"},
{"type": "message", "name": "Alice", "content": "早安", "time": "09:00"},
{"type": "date", "content": "2026.04.10 星期五"},
{"type": "message", "name": "Bob", "content": "晚安", "time": "21:00"},
]
dialog._messageCount = 2
dialog._messageIndexMap = [0, 1, 1, 2]

dialog._pos = 0
assert dialog._getProgressLabel() == "1 / 2"

dialog._pos = 1
assert dialog._getProgressLabel() == "1 / 2"

dialog._pos = 2
assert dialog._getProgressLabel() == "2 / 2"

dialog._pos = 3
assert dialog._getProgressLabel() == "2 / 2"


def test_message_reader_hides_progress_on_trailing_date_row():
dialog = object.__new__(message_reader.MessageReaderDialog)
dialog._messages = [
{"type": "message", "name": "Alice", "content": "早安", "time": "09:00"},
{"type": "date", "content": "2026.04.10 星期五"},
]
dialog._messageCount = 1
dialog._messageIndexMap = [1, 1]
dialog._pos = 1

assert dialog._getProgressLabel() == ""


def test_message_reader_boundary_prompts_use_generic_item_wording():
spoken = []
dialog = object.__new__(message_reader.MessageReaderDialog)
dialog._messages = [{"type": "date", "content": "2026.04.09 星期四"}]
dialog._pos = 0
dialog._speakMessage = spoken.append
dialog._updateDisplay = lambda: None

dialog._movePrevious()
dialog._moveNext()

assert spoken == ["已經是第一項", "已經是最後一項"]
Loading