Add home command
[jan-pona-mute.git] / jan-pona-mute.py
... / ...
CommitLineData
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
17import diaspy
18import subprocess
19import argparse
20import shutil
21import cmd
22import sys
23import os
24
25# Command abbreviations
26_ABBREVS = {
27 "q": "quit",
28 "p": "preview",
29 "c": "comments",
30 "r": "reload",
31 "n": "notifications",
32 "e": "edit",
33 "d": "delete",
34}
35
36_RC_PATHS = (
37 "~/.config/jan-pona-mute/login",
38 "~/.jan-pona-mute.d/login"
39 "~/.jan-pona-mute"
40)
41
42_NOTE_DIRS = (
43 "~/.config/jan-pona-mute/notes",
44 "~/.jan-pona-mute.d/notes"
45)
46
47_PAGERS = (
48 os.getenv("PAGER"),
49 "mdcat",
50 "fold",
51 "cat"
52)
53
54_EDITORS = (
55 os.getenv("EDITOR"),
56 "vi",
57 "ed"
58)
59
60def 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
68def 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
82def 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
89def get_pager():
90 """Pager finder"""
91 return get_binary(_PAGERS)
92
93def get_editor():
94 """Editor finder"""
95 return get_binary(_EDITORS)
96
97class 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 = []
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("""
134Use the 'account' and 'password' commands to set up your connection,
135then use the 'login' command to log in. If everything works as
136intended, use the 'save' command to save these commands to an init
137file.
138
139Once you've listed things such as notifications or the home stream,
140enter 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("Info about yourself:")
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 self.connection = diaspy.connection.Connection(
236 pod = "https://%s" % self.pod, username = self.username, password = self.password)
237 try:
238 self.connection.login()
239 self.onecmd("notifications")
240 except diaspy.errors.LoginError:
241 print("Login failed.")
242
243 def do_pager(self, pager):
244 """Set the pager, e.g. to cat"""
245 self.pager = pager
246 print("Pager set: %s" % self.pager)
247
248 def do_editor(self, editor):
249 """Set the editor, e.g. to ed"""
250 self.editor = editor
251 print("Editor set: %s" % self.editor)
252
253 def header(self, line):
254 """Wrap line in header format."""
255 return self.header_format % line
256
257 def do_notifications(self, line):
258 """List notifications. Use 'notifications reload' to reload them."""
259 if line == "" and self.notifications:
260 print("Redisplaying the notifications in the cache.")
261 print("Use the 'reload' argument to reload them.")
262 elif line == "reload" or not self.notifications:
263 if self.connection == None:
264 print("Use the 'login' command, first.")
265 return
266 self.notifications = diaspy.notifications.Notifications(self.connection).last()
267 else:
268 print("The 'notifications' command only takes the optional argument 'reload'.")
269 return
270 if self.notifications:
271 for n, notification in enumerate(self.notifications):
272 print(self.header("%2d. %s %s") % (n+1, notification.when(), notification))
273 print("Enter a number to select the notification.")
274 self.numbers_refer_to = 'notifications'
275 else:
276 print("There are no notifications. 😢")
277
278 ### The end!
279 def do_quit(self, *args):
280 """Exit jan-pona-mute."""
281 print("Be safe!")
282 sys.exit()
283
284 def default(self, line):
285 if line.strip() == "EOF":
286 return self.onecmd("quit")
287
288 # Expand abbreviated commands
289 first_word = line.split()[0].strip()
290 if first_word in _ABBREVS:
291 full_cmd = _ABBREVS[first_word]
292 expanded = line.replace(first_word, full_cmd, 1)
293 return self.onecmd(expanded)
294
295 # Finally, see if it's a notification and show it
296 self.do_show(line)
297
298 def do_show(self, line):
299 """Show the post given by the index number.
300The index number must refer to the current list of notifications
301or the home stream."""
302 if not self.notifications and not self.home:
303 print("Use the 'login' command to load notifications.")
304 return
305 if line == "":
306 print("Please specify a number.")
307 return
308 try:
309 n = int(line.strip())
310 if self.numbers_refer_to == 'notifications':
311 notification = self.notifications[n-1]
312 self.show(notification)
313 self.load(notification.about())
314 elif self.numbers_refer_to == 'home':
315 self.post = self.home[n-1]
316 else:
317 print("Internal error: not sure what numbers '%s' refer to." % self.numbers_refer_to)
318 return
319 except ValueError:
320 print("The 'show' command takes a notification number but '%s' is not a number" % line)
321 return
322 except IndexError:
323 print("Index too high!")
324 return
325
326 print()
327 self.show(self.post)
328
329 if(self.post.comments):
330 print()
331 if len(self.post.comments) == 1:
332 print("There is 1 comment.")
333 else:
334 print("There are %d comments." % len(self.post.comments))
335 print("Use the 'comments' command to list the latest comments.")
336
337 def load(self, id):
338 """Load the post belonging to the id (from a notification),
339or get it from the cache."""
340 if id in self.post_cache:
341 self.post = self.post_cache[id]
342 print("Retrieved post from the cache.")
343 else:
344 print("Loading...")
345 self.post = diaspy.models.Post(connection = self.connection, id = id)
346 self.post_cache[id] = self.post
347 return self.post
348
349 def do_reload(self, line):
350 """Reload the current post."""
351 if self.post == None:
352 print("Use the 'show' command to show a post, first.")
353 return
354 print("Reloading...")
355 self.post = diaspy.models.Post(connection = self.connection, id = self.post.id)
356 self.post_cache[id] = self.post
357
358 def show(self, item):
359 """Show the current item."""
360 if self.pager:
361 subprocess.run(self.pager, input = str(item), text = True)
362 else:
363 print(str(item))
364
365 def do_comments(self, line):
366 """Show the comments for the current post.
367Use the 'all' argument to show them all. Use a numerical argument to
368show that many. The default is to load the last five."""
369 if self.post == None:
370 print("Use the 'show' command to show a post, first.")
371 return
372 if self.post.comments == None:
373 print("The current post has no comments.")
374 return
375
376 n = 5
377 comments = self.post.comments
378
379 if line == "all":
380 n = None
381 elif line != "":
382 try:
383 n = int(line.strip())
384 except ValueError:
385 print("The 'comments' command takes a number as its argument, or 'all'.")
386 print("The default is to show the last 5 comments.")
387 return
388
389 if n == None:
390 start = 0
391 else:
392 # n is from the back
393 start = max(len(comments) - n, 0)
394
395 if comments:
396 for n, comment in enumerate(comments[start:], start):
397 print()
398 print(self.header("%2d. %s %s") % (n+1, comment.when(), comment.author()))
399 print()
400 self.show(comment)
401 else:
402 print("There are no comments on the selected post.")
403
404 def do_comment(self, line):
405 """Leave a comment on the current post.
406If you just use a number as your comment, it will refer to a note.
407Use the 'edit' command to edit notes."""
408 if self.post == None:
409 print("Use the 'show' command to show a post, first.")
410 return
411 try:
412 # if the comment is just a number, use a note to post
413 n = int(line.strip())
414 notes = self.get_notes()
415 if notes:
416 try:
417 line = self.read_note(notes[n-1])
418 print("Using note #%d: %s" % (n, notes[n-1]))
419 except IndexError:
420 print("Use the 'list notes' command to list valid numbers.")
421 return
422 else:
423 print("There are no notes to use.")
424 return
425 except ValueError:
426 # in which case we'll simply comment with the line
427 pass
428 comment = self.post.comment(line)
429 self.post.comments.add(comment)
430 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
431 print("Comment posted.")
432
433 def do_delete(self, line):
434 """Delete a comment."""
435 words = line.strip().split()
436 if words:
437 if words[0] == "comment":
438 if len(words) == 4:
439 post = self.post_cache[words[3]]
440 post.delete_comment(words[1])
441 comments = [c.id for c in post.comments if c.id != id]
442 post.comments = diaspy.models.Comments(comments)
443 print("Comment deleted.")
444 return
445 if self.post == None:
446 print("Use the 'show' command to show a post, first.")
447 return
448 if len(words) == 2:
449 try:
450 n = int(words[1])
451 comment = self.post.comments[n-1]
452 id = comment.id
453 except ValueError:
454 print("Deleting a comment requires an integer.")
455 return
456 except IndexError:
457 print("Use the 'comments' command to find valid comment numbers.")
458 return
459 # not catching any errors from diaspy
460 self.post.delete_comment(id)
461 # there is no self.post.comments.remove(id)
462 comments = [c.id for c in self.post.comments if c.id != id]
463 self.post.comments = diaspy.models.Comments(comments)
464 print("Comment deleted.")
465 return
466 else:
467 print("Deleting a comment requires a comment id and a post id, or a number.")
468 print("delete comment <comment id> from <post id>")
469 print("delete comment 5")
470 return
471 if words[0] == "note":
472 if len(words) != 2:
473 print("Deleting a note requires a number.")
474 return
475 try:
476 n = int(words[1])
477 except ValueError:
478 print("Deleting a note requires an integer.")
479 return
480 notes = self.get_notes()
481 if notes:
482 try:
483 os.unlink(self.get_note_path(notes[n-1]))
484 print("Deleted note #%d: %s" % (n, notes[n-1]))
485 except IndexError:
486 print("Use the 'list notes' command to list valid numbers.")
487 else:
488 print("There are no notes to delete.")
489 else:
490 print("Things to delete: comment, note.")
491 return
492 else:
493 print("Delete what?")
494
495 def do_undo(self, line):
496 """Undo an action."""
497 if line != "":
498 print("The 'undo' command does not take an argument.")
499 return
500 if not self.undo:
501 print("There is nothing to undo.")
502 return
503 return self.onecmd(self.undo.pop())
504
505 def do_edit(self, line):
506 """Edit a note with a given name."""
507 if line == "":
508 print("Edit takes the name of a note as an argument.")
509 return
510 file = self.get_note_path(line)
511 if self.editor:
512 subprocess.run([self.editor, file])
513 self.onecmd("notes")
514 else:
515 print("Use the 'editor' command to set an editor.")
516
517 def do_notes(self, line):
518 """List notes"""
519 if line != "":
520 print("The 'notes' command does not take an argument.")
521 return
522 notes = self.get_notes()
523 if notes:
524 for n, note in enumerate(notes):
525 print(self.header("%2d. %s") % (n+1, note))
526 else:
527 print("Use 'edit' to create a note.")
528 else:
529 print("Things to list: notes.")
530
531 def get_notes(self):
532 """Get the list of notes."""
533 return os.listdir(get_notes_dir())
534
535 def get_note_path(self, filename):
536 """Get the correct path for a note."""
537 return os.path.join(get_notes_dir(), filename)
538
539 def read_note(self, filename):
540 """Get text of a note."""
541 with open(self.get_note_path(filename), mode = 'r', encoding = 'utf-8') as fp:
542 return fp.read()
543
544 def do_preview(self, line):
545 """Preview a note using your pager.
546Use the 'pager' command to set your pager to something like 'mdcat'."""
547 if line == "":
548 print("The 'preview' command the number of a note as an argument.")
549 print("Use the 'notes' command to list all your notes.")
550 return
551 try:
552 n = int(line.strip())
553 notes = self.get_notes()
554 if notes:
555 try:
556 self.show(self.read_note(notes[n-1]))
557 except IndexError:
558 print("Use the 'list notes' command to list valid numbers.")
559 return
560 else:
561 print("There are no notes to preview.")
562 return
563 except ValueError:
564 print("The 'preview' command takes a number as its argument.")
565 return
566
567 def do_home(self, line):
568 """Show the main stream containing the combined posts of the
569followed users and tags and the community spotlights posts if
570the user enabled those."""
571 if line == "":
572 if self.home:
573 print("Redisplaying the cached statuses of the home stream.")
574 print("Use the 'reload' argument to reload them.")
575 print("Use the 'all' argument to show them all.")
576 print("Use a number to show only that many.")
577 print("The default is 5.")
578 else:
579 print("Loading...")
580 self.home = diaspy.streams.Stream(self.connection)
581 self.home.fill()
582 for post in self.home:
583 if post.id not in self.post_cache:
584 self.post_cache[post.id] = post
585 elif line == "reload":
586 if self.connection == None:
587 print("Use the 'login' command, first.")
588 return
589 if self.home:
590 print("Reloading...")
591 self.home.update()
592 line = ""
593 else:
594 self.home = diaspy.streams.Stream(self.connection)
595 self.home.fill()
596
597 n = 5
598 posts = self.home
599
600 if line == "all":
601 n = None
602 elif line != "":
603 try:
604 n = int(line.strip())
605 except ValueError:
606 print("The 'home' command takes a number as its argument, or 'reload' or 'all'.")
607 print("The default is to show the last 5 posts.")
608 return
609
610 if n == None:
611 start = 0
612 else:
613 # n is from the back
614 start = max(len(posts) - n, 0)
615
616 if posts:
617 for n, post in enumerate(posts[start:], start):
618 print()
619 print(self.header("%2d. %s %s") % (n+1, post.data()["created_at"], post.author()))
620 print()
621 self.show(post)
622
623 print("Enter a number to select the post.")
624 self.numbers_refer_to = 'home'
625 else:
626 print("The people you follow have nothing to say.")
627 print("The tags you follow are empty. 😢")
628
629# Main function
630def main():
631
632 # Parse args
633 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
634 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
635 const=False, default=True, help='Do not load a init file')
636 args = parser.parse_args()
637
638 # Instantiate client
639 c = DiasporaClient()
640
641 # Process init file
642 seen_pager = False
643 seen_editor = False
644 if args.init_file:
645 rcfile = get_rcfile()
646 if rcfile:
647 print("Using init file %s" % rcfile)
648 with open(rcfile, "r") as fp:
649 for line in fp:
650 line = line.strip()
651 if line != "":
652 c.cmdqueue.append(line)
653 if not seen_pager:
654 seen_pager = line.startswith("pager ");
655 if not seen_editor:
656 seen_editor = line.startswith("editor ");
657 else:
658 print("Use the 'save' command to save your login sequence to an init file.")
659
660 if not seen_pager:
661 # prepend
662 c.cmdqueue.insert(0, "pager %s" % get_pager())
663 if not seen_editor:
664 # prepend
665 c.cmdqueue.insert(0, "editor %s" % get_editor())
666
667 # Endless interpret loop
668 while True:
669 try:
670 c.cmdloop()
671 except KeyboardInterrupt:
672 print("")
673
674if __name__ == '__main__':
675 main()