arcanechat-tui 0.11.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.
- arcanechat_tui/__init__.py +3 -0
- arcanechat_tui/__main__.py +5 -0
- arcanechat_tui/_version.py +21 -0
- arcanechat_tui/application.py +165 -0
- arcanechat_tui/cards_widget.py +35 -0
- arcanechat_tui/chatlist.py +104 -0
- arcanechat_tui/cli.py +149 -0
- arcanechat_tui/composer.py +86 -0
- arcanechat_tui/container.py +31 -0
- arcanechat_tui/conversation.py +127 -0
- arcanechat_tui/eventcenter.py +45 -0
- arcanechat_tui/lazylistwaker.py +33 -0
- arcanechat_tui/logger.py +29 -0
- arcanechat_tui/main.py +64 -0
- arcanechat_tui/util.py +86 -0
- arcanechat_tui/welcome_widget.py +10 -0
- arcanechat_tui-0.11.0.dist-info/LICENSE +674 -0
- arcanechat_tui-0.11.0.dist-info/METADATA +84 -0
- arcanechat_tui-0.11.0.dist-info/RECORD +22 -0
- arcanechat_tui-0.11.0.dist-info/WHEEL +5 -0
- arcanechat_tui-0.11.0.dist-info/entry_points.txt +3 -0
- arcanechat_tui-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Conversation area widget"""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import urwid
|
|
7
|
+
from deltachat2 import (
|
|
8
|
+
Client,
|
|
9
|
+
Message,
|
|
10
|
+
MessageState,
|
|
11
|
+
SpecialContactId,
|
|
12
|
+
SystemMessageType,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .lazylistwaker import LazyListWalker
|
|
16
|
+
from .util import shorten_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DayMarker(urwid.Columns):
|
|
20
|
+
"""Day marker separating messages by day"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, timestamp: int):
|
|
23
|
+
date = datetime.utcfromtimestamp(timestamp) # timestamp is in local time already
|
|
24
|
+
date_format = "%b %d, %Y"
|
|
25
|
+
date_label = urwid.Text(("date", date.strftime(f"\n {date_format} ")))
|
|
26
|
+
divider = urwid.AttrMap(urwid.Divider("─", top=1, bottom=1), "date")
|
|
27
|
+
date_wgt = urwid.Columns([divider, ("flow", date_label), divider])
|
|
28
|
+
margin = ("fixed", 1, urwid.Text(" "))
|
|
29
|
+
super().__init__([margin, date_wgt, margin])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MessageItem(urwid.AttrMap):
|
|
33
|
+
"""A single message item"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, msg: Any, nickbg: str) -> None:
|
|
36
|
+
sender = msg.sender
|
|
37
|
+
sent_date = datetime.fromtimestamp(msg.timestamp)
|
|
38
|
+
if (
|
|
39
|
+
msg.show_padlock
|
|
40
|
+
or (sender.id <= SpecialContactId.LAST_SPECIAL and sender.id != SpecialContactId.SELF)
|
|
41
|
+
or msg.system_message_type == SystemMessageType.WEBXDC_INFO_MESSAGE
|
|
42
|
+
):
|
|
43
|
+
timestamp = sent_date.strftime(" %H:%M ")
|
|
44
|
+
date_wgt = (len(timestamp), urwid.SelectableIcon(("encrypted", timestamp)))
|
|
45
|
+
else:
|
|
46
|
+
timestamp = sent_date.strftime("!%H:%M ")
|
|
47
|
+
date_wgt = (len(timestamp), urwid.SelectableIcon(("unencrypted", timestamp)))
|
|
48
|
+
|
|
49
|
+
header_wgt = get_sender_label(msg, nickbg)
|
|
50
|
+
|
|
51
|
+
lines = []
|
|
52
|
+
if msg.quote:
|
|
53
|
+
if msg.quote.kind == "WithMessage":
|
|
54
|
+
quote_sender = msg.quote.override_sender_name or msg.quote.author_display_name
|
|
55
|
+
quote_color = urwid.AttrSpec(msg.quote.author_display_color, nickbg)
|
|
56
|
+
lines.append((quote_color, f"│ {quote_sender}\n"))
|
|
57
|
+
else:
|
|
58
|
+
quote_color = "quote"
|
|
59
|
+
lines.append((quote_color, "│ "))
|
|
60
|
+
lines.append(("quote", f"{shorten_text(msg.quote.text, 150, placeholder='[…]')}\n"))
|
|
61
|
+
text = msg.text
|
|
62
|
+
if msg.file_name:
|
|
63
|
+
text = f"[{msg.file_name}]{' – ' if text else ''}{text}"
|
|
64
|
+
if msg.is_info:
|
|
65
|
+
lines.append(("system_msg", text))
|
|
66
|
+
else:
|
|
67
|
+
if sender.id == SpecialContactId.SELF:
|
|
68
|
+
lines.append(("self_msg", text))
|
|
69
|
+
else:
|
|
70
|
+
lines.append(text)
|
|
71
|
+
body_wgt = urwid.Text(lines or "")
|
|
72
|
+
|
|
73
|
+
cols = urwid.Columns([date_wgt, urwid.Pile([header_wgt, body_wgt])])
|
|
74
|
+
super().__init__(cols, None, focus_map="focused_item")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConversationWidget(urwid.ListBox):
|
|
78
|
+
"""Display a list of messages"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, client: Client, nickbg: str) -> None:
|
|
81
|
+
self.client = client
|
|
82
|
+
self.nickbg = nickbg
|
|
83
|
+
self.chat: Optional[Tuple[int, int]] = None
|
|
84
|
+
super().__init__(LazyListWalker([], self._create_message_item))
|
|
85
|
+
|
|
86
|
+
def set_chat(self, chat: Optional[Tuple[int, int]]) -> None:
|
|
87
|
+
self.chat = chat
|
|
88
|
+
if chat:
|
|
89
|
+
self.client.rpc.marknoticed_chat(*chat)
|
|
90
|
+
self._update_conversation()
|
|
91
|
+
|
|
92
|
+
def messages_changed(self, _client: Client, accid: int, chatid: int, _msgid: int) -> None:
|
|
93
|
+
if self.chat and accid == self.chat[0] and chatid in (self.chat[1], 0):
|
|
94
|
+
self._update_conversation()
|
|
95
|
+
|
|
96
|
+
def _update_conversation(self) -> None:
|
|
97
|
+
self.body.clear_cache()
|
|
98
|
+
if self.chat:
|
|
99
|
+
items = self.client.rpc.get_message_list_items(*self.chat, False, True)
|
|
100
|
+
self.body[:] = [
|
|
101
|
+
(self.chat[0], item.kind, item.msg_id if item.kind == "message" else item.timestamp)
|
|
102
|
+
for item in items
|
|
103
|
+
]
|
|
104
|
+
if items:
|
|
105
|
+
self.set_focus(len(items) - 1)
|
|
106
|
+
else:
|
|
107
|
+
self.body.clear()
|
|
108
|
+
|
|
109
|
+
def _create_message_item(self, item: Tuple[int, str, int]) -> urwid.Widget:
|
|
110
|
+
if item[1] == "message":
|
|
111
|
+
self.client.rpc.markseen_msgs(item[0], [item[2]])
|
|
112
|
+
return MessageItem(self.client.rpc.get_message(item[0], item[2]), self.nickbg)
|
|
113
|
+
return DayMarker(item[2])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_sender_label(msg: Message, nickbg: str) -> urwid.Text:
|
|
117
|
+
name = shorten_text(msg.override_sender_name or msg.sender.display_name, 50)
|
|
118
|
+
components: list = [(urwid.AttrSpec(msg.sender.color, nickbg), name)]
|
|
119
|
+
if msg.state == MessageState.OUT_MDN_RCVD:
|
|
120
|
+
components.append(" ✓✓")
|
|
121
|
+
elif msg.state == MessageState.OUT_DELIVERED:
|
|
122
|
+
components.append(" ✓")
|
|
123
|
+
elif msg.state == MessageState.OUT_PENDING:
|
|
124
|
+
components.append(" →")
|
|
125
|
+
elif msg.state == MessageState.OUT_FAILED:
|
|
126
|
+
components.extend([" ", ("failed", " ! ")])
|
|
127
|
+
return urwid.Text(components)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Event center"""
|
|
2
|
+
|
|
3
|
+
import urwid
|
|
4
|
+
from deltachat2 import Client, CoreEvent, EventType
|
|
5
|
+
|
|
6
|
+
CHATLIST_CHANGED = "chatlist_changed"
|
|
7
|
+
CHAT_CHANGED = "chat_changed"
|
|
8
|
+
MESSAGES_CHANGED = "msgs_changed"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventCenter:
|
|
12
|
+
"""Event center dispatching Delta Chat core events"""
|
|
13
|
+
|
|
14
|
+
signals = [CHATLIST_CHANGED, CHAT_CHANGED, MESSAGES_CHANGED]
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
urwid.register_signal(self.__class__, self.signals)
|
|
18
|
+
|
|
19
|
+
def process_core_event(self, client: Client, accid: int, event: CoreEvent) -> None:
|
|
20
|
+
if event.kind == EventType.CHAT_MODIFIED:
|
|
21
|
+
urwid.emit_signal(self, CHAT_CHANGED, client, accid, event.chat_id)
|
|
22
|
+
urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
|
|
23
|
+
|
|
24
|
+
elif event.kind == EventType.CONTACTS_CHANGED:
|
|
25
|
+
urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
|
|
26
|
+
|
|
27
|
+
elif event.kind == EventType.INCOMING_MSG:
|
|
28
|
+
urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
|
|
29
|
+
urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
|
|
30
|
+
|
|
31
|
+
elif event.kind == EventType.MSGS_CHANGED:
|
|
32
|
+
urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
|
|
33
|
+
urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
|
|
34
|
+
|
|
35
|
+
elif event.kind == EventType.MSGS_NOTICED:
|
|
36
|
+
urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
|
|
37
|
+
|
|
38
|
+
elif event.kind == EventType.MSG_DELIVERED:
|
|
39
|
+
urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
|
|
40
|
+
|
|
41
|
+
elif event.kind == EventType.MSG_FAILED:
|
|
42
|
+
urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
|
|
43
|
+
|
|
44
|
+
elif event.kind == EventType.MSG_READ:
|
|
45
|
+
urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A ListWalker that creates the widgets dynamically as needed."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Any, Callable, Iterable, NoReturn
|
|
5
|
+
|
|
6
|
+
import urwid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LazyListWalker(urwid.SimpleListWalker):
|
|
10
|
+
"""A ListWalker that creates the widgets dynamically as needed."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
contents: Iterable,
|
|
15
|
+
widget_factory: Callable[[Any], urwid.Widget],
|
|
16
|
+
cache_size=1000,
|
|
17
|
+
wrap_around: bool = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.cache_size = cache_size
|
|
20
|
+
self._widget_factory = widget_factory
|
|
21
|
+
self.widget_factory = lru_cache(maxsize=cache_size)(widget_factory)
|
|
22
|
+
super().__init__(contents, wrap_around)
|
|
23
|
+
|
|
24
|
+
def clear_cache(self) -> None:
|
|
25
|
+
self.widget_factory = lru_cache(maxsize=self.cache_size)(self._widget_factory)
|
|
26
|
+
|
|
27
|
+
def __getitem__(self, position: int) -> urwid.Widget:
|
|
28
|
+
"""return widget at position or raise an IndexError or KeyError"""
|
|
29
|
+
return self.widget_factory(super().__getitem__(position))
|
|
30
|
+
|
|
31
|
+
def set_modified_callback(self, callback: Callable[[], Any]) -> NoReturn:
|
|
32
|
+
"""Ignore this, just copied from SimpleListWalker to avoid pylint warning"""
|
|
33
|
+
raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')
|
arcanechat_tui/logger.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Logging to log files"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import APP_NAME
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_logger(level: str, folder: Path) -> logging.Logger:
|
|
11
|
+
logger = logging.Logger(APP_NAME)
|
|
12
|
+
logger.parent = None
|
|
13
|
+
|
|
14
|
+
if level == "disabled":
|
|
15
|
+
logger.disabled = True
|
|
16
|
+
return logger
|
|
17
|
+
|
|
18
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
19
|
+
|
|
20
|
+
log_path = folder / "logs"
|
|
21
|
+
log_path.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
log_path /= "log.txt"
|
|
23
|
+
|
|
24
|
+
fhandler = RotatingFileHandler(log_path, backupCount=3, maxBytes=2000000)
|
|
25
|
+
fhandler.setLevel(level.upper())
|
|
26
|
+
fhandler.setFormatter(formatter)
|
|
27
|
+
logger.addHandler(fhandler)
|
|
28
|
+
|
|
29
|
+
return logger
|
arcanechat_tui/main.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Program's entry point"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from deltachat2 import Client, CoreEvent, EventType, IOTransport, Rpc, events
|
|
7
|
+
|
|
8
|
+
from .application import Application
|
|
9
|
+
from .cli import Cli
|
|
10
|
+
from .logger import create_logger
|
|
11
|
+
from .util import get_account
|
|
12
|
+
|
|
13
|
+
FG_COLOR = "white"
|
|
14
|
+
BG_COLOR = "g11"
|
|
15
|
+
dtheme = {
|
|
16
|
+
"background": ["", "", "", FG_COLOR, BG_COLOR],
|
|
17
|
+
"status_bar": ["", "", "", "white", "g23"],
|
|
18
|
+
"focused_item": ["", "", "", "white", "g23"],
|
|
19
|
+
"separator": ["", "", "", "g15", "g15"],
|
|
20
|
+
"date": ["", "", "", "#6f0", BG_COLOR],
|
|
21
|
+
"encrypted": ["", "", "", "dark gray", BG_COLOR],
|
|
22
|
+
"unencrypted": ["", "", "", "dark red", BG_COLOR],
|
|
23
|
+
"failed": ["", "", "", "white", "dark red"],
|
|
24
|
+
"selected_chat": ["", "", "", "black", "light blue"],
|
|
25
|
+
"unread_badge": ["", "", "", "#000", "#6f0"],
|
|
26
|
+
"unread_badge_muted": ["", "", "", "#000", "dark gray"],
|
|
27
|
+
"reversed": ["", "", "", BG_COLOR, FG_COLOR],
|
|
28
|
+
"quote": ["", "", "", "dark gray", BG_COLOR],
|
|
29
|
+
"mention": ["", "", "", "bold, light red", BG_COLOR],
|
|
30
|
+
"system_msg": ["", "", "", "dark gray", BG_COLOR],
|
|
31
|
+
"self_msg": ["", "", "", "dark green", BG_COLOR],
|
|
32
|
+
}
|
|
33
|
+
dkeymap = {
|
|
34
|
+
"quit": "q",
|
|
35
|
+
"send_msg": "enter",
|
|
36
|
+
"insert_new_line": "meta enter",
|
|
37
|
+
"next_chat": "meta up",
|
|
38
|
+
"prev_chat": "meta down",
|
|
39
|
+
}
|
|
40
|
+
hooks = events.HookCollection()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@hooks.on(events.RawEvent)
|
|
44
|
+
def log_event(client: Client, accid: int, event: CoreEvent) -> None:
|
|
45
|
+
if event.kind == EventType.INFO:
|
|
46
|
+
client.logger.debug("[acc=%s] %s", accid, event.msg)
|
|
47
|
+
elif event.kind == EventType.WARNING:
|
|
48
|
+
client.logger.warning("[acc=%s] %s", accid, event.msg)
|
|
49
|
+
elif event.kind == EventType.ERROR:
|
|
50
|
+
client.logger.error("[acc=%s] %s", accid, event.msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
args = Cli().parse_args()
|
|
55
|
+
args.program_folder.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
accounts_dir = args.program_folder / "accounts"
|
|
57
|
+
logging.getLogger("deltachat2.IOTransport").disabled = True
|
|
58
|
+
with IOTransport(accounts_dir=accounts_dir, stderr=subprocess.DEVNULL) as trans:
|
|
59
|
+
client = Client(Rpc(trans), hooks, create_logger(args.log, args.program_folder))
|
|
60
|
+
if "cmd" in args:
|
|
61
|
+
args.cmd(client, args)
|
|
62
|
+
else:
|
|
63
|
+
accid = get_account(client.rpc, args.account)
|
|
64
|
+
Application(client, keymap=dkeymap, theme=dtheme).run(accid)
|
arcanechat_tui/util.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Utilities"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from deltachat2 import ChatType, Rpc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def shorten_text(text: str, width: int, placeholder: str = "…") -> str:
|
|
10
|
+
text = " ".join(text.split())
|
|
11
|
+
if len(text) > width:
|
|
12
|
+
width -= len(placeholder)
|
|
13
|
+
assert width > 0, "placeholder can't be bigger than width"
|
|
14
|
+
text = text[:width].strip() + placeholder
|
|
15
|
+
return text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_subtitle(rpc: Rpc, accid: int, chat: Any) -> str:
|
|
19
|
+
if chat.is_self_talk:
|
|
20
|
+
return "Messages I sent to myself"
|
|
21
|
+
if chat.is_device_chat:
|
|
22
|
+
return "Locally generated messages"
|
|
23
|
+
if chat.chat_type == ChatType.MAILINGLIST:
|
|
24
|
+
return "Mailing List"
|
|
25
|
+
|
|
26
|
+
members = rpc.get_chat_contacts(accid, chat.id)
|
|
27
|
+
if chat.chat_type == ChatType.SINGLE:
|
|
28
|
+
subtitle = rpc.get_contact(accid, members[0]).address
|
|
29
|
+
elif chat.chat_type == ChatType.BROADCAST:
|
|
30
|
+
count = len(members)
|
|
31
|
+
subtitle = "1 recipient" if count == 1 else f"{count} recipients"
|
|
32
|
+
else:
|
|
33
|
+
count = len(members)
|
|
34
|
+
subtitle = "1 member" if count == 1 else f"{count} members"
|
|
35
|
+
|
|
36
|
+
return subtitle
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def abspath(path: str) -> Path:
|
|
40
|
+
try:
|
|
41
|
+
return Path(path).expanduser().absolute()
|
|
42
|
+
except RuntimeError:
|
|
43
|
+
return Path(path).absolute()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_docstring(txt) -> tuple:
|
|
47
|
+
"""parse docstring, returning a tuple with short and long description"""
|
|
48
|
+
description = txt
|
|
49
|
+
i = txt.find(".")
|
|
50
|
+
if i == -1:
|
|
51
|
+
help_ = txt
|
|
52
|
+
else:
|
|
53
|
+
help_ = txt[: i + 1]
|
|
54
|
+
return help_, description
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_or_create_account(rpc: Rpc, addr: str) -> int:
|
|
58
|
+
"""Get account for address, if no account exists create a new one."""
|
|
59
|
+
accid = get_account(rpc, addr)
|
|
60
|
+
if not accid:
|
|
61
|
+
accid = rpc.add_account()
|
|
62
|
+
rpc.set_config(accid, "addr", addr)
|
|
63
|
+
return accid
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_account(rpc: Rpc, addr: str) -> int:
|
|
67
|
+
"""Get account id for address.
|
|
68
|
+
If no account exists with the given address, zero is returned.
|
|
69
|
+
"""
|
|
70
|
+
if not addr:
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return int(addr)
|
|
75
|
+
except ValueError:
|
|
76
|
+
for accid in rpc.get_all_account_ids():
|
|
77
|
+
if addr == get_address(rpc, accid):
|
|
78
|
+
return accid
|
|
79
|
+
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_address(rpc: Rpc, accid: int) -> str:
|
|
84
|
+
if rpc.is_configured(accid):
|
|
85
|
+
return rpc.get_config(accid, "configured_addr")
|
|
86
|
+
return rpc.get_config(accid, "addr")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Welcome screen widget displayed when no chat is selected"""
|
|
2
|
+
|
|
3
|
+
import urwid
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WelcomeWidget(urwid.Filler):
|
|
7
|
+
"""Welcome screen widget displayed when no chat is selected"""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
super().__init__(urwid.Text("(No chat selected)", align="center"))
|