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