README: add installation and quickstart
[jan-pona-mute.git] / jan-pona-mute.py
1 #!/usr/bin/env python3
2 # Copyright (C) 2019 Alex Schroeder <alex@gnu.org>
3
4 # This program is free software: you can redistribute it and/or modify it under
5 # the terms of the GNU Affero General Public License as published by the Free
6 # Software Foundation, either version 3 of the License, or (at your option) any
7 # later version.
8 #
9 # This program is distributed in the hope that it will be useful, but WITHOUT
10 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12 # details.
13 #
14 # You should have received a copy of the GNU Affero General Public License along
15 # with this program. If not, see <https://www.gnu.org/licenses/>.
16
17 import diaspy
18 import subprocess
19 import argparse
20 import shutil
21 import cmd
22 import sys
23 import os
24
25 # Command abbreviations
26 _ABBREVS = {
27 "q": "quit",
28 "p": "print",
29 "c": "comments",
30 }
31
32 _RC_PATHS = (
33 "~/.config/jan-pona-mute/login",
34 "~/.config/.jan-pona-mute",
35 "~/.jan-pona-mute"
36 )
37
38 _PAGERS = (
39 "mdcat",
40 "fold",
41 "cat"
42 )
43
44 # Init file finder
45 def get_rcfile():
46 for rc_path in _RC_PATHS:
47 rcfile = os.path.expanduser(rc_path)
48 if os.path.exists(rcfile):
49 return rcfile
50 return None
51
52 # Pager finder
53 def get_pager():
54 for cmd in _PAGERS:
55 pager = shutil.which(cmd)
56 if pager != None:
57 return pager
58
59 class DiasporaClient(cmd.Cmd):
60
61 prompt = "\x1b[38;5;255m" + "> " + "\x1b[0m"
62 intro = "Welcome to Diaspora! Use the intro command for a quick introduction."
63
64 header_format = "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
65
66 username = None
67 pod = None
68 password = None
69 pager = None
70
71 connection = None
72 notifications = []
73 index = None
74 post = None
75 post_cache = {} # key is self.post.uid
76
77 undo = []
78
79
80 # dict mapping user ids to usernames
81 users = {}
82
83 def get_username(self, guid):
84 if guid in self.users:
85 return self.users[guid]
86 else:
87 user = diaspy.people.User(connection = self.connection, guid = guid)
88 self.users[guid] = user.handle()
89 return self.users[guid]
90
91 def do_intro(self, line):
92 """Start here."""
93 print("""
94 Use the account and password commands to set up your connection, then
95 use the login command to log in. If everything works as intended, use
96 the save command to save these commands to an init file.
97
98 Once you've listed things such as notifications, enter a number to
99 select the corresponding item. Use the print command to see more.
100 """)
101
102 def do_account(self, account):
103 """Set username and pod using the format username@pod."""
104 try:
105 (self.username, self.pod) = account.split('@')
106 print("Username and pod set: %s@%s" % (self.username, self.pod))
107 except ValueError:
108 print("The account must contain an @ character, e.g. kensanata@pluspora.com.")
109 print("Use the account comand to set the account.")
110
111 def do_info(self, line):
112 """Get some info about things. By default, it is info about yourself."""
113 print("Info about yourself:")
114 print("Username: %s" % self.username)
115 print("Password: %s" % ("None" if self.password == None else "set"))
116 print("Pod: %s" % self.pod)
117 print("Pager: %s" % self.pager)
118
119 def do_password(self, password):
120 """Set the password."""
121 self.password = (None if self.password == "" else password)
122 print("Password %s" % ("unset" if self.password == "" else "set"))
123
124 def do_save(self, line):
125 """Save your login information to the init file."""
126 if self.username == None or self.pod == None:
127 print("Use the account command to set username and pod")
128 elif self.password == None:
129 print("Use the password command")
130 else:
131 rcfile = get_rcfile()
132 if rcfile == None:
133 rfile = first(_RC_PATHS)
134 seen_account = False
135 seen_password = False
136 seen_login = False
137 changed = False
138 file = []
139 with open(rcfile, "r") as fp:
140 for line in fp:
141 words = line.strip().split()
142 if words:
143 if words[0] == "account":
144 seen_account = True
145 account = "%s@%s" % (self.username, self.pod)
146 if len(words) > 1 and words[1] != account:
147 line = "account %s\n" % account
148 changed = True
149 elif words[0] == "password":
150 seen_password = True
151 if len(words) > 1 and words[1] != self.password:
152 line = "password %s\n" % self.password
153 changed = True
154 elif words[0] == "login":
155 if seen_account and seen_password:
156 seen_login = True
157 else:
158 # skip login if no account or no password given
159 line = None
160 changed = True
161 if line != None:
162 file.append(line)
163 if not seen_account:
164 file.append("account %s@%s\n" % (self.username, self.pod))
165 changed = True
166 if not seen_password:
167 file.append("password %s\n" % self.password)
168 changed = True
169 if not seen_login:
170 file.append("login\n")
171 changed = True
172 if changed:
173 if os.path.isfile(rcfile):
174 os.rename(rcfile, rcfile + "~")
175 if not os.path.isdir(os.path.dirname(rcfile)):
176 os.makedirs(os.path.dirname(rcfile))
177 with open(rcfile, "w") as fp:
178 fp.write("".join(file))
179 print("Wrote %s" % rcfile)
180 else:
181 print("No changes made, %s left unchanged" % rcfile)
182
183 def do_login(self, line):
184 """Login."""
185 if line != "":
186 self.onecmd("account %s" % line)
187 if self.username == None or self.pod == None:
188 print("Use the account command to set username and pod")
189 elif self.password == None:
190 print("Use the password command")
191 else:
192 self.connection = diaspy.connection.Connection(
193 pod = "https://%s" % self.pod, username = self.username, password = self.password)
194 try:
195 self.connection.login()
196 self.onecmd("notifications")
197 except diaspy.errors.LoginError:
198 print("Login failed")
199
200 def do_pager(self, pager):
201 """Set the pager, e.g. to cat"""
202 self.pager = pager
203 print("Pager set: %s" % self.pager)
204
205 def header(self, line):
206 """Wrap line in header format."""
207 return self.header_format % line
208
209 def do_notifications(self, line):
210 """List notifications."""
211 if self.connection == None:
212 print("Use the login command, first.")
213 return
214 self.notifications = diaspy.notifications.Notifications(self.connection).last()
215 for n, notification in enumerate(self.notifications):
216 print(self.header("%2d. %s %s") % (n+1, notification.when(), notification))
217 print("Enter a number to select the notification.")
218
219 ### The end!
220 def do_quit(self, *args):
221 """Exit jan-pona-mute."""
222 print("Be safe!")
223 sys.exit()
224
225 def default(self, line):
226 if line.strip() == "EOF":
227 return self.onecmd("quit")
228
229 # Expand abbreviated commands
230 first_word = line.split()[0].strip()
231 if first_word in _ABBREVS:
232 full_cmd = _ABBREVS[first_word]
233 expanded = line.replace(first_word, full_cmd, 1)
234 return self.onecmd(expanded)
235
236 try:
237 n = int(line.strip())
238 # Finally, see if it's a notification and show it
239 self.do_show(n)
240 except ValueError:
241 print("Use the help command to show available commands")
242
243 def do_show(self, n):
244 """Show the post given by the index number.
245 The index number must refer to the current list of notifications."""
246 try:
247 notification = self.notifications[n-1]
248 self.index = n
249 except IndexError:
250 print("Index too high!")
251 return
252
253 self.show(notification)
254
255 print("Loading...")
256 self.post = diaspy.models.Post(connection = self.connection, id = notification.about())
257 self.post_cache[self.post.guid] = self.post
258
259 print()
260 self.show(self.post)
261
262 def show(self, item):
263 """Show the current item."""
264 if self.pager:
265 subprocess.run(self.pager, input = repr(item), text = True)
266 else:
267 print(repr(item))
268
269 def do_comments(self, line):
270 """Show the comments for the current post."""
271 if self.post == None:
272 print("Use the show command to show a post, first.")
273 return
274 if self.post.comments == None:
275 print("The current post has no comments.")
276 return
277
278 n = 5
279 comments = self.post.comments
280
281 if line == "all":
282 n = None
283 elif line != "":
284 try:
285 n = int(line.strip())
286 except ValueError:
287 print("The comments command takes a number as its argument, or 'all'")
288 print("The default is to show the last 5 comments")
289 return
290
291 if n != None:
292 comments = comments[-n:]
293
294 for n, comment in enumerate(comments):
295 print()
296 print(self.header("%2d. %s %s") % (n+1, comment.when(), comment.author()))
297 print()
298 self.show(comment)
299
300 def do_comment(self, line):
301 """Leave a comment on the current post."""
302 if self.post == None:
303 print("Use the show command to show a post, first.")
304 return
305 comment = self.post.comment(line)
306 self.post.comments.add(comment)
307 self.undo.append("delete comment %s from %s" % (comment.id, self.post.guid))
308 print("Comment posted.")
309
310 def do_delete(self, line):
311 """Delete a comment."""
312 words = line.strip().split()
313 if words:
314 if words[0] == "comment":
315 if self.post == None:
316 print("Use the show command to show a post, first.")
317 return
318 if len(words) != 4:
319 print("Deleting a comment requires a comment id and a post guid.")
320 print("delete comment <id> from <guid>")
321 return
322 self.post_cache[words[3]].delete_comment(words[1])
323 print("Comment deleted.")
324 else:
325 print("Deleting '%s' is not supported." % words[0])
326 return
327 else:
328 print("Delete what?")
329
330 def do_undo(self, line):
331 """Undo an action."""
332 if line != "":
333 print("Undo does not take an argument.")
334 return
335 if not self.undo:
336 print("There is nothing to undo.")
337 return
338 return self.onecmd(self.undo.pop())
339
340 # Main function
341 def main():
342
343 # Parse args
344 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
345 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
346 const=False, default=True, help='Do not load a init file')
347 args = parser.parse_args()
348
349 # Instantiate client
350 c = DiasporaClient()
351
352 # Process init file
353 seen_pager = False
354 if args.init_file:
355 rcfile = get_rcfile()
356 if rcfile:
357 print("Using init file %s" % rcfile)
358 with open(rcfile, "r") as fp:
359 for line in fp:
360 line = line.strip()
361 if line != "":
362 c.cmdqueue.append(line)
363 if not seen_pager:
364 seen_pager = line.startswith("pager ");
365 else:
366 print("Use the save command to save your login sequence to an init file")
367
368 if not seen_pager:
369 # prepend
370 c.cmdqueue.insert(0, "pager %s" % get_pager())
371
372 # Endless interpret loop
373 while True:
374 try:
375 c.cmdloop()
376 except KeyboardInterrupt:
377 print("")
378
379 if __name__ == '__main__':
380 main()