81236f1b70830b10d3ba8f1456d82f879952c392
[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 'notifications reload' 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 posts = sorted(self.home, key=lambda x: x.data()["created_at"])
322 self.post = posts[n-1]
323 else:
324 print("Internal error: not sure what numbers '%s' refer to." % self.numbers_refer_to)
325 return
326 except ValueError:
327 print("The 'show' command takes a notification number but '%s' is not a number" % line)
328 return
329 except IndexError:
330 print("Index too high!")
331 return
332
333 print()
334 print(self.header("%2d. %s %s") % (n, self.post.data()["created_at"], self.post.author()))
335 print()
336 self.show(self.post)
337 print()
338
339 if(self.post.comments):
340 print("%d comment%s" % (len(self.post.comments), "s" if len(self.post.comments) != 1 else ""))
341 print("Use the 'comments' command to list the latest comments.")
342 print("Use the 'comment' command to leave a comment.")
343
344 def load(self, id):
345 """Load the post belonging to the id (from a notification),
346 or get it from the cache."""
347 if id in self.post_cache:
348 self.post = self.post_cache[id]
349 print("Retrieved post from the cache.")
350 else:
351 print("Loading...")
352 self.post = diaspy.models.Post(connection = self.connection, id = id)
353 self.post_cache[id] = self.post
354 return self.post
355
356 def do_reload(self, line):
357 """Reload the current post."""
358 if self.post == None:
359 print("Use the 'show' command to show a post, first.")
360 return
361 print("Reloading...")
362 self.post = diaspy.models.Post(connection = self.connection, id = self.post.id)
363 self.post_cache[id] = self.post
364
365 def show(self, item):
366 """Show the current item."""
367 if self.pager:
368 subprocess.run(self.pager, input = str(item), text = True)
369 else:
370 print(str(item))
371
372 def do_comments(self, line):
373 """Show the comments for the current post.
374 Use the 'all' argument to show them all. Use a numerical argument to
375 show that many. The default is to load the last five."""
376 if self.post == None:
377 print("Use the 'show' command to show a post, first.")
378 return
379 if self.post.comments == None:
380 print("The current post has no comments.")
381 return
382
383 n = 5
384 comments = self.post.comments
385
386 if line == "all":
387 n = None
388 elif line != "":
389 try:
390 n = int(line.strip())
391 except ValueError:
392 print("The 'comments' command takes a number as its argument, or 'all'.")
393 print("The default is to show the last 5 comments.")
394 return
395
396 if n == None:
397 start = 0
398 else:
399 # n is from the back
400 start = max(len(comments) - n, 0)
401
402 if comments:
403 for n, comment in enumerate(comments[start:], start):
404 print()
405 print(self.header("%2d. %s %s") % (n+1, comment.when(), comment.author()))
406 print()
407 self.show(comment)
408 print()
409 else:
410 print("There are no comments on the selected post.")
411 print("Use the 'comment' command to post a comment.")
412
413 def do_comment(self, line):
414 """Leave a comment on the current post.
415 If you just use a number as your comment, it will refer to a note.
416 Use the 'edit' command to edit notes."""
417 if self.post == None:
418 print("Use the 'show' command to show a post, first.")
419 return
420 try:
421 # if the comment is just a number, use a note to post
422 n = int(line.strip())
423 notes = self.get_notes()
424 if notes:
425 try:
426 line = self.read_note(notes[n-1])
427 print("Using note #%d: %s" % (n, notes[n-1]))
428 except IndexError:
429 print("Use the 'list notes' command to list valid numbers.")
430 return
431 else:
432 print("There are no notes to use.")
433 return
434 except ValueError:
435 # in which case we'll simply comment with the line
436 pass
437 comment = self.post.comment(line)
438 self.post.comments.add(comment)
439 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
440 print("Comment posted.")
441
442 def do_post(self, line):
443 """Write a post on the current stream.
444 If you just use a number as your post, it will refer to a note.
445 Use the 'edit' command to edit notes."""
446 if self.home == None:
447 self.home = diaspy.streams.Stream(self.connection)
448 try:
449 # if the post is just a number, use a note to post
450 n = int(line.strip())
451 notes = self.get_notes()
452 if notes:
453 try:
454 line = self.read_note(notes[n-1])
455 print("Using note #%d: %s" % (n, notes[n-1]))
456 except IndexError:
457 print("Use the 'list notes' command to list valid numbers.")
458 return
459 else:
460 print("There are no notes to use.")
461 return
462 except ValueError:
463 # in which case we'll simply post the line
464 pass
465 self.post = self.home.post(text = line)
466 self.post_cache[self.post.id] = self.post
467 self.undo.append("delete post %s" % self.post.id)
468 print("Posted. Use the 'show' command to show it.")
469
470 def do_delete(self, line):
471 """Delete a comment."""
472 words = line.strip().split()
473 if words:
474 if words[0] == "post":
475 if len(words) > 1:
476 print("Deleting a post takes no argument. It always deletes the selected post.")
477 return
478 if not self.post:
479 print("Use the 'show' command to select a post.")
480 return
481 if self.home and self.post in self.home:
482 self.home._stream.remove(self.post)
483 if self.post.id in self.post_cache:
484 self.post_cache.pop(self.post.id)
485 self.post.delete()
486 print("Post deleted.")
487 return
488 if words[0] == "comment":
489 if len(words) == 4:
490 post = self.post_cache[words[3]]
491 post.delete_comment(words[1])
492 comments = [c.id for c in post.comments if c.id != id]
493 post.comments = diaspy.models.Comments(comments)
494 print("Comment deleted.")
495 return
496 if self.post == None:
497 print("Use the 'show' command to show a post, first.")
498 return
499 if len(words) == 2:
500 try:
501 n = int(words[1])
502 comment = self.post.comments[n-1]
503 id = comment.id
504 except ValueError:
505 print("Deleting a comment requires an integer.")
506 return
507 except IndexError:
508 print("Use the 'comments' command to find valid comment numbers.")
509 return
510 # not catching any errors from diaspy
511 self.post.delete_comment(id)
512 # there is no self.post.comments.remove(id)
513 comments = [c.id for c in self.post.comments if c.id != id]
514 self.post.comments = diaspy.models.Comments(comments)
515 print("Comment deleted.")
516 return
517 else:
518 print("Deleting a comment requires a comment id and a post id, or a number.")
519 print("delete comment <comment id> from <post id>")
520 print("delete comment 5")
521 return
522 if words[0] == "note":
523 if len(words) != 2:
524 print("Deleting a note requires a number.")
525 return
526 try:
527 n = int(words[1])
528 except ValueError:
529 print("Deleting a note requires an integer.")
530 return
531 notes = self.get_notes()
532 if notes:
533 try:
534 os.unlink(self.get_note_path(notes[n-1]))
535 print("Deleted note #%d: %s" % (n, notes[n-1]))
536 except IndexError:
537 print("Use the 'list notes' command to list valid numbers.")
538 else:
539 print("There are no notes to delete.")
540 else:
541 print("Things to delete: post, comment, note.")
542 return
543 else:
544 print("Delete what?")
545
546 def do_undo(self, line):
547 """Undo an action."""
548 if line != "":
549 print("The 'undo' command does not take an argument.")
550 return
551 if not self.undo:
552 print("There is nothing to undo.")
553 return
554 return self.onecmd(self.undo.pop())
555
556 def do_edit(self, line):
557 """Edit a note with a given name."""
558 if line == "":
559 print("Edit takes the name of a note as an argument.")
560 return
561 file = self.get_note_path(line)
562 if self.editor:
563 subprocess.run([self.editor, file])
564 self.onecmd("notes")
565 else:
566 print("Use the 'editor' command to set an editor.")
567
568 def do_notes(self, line):
569 """List notes"""
570 if line != "":
571 print("The 'notes' command does not take an argument.")
572 return
573 notes = self.get_notes()
574 if notes:
575 for n, note in enumerate(notes):
576 print(self.header("%2d. %s") % (n+1, note))
577 print("Use the 'edit' command to edit a note.")
578 print("Use the 'preview' command to look at a note.")
579 print("Use the 'post' command to post a note.")
580 print("Use the 'comment' command to post a comment.")
581 else:
582 print("Use 'edit' to create a note.")
583
584 def get_notes(self):
585 """Get the list of notes."""
586 return os.listdir(get_notes_dir())
587
588 def get_note_path(self, filename):
589 """Get the correct path for a note."""
590 return os.path.join(get_notes_dir(), filename)
591
592 def read_note(self, filename):
593 """Get text of a note."""
594 with open(self.get_note_path(filename), mode = 'r', encoding = 'utf-8') as fp:
595 return fp.read()
596
597 def do_preview(self, line):
598 """Preview a note using your pager.
599 Use the 'pager' command to set your pager to something like 'mdcat'."""
600 if line == "":
601 print("The 'preview' command the number of a note as an argument.")
602 print("Use the 'notes' command to list all your notes.")
603 return
604 try:
605 n = int(line.strip())
606 notes = self.get_notes()
607 if notes:
608 try:
609 self.show(self.read_note(notes[n-1]))
610 except IndexError:
611 print("Use the 'list notes' command to list valid numbers.")
612 return
613 else:
614 print("There are no notes to preview.")
615 return
616 except ValueError:
617 print("The 'preview' command takes a number as its argument.")
618 return
619
620 def do_home(self, line):
621 """Show the main stream containing the combined posts of the
622 followed users and tags and the community spotlights posts if
623 the user enabled those."""
624 if line == "":
625 if self.home:
626 print("Redisplaying the cached statuses of the home stream.")
627 print("Use the 'reload' argument to reload them.")
628 print("Use the 'all' argument to show them all.")
629 print("Use a number to show only that many.")
630 print("The default is 5.")
631 else:
632 print("Loading...")
633 self.home = diaspy.streams.Stream(self.connection)
634 self.home.fill()
635 for post in self.home:
636 if post.id not in self.post_cache:
637 self.post_cache[post.id] = post
638 elif line == "reload":
639 if self.connection == None:
640 print("Use the 'login' command, first.")
641 return
642 if self.home:
643 print("Reloading...")
644 self.home.update()
645 line = ""
646 else:
647 self.home = diaspy.streams.Stream(self.connection)
648 self.home.fill()
649
650 n = 5
651 posts = sorted(self.home, key=lambda x: x.data()["created_at"])
652
653 if line == "all":
654 n = None
655 elif line != "":
656 try:
657 n = int(line.strip())
658 except ValueError:
659 print("The 'home' command takes a number as its argument, or 'reload' or 'all'.")
660 print("The default is to show the last 5 posts.")
661 return
662
663 if n == None:
664 start = 0
665 else:
666 # n is from the back
667 start = max(len(posts) - n, 0)
668
669 if posts:
670 for n, post in enumerate(posts[start:], start):
671 print()
672 print(self.header("%2d. %s %s") % (n+1, post.data()["created_at"], post.author()))
673 print()
674 self.show(post)
675 print()
676 print("%d comment%s" % (len(post.comments), "s" if len(post.comments) != 1 else ""))
677
678 print()
679 print("Enter a number to select the post.")
680 self.numbers_refer_to = 'home'
681 else:
682 print("The people you follow have nothing to say.")
683 print("The tags you follow are empty. 😢")
684
685 def do_shortcuts(self, line):
686 """List all shortcuts."""
687 if line != "":
688 print("The 'shortcuts' command does not take an argument.")
689 return
690 print(self.header("Shortcuts"))
691 for shortcut in sorted(shortcuts):
692 print("%s\t%s" % (shortcut, shortcuts[shortcut]))
693 print("Use the 'shortcut' command to change or add shortcuts.")
694
695 def do_shortcut(self, line):
696 """Define a new shortcut."""
697 words = line.strip().split(maxsplit = 1)
698 if len(words) == 0:
699 return self.onecmd("shortcuts")
700 elif len(words) == 1:
701 shortcut = words[0]
702 if shortcut in shortcuts:
703 print("%s\t%s" % (shortcut, shortcuts[shortcut]))
704 else:
705 print("%s is not a shortcut" % shortcut)
706 else:
707 shortcuts[words[0]] = words[1]
708
709 # Main function
710 def main():
711
712 # Parse args
713 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
714 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
715 const=False, default=True, help='Do not load a init file')
716 args = parser.parse_args()
717
718 # Instantiate client
719 c = DiasporaClient()
720
721 # Process init file
722 seen_pager = False
723 seen_editor = False
724 if args.init_file:
725 rcfile = get_rcfile()
726 if rcfile:
727 print("Using init file %s" % rcfile)
728 with open(rcfile, "r") as fp:
729 for line in fp:
730 line = line.strip()
731 if line != "":
732 c.cmdqueue.append(line)
733 if not seen_pager:
734 seen_pager = line.startswith("pager ");
735 if not seen_editor:
736 seen_editor = line.startswith("editor ");
737 else:
738 print("Use the 'save' command to save your login sequence to an init file.")
739
740 if not seen_pager:
741 # prepend
742 c.cmdqueue.insert(0, "pager %s" % get_pager())
743 if not seen_editor:
744 # prepend
745 c.cmdqueue.insert(0, "editor %s" % get_editor())
746
747 # Endless interpret loop
748 while True:
749 try:
750 c.cmdloop()
751 except KeyboardInterrupt:
752 print("")
753
754 if __name__ == '__main__':
755 main()