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