Note what we're doing during login
[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 _RC_PATHS = (
26 "~/.config/jan-pona-mute/login",
27 "~/.jan-pona-mute.d/login"
28 "~/.jan-pona-mute"
29 )
30
31 _NOTE_DIRS = (
32 "~/.config/jan-pona-mute/notes",
33 "~/.jan-pona-mute.d/notes"
34 )
35
36 _PAGERS = (
37 os.getenv("PAGER"),
38 "mdcat",
39 "fold",
40 "cat"
41 )
42
43 _EDITORS = (
44 os.getenv("EDITOR"),
45 "vi",
46 "ed"
47 )
48
49 shortcuts = {
50 "q": "quit",
51 "p": "preview",
52 "c": "comments",
53 "r": "reload",
54 "n": "notifications",
55 "e": "edit",
56 "d": "delete",
57 "g": "notifications reload",
58 }
59
60 def get_rcfile():
61 """Init file finder"""
62 for path in _RC_PATHS:
63 path = os.path.expanduser(path)
64 if os.path.exists(path):
65 return path
66 return None
67
68 def get_notes_dir():
69 """Notes directory finder"""
70 dir = None
71 for path in _NOTE_DIRS:
72 path = os.path.expanduser(path)
73 if os.path.isdir(path):
74 dir = path
75 break
76 if dir == None:
77 dir = os.path.expanduser(_NOTE_DIRS[0])
78 if not os.path.isdir(dir):
79 os.makedirs(dir)
80 return dir
81
82 def get_binary(list):
83 for cmd in list:
84 if cmd != None:
85 bin = shutil.which(cmd)
86 if bin != None:
87 return bin
88
89 def get_pager():
90 """Pager finder"""
91 return get_binary(_PAGERS)
92
93 def get_editor():
94 """Editor finder"""
95 return get_binary(_EDITORS)
96
97 class DiasporaClient(cmd.Cmd):
98
99 prompt = "\x1b[38;5;255m" + "> " + "\x1b[0m"
100 intro = "Welcome to Diaspora! Use the intro command for a quick introduction."
101
102 header_format = "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
103
104 username = None
105 pod = None
106 password = None
107 pager = None
108 editor = None
109
110 connection = None
111 notifications = []
112 home = None
113 numbers_refer_to = None
114 post = None
115 post_cache = {} # key is self.post.uid, and notification.id
116
117 undo = []
118
119
120 # dict mapping user ids to usernames
121 users = {}
122
123 def get_username(self, guid):
124 if guid in self.users:
125 return self.users[guid]
126 else:
127 user = diaspy.people.User(connection = self.connection, guid = guid)
128 self.users[guid] = user.handle()
129 return self.users[guid]
130
131 def do_intro(self, line):
132 """Start here."""
133 print("""
134 Use the 'account' and 'password' commands to set up your connection,
135 then use the 'login' command to log in. If everything works as
136 intended, use the 'save' command to save these commands to an init
137 file.
138
139 Once you've listed things such as notifications or the home stream,
140 enter a number to select the corresponding item.
141 """)
142
143 def do_account(self, account):
144 """Set username and pod using the format username@pod."""
145 try:
146 (self.username, self.pod) = account.split('@')
147 print("Username and pod set: %s@%s" % (self.username, self.pod))
148 except ValueError:
149 print("The account must contain an @ character, e.g. kensanata@pluspora.com.")
150 print("Use the account comand to set the account.")
151
152 def do_info(self, line):
153 """Get some info about things. By default, it is info about yourself."""
154 print(self.header("Info"))
155 print("Username: %s" % self.username)
156 print("Password: %s" % ("None" if self.password == None else "set"))
157 print("Pod: %s" % self.pod)
158 print("Pager: %s" % self.pager)
159 print("Editor: %s" % self.editor)
160 print("Cache: %s posts" % len(self.post_cache))
161
162 def do_password(self, password):
163 """Set the password."""
164 self.password = (None if self.password == "" else password)
165 print("Password %s" % ("unset" if self.password == "" else "set"))
166
167 def do_save(self, line):
168 """Save your login information to the init file."""
169 if self.username == None or self.pod == None:
170 print("Use the 'account' command to set username and pod.")
171 elif self.password == None:
172 print("Use the 'password' command.")
173 else:
174 rcfile = get_rcfile()
175 if rcfile == None:
176 rfile = _RC_PATHS[0]
177 seen_account = False
178 seen_password = False
179 seen_login = False
180 changed = False
181 file = []
182 with open(rcfile, "r") as fp:
183 for line in fp:
184 words = line.strip().split()
185 if words:
186 if words[0] == "account":
187 seen_account = True
188 account = "%s@%s" % (self.username, self.pod)
189 if len(words) > 1 and words[1] != account:
190 line = "account %s\n" % account
191 changed = True
192 elif words[0] == "password":
193 seen_password = True
194 if len(words) > 1 and words[1] != self.password:
195 line = "password %s\n" % self.password
196 changed = True
197 elif words[0] == "login":
198 if seen_account and seen_password:
199 seen_login = True
200 else:
201 # skip login if no account or no password given
202 line = None
203 changed = True
204 if line != None:
205 file.append(line)
206 if not seen_account:
207 file.append("account %s@%s\n" % (self.username, self.pod))
208 changed = True
209 if not seen_password:
210 file.append("password %s\n" % self.password)
211 changed = True
212 if not seen_login:
213 file.append("login\n")
214 changed = True
215 if changed:
216 if os.path.isfile(rcfile):
217 os.rename(rcfile, rcfile + "~")
218 if not os.path.isdir(os.path.dirname(rcfile)):
219 os.makedirs(os.path.dirname(rcfile))
220 with open(rcfile, "w") as fp:
221 fp.write("".join(file))
222 print("Wrote %s" % rcfile)
223 else:
224 print("No changes made, %s left unchanged" % rcfile)
225
226 def do_login(self, line):
227 """Login."""
228 if line != "":
229 self.onecmd("account %s" % line)
230 if self.username == None or self.pod == None:
231 print("Use the 'account' command to set username and pod.")
232 elif self.password == None:
233 print("Use the 'password' command.")
234 else:
235 print("Setting up a connection...")
236 self.connection = diaspy.connection.Connection(
237 pod = "https://%s" % self.pod, username = self.username, password = self.password)
238 try:
239 print("Logging in...")
240 self.connection.login()
241 except diaspy.errors.LoginError:
242 print("Login failed.")
243
244 def do_pager(self, pager):
245 """Set the pager, e.g. to cat"""
246 self.pager = pager
247 print("Pager set: %s" % self.pager)
248
249 def do_editor(self, editor):
250 """Set the editor, e.g. to ed"""
251 self.editor = editor
252 print("Editor set: %s" % self.editor)
253
254 def header(self, line):
255 """Wrap line in header format."""
256 return self.header_format % line
257
258 def do_notifications(self, line):
259 """List notifications. Use 'notifications reload' to reload them."""
260 if line == "" and self.notifications:
261 print("Redisplaying the notifications in the cache.")
262 print("Use the 'reload' argument to reload them.")
263 elif line == "reload" or not self.notifications:
264 if self.connection == None:
265 print("Use the 'login' command, first.")
266 return
267 self.notifications = diaspy.notifications.Notifications(self.connection).last()
268 else:
269 print("The 'notifications' command only takes the optional argument 'reload'.")
270 return
271 if self.notifications:
272 for n, notification in enumerate(self.notifications):
273 if notification.unread:
274 print(self.header("%2d. %s %s") % (n+1, notification.when(), notification))
275 else:
276 print("%2d. %s %s" % (n+1, notification.when(), notification))
277 print("Enter a number to select the notification.")
278 self.numbers_refer_to = 'notifications'
279 else:
280 print("There are no notifications. 😢")
281
282 ### The end!
283 def do_quit(self, *args):
284 """Exit jan-pona-mute."""
285 print("Be safe!")
286 sys.exit()
287
288 def default(self, line):
289 if line.strip() == "EOF":
290 return self.onecmd("quit")
291
292 # Expand abbreviated commands
293 first_word = line.split()[0].strip()
294 if first_word in shortcuts:
295 full_cmd = shortcuts[first_word]
296 expanded = line.replace(first_word, full_cmd, 1)
297 return self.onecmd(expanded)
298
299 # Finally, see if it's a notification and show it
300 self.do_show(line)
301
302 def do_show(self, line):
303 """Show the post given by the index number.
304 The index number must refer to the current list of notifications
305 or the home stream. If no index number is given, show the current
306 post again."""
307 if not self.notifications and not self.home:
308 print("Use the 'login' command to load notifications.")
309 return
310 if line == "" and self.post == None:
311 print("Please specify a number.")
312 return
313 if line != "":
314 try:
315 n = int(line.strip())
316 if self.numbers_refer_to == 'notifications':
317 notification = self.notifications[n-1]
318 self.show(notification)
319 self.load(notification.about())
320 elif self.numbers_refer_to == 'home':
321 self.post = self.home[n-1]
322 else:
323 print("Internal error: not sure what numbers '%s' refer to." % self.numbers_refer_to)
324 return
325 except ValueError:
326 print("The 'show' command takes a notification number but '%s' is not a number" % line)
327 return
328 except IndexError:
329 print("Index too high!")
330 return
331
332 print()
333 self.show(self.post)
334
335 if(self.post.comments):
336 print()
337 if len(self.post.comments) == 1:
338 print("There is 1 comment.")
339 else:
340 print("There are %d comments." % len(self.post.comments))
341 print("Use the 'comments' command to list the latest comments.")
342
343 def load(self, id):
344 """Load the post belonging to the id (from a notification),
345 or get it from the cache."""
346 if id in self.post_cache:
347 self.post = self.post_cache[id]
348 print("Retrieved post from the cache.")
349 else:
350 print("Loading...")
351 self.post = diaspy.models.Post(connection = self.connection, id = id)
352 self.post_cache[id] = self.post
353 return self.post
354
355 def do_reload(self, line):
356 """Reload the current post."""
357 if self.post == None:
358 print("Use the 'show' command to show a post, first.")
359 return
360 print("Reloading...")
361 self.post = diaspy.models.Post(connection = self.connection, id = self.post.id)
362 self.post_cache[id] = self.post
363
364 def show(self, item):
365 """Show the current item."""
366 if self.pager:
367 subprocess.run(self.pager, input = str(item), text = True)
368 else:
369 print(str(item))
370
371 def do_comments(self, line):
372 """Show the comments for the current post.
373 Use the 'all' argument to show them all. Use a numerical argument to
374 show that many. The default is to load the last five."""
375 if self.post == None:
376 print("Use the 'show' command to show a post, first.")
377 return
378 if self.post.comments == None:
379 print("The current post has no comments.")
380 return
381
382 n = 5
383 comments = self.post.comments
384
385 if line == "all":
386 n = None
387 elif line != "":
388 try:
389 n = int(line.strip())
390 except ValueError:
391 print("The 'comments' command takes a number as its argument, or 'all'.")
392 print("The default is to show the last 5 comments.")
393 return
394
395 if n == None:
396 start = 0
397 else:
398 # n is from the back
399 start = max(len(comments) - n, 0)
400
401 if comments:
402 for n, comment in enumerate(comments[start:], start):
403 print()
404 print(self.header("%2d. %s %s") % (n+1, comment.when(), comment.author()))
405 print()
406 self.show(comment)
407 else:
408 print("There are no comments on the selected post.")
409
410 def do_comment(self, line):
411 """Leave a comment on the current post.
412 If you just use a number as your comment, it will refer to a note.
413 Use the 'edit' command to edit notes."""
414 if self.post == None:
415 print("Use the 'show' command to show a post, first.")
416 return
417 try:
418 # if the comment is just a number, use a note to post
419 n = int(line.strip())
420 notes = self.get_notes()
421 if notes:
422 try:
423 line = self.read_note(notes[n-1])
424 print("Using note #%d: %s" % (n, notes[n-1]))
425 except IndexError:
426 print("Use the 'list notes' command to list valid numbers.")
427 return
428 else:
429 print("There are no notes to use.")
430 return
431 except ValueError:
432 # in which case we'll simply comment with the line
433 pass
434 comment = self.post.comment(line)
435 self.post.comments.add(comment)
436 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
437 print("Comment posted.")
438
439 def do_post(self, line):
440 """Write a post on the current stream.
441 If you just use a number as your post, it will refer to a note.
442 Use the 'edit' command to edit notes."""
443 if self.home == None:
444 self.home = diaspy.streams.Stream(self.connection)
445 try:
446 # if the post is just a number, use a note to post
447 n = int(line.strip())
448 notes = self.get_notes()
449 if notes:
450 try:
451 line = self.read_note(notes[n-1])
452 print("Using note #%d: %s" % (n, notes[n-1]))
453 except IndexError:
454 print("Use the 'list notes' command to list valid numbers.")
455 return
456 else:
457 print("There are no notes to use.")
458 return
459 except ValueError:
460 # in which case we'll simply post the line
461 pass
462 self.post = self.home.post(text = line)
463 self.post_cache[self.post.id] = self.post
464 self.undo.append("delete post %s" % self.post.id)
465 print("Posted. Use the 'show' command to show it.")
466
467 def do_delete(self, line):
468 """Delete a comment."""
469 words = line.strip().split()
470 if words:
471 if words[0] == "comment":
472 if len(words) == 4:
473 post = self.post_cache[words[3]]
474 post.delete_comment(words[1])
475 comments = [c.id for c in post.comments if c.id != id]
476 post.comments = diaspy.models.Comments(comments)
477 print("Comment deleted.")
478 return
479 if self.post == None:
480 print("Use the 'show' command to show a post, first.")
481 return
482 if len(words) == 2:
483 try:
484 n = int(words[1])
485 comment = self.post.comments[n-1]
486 id = comment.id
487 except ValueError:
488 print("Deleting a comment requires an integer.")
489 return
490 except IndexError:
491 print("Use the 'comments' command to find valid comment numbers.")
492 return
493 # not catching any errors from diaspy
494 self.post.delete_comment(id)
495 # there is no self.post.comments.remove(id)
496 comments = [c.id for c in self.post.comments if c.id != id]
497 self.post.comments = diaspy.models.Comments(comments)
498 print("Comment deleted.")
499 return
500 else:
501 print("Deleting a comment requires a comment id and a post id, or a number.")
502 print("delete comment <comment id> from <post id>")
503 print("delete comment 5")
504 return
505 if words[0] == "note":
506 if len(words) != 2:
507 print("Deleting a note requires a number.")
508 return
509 try:
510 n = int(words[1])
511 except ValueError:
512 print("Deleting a note requires an integer.")
513 return
514 notes = self.get_notes()
515 if notes:
516 try:
517 os.unlink(self.get_note_path(notes[n-1]))
518 print("Deleted note #%d: %s" % (n, notes[n-1]))
519 except IndexError:
520 print("Use the 'list notes' command to list valid numbers.")
521 else:
522 print("There are no notes to delete.")
523 else:
524 print("Things to delete: comment, note.")
525 return
526 else:
527 print("Delete what?")
528
529 def do_undo(self, line):
530 """Undo an action."""
531 if line != "":
532 print("The 'undo' command does not take an argument.")
533 return
534 if not self.undo:
535 print("There is nothing to undo.")
536 return
537 return self.onecmd(self.undo.pop())
538
539 def do_edit(self, line):
540 """Edit a note with a given name."""
541 if line == "":
542 print("Edit takes the name of a note as an argument.")
543 return
544 file = self.get_note_path(line)
545 if self.editor:
546 subprocess.run([self.editor, file])
547 self.onecmd("notes")
548 else:
549 print("Use the 'editor' command to set an editor.")
550
551 def do_notes(self, line):
552 """List notes"""
553 if line != "":
554 print("The 'notes' command does not take an argument.")
555 return
556 notes = self.get_notes()
557 if notes:
558 for n, note in enumerate(notes):
559 print(self.header("%2d. %s") % (n+1, note))
560 print("Use the 'edit' command to edit a note.")
561 print("Use the 'preview' command to look at a note.")
562 print("Use the 'post' command to post a note.")
563 print("Use the 'comment' command to post a comment.")
564 else:
565 print("Use 'edit' to create a note.")
566
567 def get_notes(self):
568 """Get the list of notes."""
569 return os.listdir(get_notes_dir())
570
571 def get_note_path(self, filename):
572 """Get the correct path for a note."""
573 return os.path.join(get_notes_dir(), filename)
574
575 def read_note(self, filename):
576 """Get text of a note."""
577 with open(self.get_note_path(filename), mode = 'r', encoding = 'utf-8') as fp:
578 return fp.read()
579
580 def do_preview(self, line):
581 """Preview a note using your pager.
582 Use the 'pager' command to set your pager to something like 'mdcat'."""
583 if line == "":
584 print("The 'preview' command the number of a note as an argument.")
585 print("Use the 'notes' command to list all your notes.")
586 return
587 try:
588 n = int(line.strip())
589 notes = self.get_notes()
590 if notes:
591 try:
592 self.show(self.read_note(notes[n-1]))
593 except IndexError:
594 print("Use the 'list notes' command to list valid numbers.")
595 return
596 else:
597 print("There are no notes to preview.")
598 return
599 except ValueError:
600 print("The 'preview' command takes a number as its argument.")
601 return
602
603 def do_home(self, line):
604 """Show the main stream containing the combined posts of the
605 followed users and tags and the community spotlights posts if
606 the user enabled those."""
607 if line == "":
608 if self.home:
609 print("Redisplaying the cached statuses of the home stream.")
610 print("Use the 'reload' argument to reload them.")
611 print("Use the 'all' argument to show them all.")
612 print("Use a number to show only that many.")
613 print("The default is 5.")
614 else:
615 print("Loading...")
616 self.home = diaspy.streams.Stream(self.connection)
617 self.home.fill()
618 for post in self.home:
619 if post.id not in self.post_cache:
620 self.post_cache[post.id] = post
621 elif line == "reload":
622 if self.connection == None:
623 print("Use the 'login' command, first.")
624 return
625 if self.home:
626 print("Reloading...")
627 self.home.update()
628 line = ""
629 else:
630 self.home = diaspy.streams.Stream(self.connection)
631 self.home.fill()
632
633 n = 5
634 posts = sorted(self.home, key=lambda x: x.data()["created_at"])
635
636 if line == "all":
637 n = None
638 elif line != "":
639 try:
640 n = int(line.strip())
641 except ValueError:
642 print("The 'home' command takes a number as its argument, or 'reload' or 'all'.")
643 print("The default is to show the last 5 posts.")
644 return
645
646 if n == None:
647 start = 0
648 else:
649 # n is from the back
650 start = max(len(posts) - n, 0)
651
652 if posts:
653 for n, post in enumerate(posts[start:], start):
654 print()
655 print(self.header("%2d. %s %s") % (n+1, post.data()["created_at"], post.author()))
656 print()
657 self.show(post)
658
659 print("Enter a number to select the post.")
660 self.numbers_refer_to = 'home'
661 else:
662 print("The people you follow have nothing to say.")
663 print("The tags you follow are empty. 😢")
664
665 def do_shortcuts(self, line):
666 """List all shortcuts."""
667 if line != "":
668 print("The 'shortcuts' command does not take an argument.")
669 return
670 print(self.header("Shortcuts"))
671 for shortcut in sorted(shortcuts):
672 print("%s\t%s" % (shortcut, shortcuts[shortcut]))
673 print("Use the 'shortcut' command to change or add shortcuts.")
674
675 def do_shortcut(self, line):
676 """Define a new shortcut."""
677 words = line.strip().split(maxsplit = 1)
678 if len(words) == 0:
679 return self.onecmd("shortcuts")
680 elif len(words) == 1:
681 shortcut = words[0]
682 if shortcut in shortcuts:
683 print("%s\t%s" % (shortcut, shortcuts[shortcut]))
684 else:
685 print("%s is not a shortcut" % shortcut)
686 else:
687 shortcuts[words[0]] = words[1]
688
689 # Main function
690 def main():
691
692 # Parse args
693 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
694 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
695 const=False, default=True, help='Do not load a init file')
696 args = parser.parse_args()
697
698 # Instantiate client
699 c = DiasporaClient()
700
701 # Process init file
702 seen_pager = False
703 seen_editor = False
704 if args.init_file:
705 rcfile = get_rcfile()
706 if rcfile:
707 print("Using init file %s" % rcfile)
708 with open(rcfile, "r") as fp:
709 for line in fp:
710 line = line.strip()
711 if line != "":
712 c.cmdqueue.append(line)
713 if not seen_pager:
714 seen_pager = line.startswith("pager ");
715 if not seen_editor:
716 seen_editor = line.startswith("editor ");
717 else:
718 print("Use the 'save' command to save your login sequence to an init file.")
719
720 if not seen_pager:
721 # prepend
722 c.cmdqueue.insert(0, "pager %s" % get_pager())
723 if not seen_editor:
724 # prepend
725 c.cmdqueue.insert(0, "editor %s" % get_editor())
726
727 # Endless interpret loop
728 while True:
729 try:
730 c.cmdloop()
731 except KeyboardInterrupt:
732 print("")
733
734 if __name__ == '__main__':
735 main()