dfe13cdb92e4025c67e37213af37f4e9e4e43b03
[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 # Command abbreviations
26 _ABBREVS = {
27 "q": "quit",
28 "p": "print",
29 "c": "comments",
30 "r": "reload",
31 "n": "notifications",
32 "e": "edit",
33 }
34
35 _RC_PATHS = (
36 "~/.config/jan-pona-mute/login",
37 "~/.jan-pona-mute.d/login"
38 "~/.jan-pona-mute"
39 )
40
41 _NOTE_DIRS = (
42 "~/.config/jan-pona-mute/notes",
43 "~/.jan-pona-mute.d/notes"
44 )
45
46 _PAGERS = (
47 os.getenv("PAGER"),
48 "mdcat",
49 "fold",
50 "cat"
51 )
52
53 _EDITORS = (
54 os.getenv("EDITOR"),
55 "vi",
56 "ed"
57 )
58
59 def get_rcfile():
60 """Init file finder"""
61 for path in _RC_PATHS:
62 path = os.path.expanduser(path)
63 if os.path.exists(path):
64 return path
65 return None
66
67 def get_notes_dir():
68 """Notes directory finder"""
69 dir = None
70 for path in _NOTE_DIRS:
71 path = os.path.expanduser(path)
72 if os.path.isdir(path):
73 dir = path
74 break
75 if dir == None:
76 dir = os.path.expanduser(_NOTE_DIRS[0])
77 if not os.path.isdir(dir):
78 os.makedirs(dir)
79 return dir
80
81 def get_binary(list):
82 for cmd in list:
83 if cmd != None:
84 bin = shutil.which(cmd)
85 if bin != None:
86 return bin
87
88 def get_pager():
89 """Pager finder"""
90 return get_binary(_PAGERS)
91
92 def get_editor():
93 """Editor finder"""
94 return get_binary(_EDITORS)
95
96 class DiasporaClient(cmd.Cmd):
97
98 prompt = "\x1b[38;5;255m" + "> " + "\x1b[0m"
99 intro = "Welcome to Diaspora! Use the intro command for a quick introduction."
100
101 header_format = "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
102
103 username = None
104 pod = None
105 password = None
106 pager = None
107 editor = None
108
109 connection = None
110 notifications = []
111 index = None
112 post = None
113 post_cache = {} # key is self.post.uid, and notification.id
114
115 undo = []
116
117
118 # dict mapping user ids to usernames
119 users = {}
120
121 def get_username(self, guid):
122 if guid in self.users:
123 return self.users[guid]
124 else:
125 user = diaspy.people.User(connection = self.connection, guid = guid)
126 self.users[guid] = user.handle()
127 return self.users[guid]
128
129 def do_intro(self, line):
130 """Start here."""
131 print("""
132 Use the 'account' and 'password' commands to set up your connection,
133 then use the 'login' command to log in. If everything works as
134 intended, use the 'save' command to save these commands to an init
135 file.
136
137 Once you've listed things such as notifications, enter a number to
138 select the corresponding item.
139 """)
140
141 def do_account(self, account):
142 """Set username and pod using the format username@pod."""
143 try:
144 (self.username, self.pod) = account.split('@')
145 print("Username and pod set: %s@%s" % (self.username, self.pod))
146 except ValueError:
147 print("The account must contain an @ character, e.g. kensanata@pluspora.com.")
148 print("Use the account comand to set the account.")
149
150 def do_info(self, line):
151 """Get some info about things. By default, it is info about yourself."""
152 print("Info about yourself:")
153 print("Username: %s" % self.username)
154 print("Password: %s" % ("None" if self.password == None else "set"))
155 print("Pod: %s" % self.pod)
156 print("Pager: %s" % self.pager)
157 print("Editor: %s" % self.editor)
158
159 def do_password(self, password):
160 """Set the password."""
161 self.password = (None if self.password == "" else password)
162 print("Password %s" % ("unset" if self.password == "" else "set"))
163
164 def do_save(self, line):
165 """Save your login information to the init file."""
166 if self.username == None or self.pod == None:
167 print("Use the 'account' command to set username and pod.")
168 elif self.password == None:
169 print("Use the 'password' command.")
170 else:
171 rcfile = get_rcfile()
172 if rcfile == None:
173 rfile = _RC_PATHS[0]
174 seen_account = False
175 seen_password = False
176 seen_login = False
177 changed = False
178 file = []
179 with open(rcfile, "r") as fp:
180 for line in fp:
181 words = line.strip().split()
182 if words:
183 if words[0] == "account":
184 seen_account = True
185 account = "%s@%s" % (self.username, self.pod)
186 if len(words) > 1 and words[1] != account:
187 line = "account %s\n" % account
188 changed = True
189 elif words[0] == "password":
190 seen_password = True
191 if len(words) > 1 and words[1] != self.password:
192 line = "password %s\n" % self.password
193 changed = True
194 elif words[0] == "login":
195 if seen_account and seen_password:
196 seen_login = True
197 else:
198 # skip login if no account or no password given
199 line = None
200 changed = True
201 if line != None:
202 file.append(line)
203 if not seen_account:
204 file.append("account %s@%s\n" % (self.username, self.pod))
205 changed = True
206 if not seen_password:
207 file.append("password %s\n" % self.password)
208 changed = True
209 if not seen_login:
210 file.append("login\n")
211 changed = True
212 if changed:
213 if os.path.isfile(rcfile):
214 os.rename(rcfile, rcfile + "~")
215 if not os.path.isdir(os.path.dirname(rcfile)):
216 os.makedirs(os.path.dirname(rcfile))
217 with open(rcfile, "w") as fp:
218 fp.write("".join(file))
219 print("Wrote %s" % rcfile)
220 else:
221 print("No changes made, %s left unchanged" % rcfile)
222
223 def do_login(self, line):
224 """Login."""
225 if line != "":
226 self.onecmd("account %s" % line)
227 if self.username == None or self.pod == None:
228 print("Use the 'account' command to set username and pod.")
229 elif self.password == None:
230 print("Use the 'password' command.")
231 else:
232 self.connection = diaspy.connection.Connection(
233 pod = "https://%s" % self.pod, username = self.username, password = self.password)
234 try:
235 self.connection.login()
236 self.onecmd("notifications")
237 except diaspy.errors.LoginError:
238 print("Login failed.")
239
240 def do_pager(self, pager):
241 """Set the pager, e.g. to cat"""
242 self.pager = pager
243 print("Pager set: %s" % self.pager)
244
245 def do_editor(self, editor):
246 """Set the editor, e.g. to ed"""
247 self.editor = editor
248 print("Editor set: %s" % self.editor)
249
250 def header(self, line):
251 """Wrap line in header format."""
252 return self.header_format % line
253
254 def do_notifications(self, line):
255 """List notifications. Use 'notifications reload' to reload them."""
256 if line == "" and self.notifications:
257 print("Redisplaying the notifications in the cache.")
258 print("Use the 'reload' argument to reload them.")
259 elif line == "reload" or not self.notifications:
260 if self.connection == None:
261 print("Use the 'login' command, first.")
262 return
263 self.notifications = diaspy.notifications.Notifications(self.connection).last()
264 else:
265 print("The 'notifications' command only takes the optional argument 'reload'.")
266 return
267 if self.notifications:
268 for n, notification in enumerate(self.notifications):
269 print(self.header("%2d. %s %s") % (n+1, notification.when(), notification))
270 print("Enter a number to select the notification.")
271 else:
272 print("There are no notifications. 😢")
273
274 ### The end!
275 def do_quit(self, *args):
276 """Exit jan-pona-mute."""
277 print("Be safe!")
278 sys.exit()
279
280 def default(self, line):
281 if line.strip() == "EOF":
282 return self.onecmd("quit")
283
284 # Expand abbreviated commands
285 first_word = line.split()[0].strip()
286 if first_word in _ABBREVS:
287 full_cmd = _ABBREVS[first_word]
288 expanded = line.replace(first_word, full_cmd, 1)
289 return self.onecmd(expanded)
290
291 # Finally, see if it's a notification and show it
292 self.do_show(line)
293
294 def do_show(self, line):
295 """Show the post given by the index number.
296 The index number must refer to the current list of notifications."""
297 if not self.notifications:
298 print("No notifications were loaded.")
299 return
300 if line == "":
301 print("The 'show' command takes a notification number.")
302 return
303 try:
304 n = int(line.strip())
305 notification = self.notifications[n-1]
306 self.index = n
307 except ValueError:
308 print("The 'show' command takes a notification number but '%s' is not a number" % line)
309 return
310 except IndexError:
311 print("Index too high!")
312 return
313
314 self.show(notification)
315 self.load(notification.about())
316
317 print()
318 self.show(self.post)
319
320 if(self.post.comments):
321 print()
322 if len(self.post.comments) == 1:
323 print("There is 1 comment.")
324 else:
325 print("There are %d comments." % len(self.post.comments))
326 print("Use the 'comments' command to list the latest comments.")
327
328 def load(self, id):
329 """Load the post belonging to the id (from a notification),
330 or get it from the cache."""
331 if id in self.post_cache:
332 self.post = self.post_cache[id]
333 print("Retrieved post from the cache.")
334 else:
335 print("Loading...")
336 self.post = diaspy.models.Post(connection = self.connection, id = id)
337 self.post_cache[id] = self.post
338 return self.post
339
340 def do_reload(self, line):
341 """Reload the current post."""
342 if self.post == None:
343 print("Use the 'show' command to show a post, first.")
344 return
345 print("Reloading...")
346 self.post = diaspy.models.Post(connection = self.connection, id = self.post.id)
347 self.post_cache[id] = self.post
348
349 def show(self, item):
350 """Show the current item."""
351 if self.pager:
352 subprocess.run(self.pager, input = str(item), text = True)
353 else:
354 print(str(item))
355
356 def do_comments(self, line):
357 """Show the comments for the current post.
358 Use the 'all' argument to show them all. Use a numerical argument to
359 show that many. The default is to load the last five."""
360 if self.post == None:
361 print("Use the 'show' command to show a post, first.")
362 return
363 if self.post.comments == None:
364 print("The current post has no comments.")
365 return
366
367 n = 5
368 comments = self.post.comments
369
370 if line == "all":
371 n = None
372 elif line != "":
373 try:
374 n = int(line.strip())
375 except ValueError:
376 print("The 'comments' command takes a number as its argument, or 'all'.")
377 print("The default is to show the last 5 comments.")
378 return
379
380 if n == None:
381 start = 0
382 else:
383 # n is from the back
384 start = max(len(comments) - n, 0)
385
386 if comments:
387 for n, comment in enumerate(comments[start:], start):
388 print()
389 print(self.header("%2d. %s %s") % (n+1, comment.when(), comment.author()))
390 print()
391 self.show(comment)
392 else:
393 print("There are no comments on the selected post.")
394
395 def do_comment(self, line):
396 """Leave a comment on the current post.
397 If you just use a number as your comment, it will refer to a note.
398 Use the 'edit' command to edit notes."""
399 if self.post == None:
400 print("Use the 'show' command to show a post, first.")
401 return
402 try:
403 # if the comment is just a number, use a note to post
404 n = int(line.strip())
405 notes = self.get_notes()
406 if notes:
407 try:
408 with open(self.get_note_path(notes[n-1]), mode = 'r', encoding = 'utf-8') as fp:
409 line = fp.read()
410 print("Using note #%d: %s" % (n, notes[n-1]))
411 except IndexError:
412 print("Use the 'list notes' command to list valid numbers.")
413 return
414 else:
415 print("There are no notes to use.")
416 return
417 except ValueError:
418 # in which case we'll simply comment with the line
419 pass
420 comment = self.post.comment(line)
421 self.post.comments.add(comment)
422 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
423 print("Comment posted.")
424
425 def do_delete(self, line):
426 """Delete a comment."""
427 words = line.strip().split()
428 if words:
429 if words[0] == "comment":
430 if self.post == None:
431 print("Use the 'show' command to show a post, first.")
432 return
433 if len(words) == 4:
434 self.post_cache[words[3]].delete_comment(words[1])
435 print("Comment deleted.")
436 return
437 if len(words) == 2:
438 try:
439 n = int(words[1])
440 comment = self.post.comments[n-1]
441 id = comment.id
442 except ValueError:
443 print("Deleting a comment requires an integer.")
444 return
445 except IndexError:
446 print("Use the 'comments' command to find valid comment numbers.")
447 return
448 # not catching any errors from diaspy
449 self.post.delete_comment(id)
450 # there is no self.post.comments.remove(id)
451 comments = [c.id for c in self.post.comments if c.id != id]
452 self.post.comments = diaspy.models.Comments(comments)
453 print("Comment deleted.")
454 return
455 else:
456 print("Deleting a comment requires a comment id and a post id, or a number.")
457 print("delete comment <comment id> from <post id>")
458 print("delete comment 5")
459 return
460 if words[0] == "note":
461 if len(words) != 2:
462 print("Deleting a note requires a number.")
463 return
464 try:
465 n = int(words[1])
466 except ValueError:
467 print("Deleting a note requires an integer.")
468 return
469 notes = self.get_notes()
470 if notes:
471 try:
472 os.unlink(self.get_note_path(notes[n-1]))
473 print("Deleted note #%d: %s" % (n, notes[n-1]))
474 except IndexError:
475 print("Use the 'list notes' command to list valid numbers.")
476 else:
477 print("There are no notes to delete.")
478 else:
479 print("Things to delete: comment, note.")
480 return
481 else:
482 print("Delete what?")
483
484 def do_undo(self, line):
485 """Undo an action."""
486 if line != "":
487 print("The 'undo' command does not take an argument.")
488 return
489 if not self.undo:
490 print("There is nothing to undo.")
491 return
492 return self.onecmd(self.undo.pop())
493
494 def do_edit(self, line):
495 """Edit a note with a given name."""
496 if line == "":
497 print("Edit takes the name of a note as an argument.")
498 return
499 file = self.get_note_path(line)
500 if self.editor:
501 subprocess.run([self.editor, file])
502 self.onecmd("notes")
503 else:
504 print("Use the 'editor' command to set an editor.")
505
506 def do_notes(self, line):
507 """List notes"""
508 if line != "":
509 print("The 'notes' command does not take an argument.")
510 return
511 notes = self.get_notes()
512 if notes:
513 for n, note in enumerate(notes):
514 print(self.header("%2d. %s") % (n+1, note))
515 else:
516 print("Use 'edit' to create a note.")
517 else:
518 print("Things to list: notes.")
519
520 def get_notes(self):
521 """Get the list of notes."""
522 return os.listdir(get_notes_dir())
523
524 def get_note_path(self, filename):
525 """Get the correct path for a note."""
526 return os.path.join(get_notes_dir(), filename)
527
528 # Main function
529 def main():
530
531 # Parse args
532 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
533 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
534 const=False, default=True, help='Do not load a init file')
535 args = parser.parse_args()
536
537 # Instantiate client
538 c = DiasporaClient()
539
540 # Process init file
541 seen_pager = False
542 seen_editor = False
543 if args.init_file:
544 rcfile = get_rcfile()
545 if rcfile:
546 print("Using init file %s" % rcfile)
547 with open(rcfile, "r") as fp:
548 for line in fp:
549 line = line.strip()
550 if line != "":
551 c.cmdqueue.append(line)
552 if not seen_pager:
553 seen_pager = line.startswith("pager ");
554 if not seen_editor:
555 seen_editor = line.startswith("editor ");
556 else:
557 print("Use the 'save' command to save your login sequence to an init file.")
558
559 if not seen_pager:
560 # prepend
561 c.cmdqueue.insert(0, "pager %s" % get_pager())
562 if not seen_editor:
563 # prepend
564 c.cmdqueue.insert(0, "editor %s" % get_editor())
565
566 # Endless interpret loop
567 while True:
568 try:
569 c.cmdloop()
570 except KeyboardInterrupt:
571 print("")
572
573 if __name__ == '__main__':
574 main()