Add home command
[jan-pona-mute.git] / jan-pona-mute.py
CommitLineData
1882b5bc 1#!/usr/bin/env python3
149bc23e
AS
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
6328099f 18import subprocess
149bc23e 19import argparse
6328099f 20import shutil
149bc23e
AS
21import cmd
22import sys
23import os
24
25# Command abbreviations
26_ABBREVS = {
27 "q": "quit",
f38b656c 28 "p": "preview",
1badc74e 29 "c": "comments",
bb8316e5
AS
30 "r": "reload",
31 "n": "notifications",
ebc699fd 32 "e": "edit",
f38b656c 33 "d": "delete",
149bc23e
AS
34}
35
1882b5bc
AS
36_RC_PATHS = (
37 "~/.config/jan-pona-mute/login",
ebc699fd 38 "~/.jan-pona-mute.d/login"
1882b5bc
AS
39 "~/.jan-pona-mute"
40)
41
ebc699fd
AS
42_NOTE_DIRS = (
43 "~/.config/jan-pona-mute/notes",
44 "~/.jan-pona-mute.d/notes"
45)
46
6328099f 47_PAGERS = (
ebc699fd 48 os.getenv("PAGER"),
6328099f
AS
49 "mdcat",
50 "fold",
51 "cat"
52)
53
ebc699fd
AS
54_EDITORS = (
55 os.getenv("EDITOR"),
56 "vi",
57 "ed"
58)
59
149bc23e 60def get_rcfile():
ebc699fd
AS
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
149bc23e
AS
66 return None
67
ebc699fd
AS
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
6328099f 89def get_pager():
ebc699fd
AS
90 """Pager finder"""
91 return get_binary(_PAGERS)
92
93def get_editor():
94 """Editor finder"""
95 return get_binary(_EDITORS)
6328099f 96
149bc23e
AS
97class DiasporaClient(cmd.Cmd):
98
99 prompt = "\x1b[38;5;255m" + "> " + "\x1b[0m"
6328099f 100 intro = "Welcome to Diaspora! Use the intro command for a quick introduction."
149bc23e 101
8bf63019
AS
102 header_format = "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
103
149bc23e
AS
104 username = None
105 pod = None
106 password = None
6328099f 107 pager = None
ebc699fd 108 editor = None
149bc23e
AS
109
110 connection = None
18e74209 111 notifications = []
bec46251
AS
112 home = []
113 numbers_refer_to = None
18e74209 114 post = None
bb8316e5 115 post_cache = {} # key is self.post.uid, and notification.id
f1bfb7bc 116
714a2f67
AS
117 undo = []
118
149bc23e
AS
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
6328099f 131 def do_intro(self, line):
d87d6adf 132 """Start here."""
6328099f 133 print("""
bb8316e5
AS
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.
6328099f 138
bec46251
AS
139Once you've listed things such as notifications or the home stream,
140enter a number to select the corresponding item.
6328099f
AS
141""")
142
149bc23e
AS
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('@')
1882b5bc 147 print("Username and pod set: %s@%s" % (self.username, self.pod))
149bc23e
AS
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:")
1882b5bc
AS
155 print("Username: %s" % self.username)
156 print("Password: %s" % ("None" if self.password == None else "set"))
157 print("Pod: %s" % self.pod)
6328099f 158 print("Pager: %s" % self.pager)
ebc699fd 159 print("Editor: %s" % self.editor)
bec46251 160 print("Cache: %s posts" % len(self.post_cache))
149bc23e
AS
161
162 def do_password(self, password):
163 """Set the password."""
164 self.password = (None if self.password == "" else password)
1882b5bc
AS
165 print("Password %s" % ("unset" if self.password == "" else "set"))
166
167 def do_save(self, line):
d87d6adf 168 """Save your login information to the init file."""
1882b5bc 169 if self.username == None or self.pod == None:
ebc699fd 170 print("Use the 'account' command to set username and pod.")
1882b5bc 171 elif self.password == None:
ebc699fd 172 print("Use the 'password' command.")
1882b5bc
AS
173 else:
174 rcfile = get_rcfile()
175 if rcfile == None:
ebc699fd 176 rfile = _RC_PATHS[0]
1882b5bc
AS
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)
149bc23e
AS
225
226 def do_login(self, line):
227 """Login."""
228 if line != "":
1882b5bc
AS
229 self.onecmd("account %s" % line)
230 if self.username == None or self.pod == None:
ebc699fd 231 print("Use the 'account' command to set username and pod.")
1882b5bc 232 elif self.password == None:
ebc699fd 233 print("Use the 'password' command.")
1882b5bc
AS
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:
ebc699fd 241 print("Login failed.")
149bc23e 242
6328099f
AS
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
ebc699fd
AS
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
8bf63019
AS
253 def header(self, line):
254 """Wrap line in header format."""
255 return self.header_format % line
256
149bc23e 257 def do_notifications(self, line):
bb8316e5
AS
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()
ebc699fd
AS
267 else:
268 print("The 'notifications' command only takes the optional argument 'reload'.")
269 return
bb8316e5
AS
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.")
bec46251 274 self.numbers_refer_to = 'notifications'
bb8316e5
AS
275 else:
276 print("There are no notifications. 😢")
149bc23e
AS
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
bb8316e5
AS
295 # Finally, see if it's a notification and show it
296 self.do_show(line)
1882b5bc 297
bb8316e5 298 def do_show(self, line):
18e74209 299 """Show the post given by the index number.
bec46251
AS
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.")
bb8316e5
AS
304 return
305 if line == "":
bec46251 306 print("Please specify a number.")
bb8316e5 307 return
1882b5bc 308 try:
bb8316e5 309 n = int(line.strip())
bec46251
AS
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
bb8316e5
AS
319 except ValueError:
320 print("The 'show' command takes a notification number but '%s' is not a number" % line)
321 return
1882b5bc 322 except IndexError:
6328099f 323 print("Index too high!")
1882b5bc
AS
324 return
325
18e74209
AS
326 print()
327 self.show(self.post)
1882b5bc 328
bb8316e5
AS
329 if(self.post.comments):
330 print()
88c1a828
AS
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))
bb8316e5
AS
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]
ebc699fd 342 print("Retrieved post from the cache.")
bb8316e5
AS
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
1882b5bc
AS
358 def show(self, item):
359 """Show the current item."""
6328099f 360 if self.pager:
88c1a828 361 subprocess.run(self.pager, input = str(item), text = True)
6328099f 362 else:
88c1a828 363 print(str(item))
6328099f 364
18e74209 365 def do_comments(self, line):
bb8316e5
AS
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."""
18e74209 369 if self.post == None:
bb8316e5 370 print("Use the 'show' command to show a post, first.")
18e74209
AS
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 != "":
6328099f
AS
382 try:
383 n = int(line.strip())
384 except ValueError:
ebc699fd
AS
385 print("The 'comments' command takes a number as its argument, or 'all'.")
386 print("The default is to show the last 5 comments.")
6328099f 387 return
6328099f 388
ebc699fd
AS
389 if n == None:
390 start = 0
391 else:
392 # n is from the back
393 start = max(len(comments) - n, 0)
18e74209 394
42716c49 395 if comments:
ebc699fd 396 for n, comment in enumerate(comments[start:], start):
42716c49
AS
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.")
1882b5bc 403
714a2f67 404 def do_comment(self, line):
ebc699fd
AS
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."""
714a2f67 408 if self.post == None:
bb8316e5 409 print("Use the 'show' command to show a post, first.")
714a2f67 410 return
ebc699fd 411 try:
9200e1e1 412 # if the comment is just a number, use a note to post
ebc699fd
AS
413 n = int(line.strip())
414 notes = self.get_notes()
415 if notes:
416 try:
7e2b259d 417 line = self.read_note(notes[n-1])
ebc699fd
AS
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:
9200e1e1
AS
426 # in which case we'll simply comment with the line
427 pass
714a2f67
AS
428 comment = self.post.comment(line)
429 self.post.comments.add(comment)
ebc699fd 430 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
714a2f67
AS
431 print("Comment posted.")
432
f1bfb7bc
AS
433 def do_delete(self, line):
434 """Delete a comment."""
435 words = line.strip().split()
436 if words:
437 if words[0] == "comment":
ebc699fd 438 if len(words) == 4:
b5d7f34e
AS
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)
ebc699fd
AS
443 print("Comment deleted.")
444 return
b5d7f34e
AS
445 if self.post == None:
446 print("Use the 'show' command to show a post, first.")
447 return
ebc699fd
AS
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.")
bb8316e5 468 print("delete comment <comment id> from <post id>")
ebc699fd
AS
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.")
f1bfb7bc 479 return
ebc699fd
AS
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.")
f1bfb7bc 489 else:
ebc699fd 490 print("Things to delete: comment, note.")
f1bfb7bc
AS
491 return
492 else:
493 print("Delete what?")
494
495 def do_undo(self, line):
496 """Undo an action."""
497 if line != "":
ebc699fd 498 print("The 'undo' command does not take an argument.")
f1bfb7bc
AS
499 return
500 if not self.undo:
501 print("There is nothing to undo.")
502 return
503 return self.onecmd(self.undo.pop())
504
ebc699fd
AS
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])
cc59946e 513 self.onecmd("notes")
ebc699fd
AS
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
7e2b259d
AS
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
f38b656c
AS
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
bec46251
AS
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
149bc23e
AS
629# Main function
630def main():
631
632 # Parse args
633 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
1882b5bc
AS
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')
149bc23e
AS
636 args = parser.parse_args()
637
638 # Instantiate client
1882b5bc
AS
639 c = DiasporaClient()
640
641 # Process init file
6328099f 642 seen_pager = False
ebc699fd 643 seen_editor = False
1882b5bc
AS
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()
6328099f
AS
651 if line != "":
652 c.cmdqueue.append(line)
653 if not seen_pager:
654 seen_pager = line.startswith("pager ");
ebc699fd
AS
655 if not seen_editor:
656 seen_editor = line.startswith("editor ");
1882b5bc 657 else:
ebc699fd 658 print("Use the 'save' command to save your login sequence to an init file.")
149bc23e 659
6328099f
AS
660 if not seen_pager:
661 # prepend
662 c.cmdqueue.insert(0, "pager %s" % get_pager())
ebc699fd
AS
663 if not seen_editor:
664 # prepend
665 c.cmdqueue.insert(0, "editor %s" % get_editor())
6328099f 666
149bc23e
AS
667 # Endless interpret loop
668 while True:
669 try:
670 c.cmdloop()
671 except KeyboardInterrupt:
672 print("")
673
674if __name__ == '__main__':
675 main()