Remove the comment from the post after undo
[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 len(words) == 4:
431 post = self.post_cache[words[3]]
432 post.delete_comment(words[1])
433 comments = [c.id for c in post.comments if c.id != id]
434 post.comments = diaspy.models.Comments(comments)
435 print("Comment deleted.")
436 return
437 if self.post == None:
438 print("Use the 'show' command to show a post, first.")
439 return
440 if len(words) == 2:
441 try:
442 n = int(words[1])
443 comment = self.post.comments[n-1]
444 id = comment.id
445 except ValueError:
446 print("Deleting a comment requires an integer.")
447 return
448 except IndexError:
449 print("Use the 'comments' command to find valid comment numbers.")
450 return
451 # not catching any errors from diaspy
452 self.post.delete_comment(id)
453 # there is no self.post.comments.remove(id)
454 comments = [c.id for c in self.post.comments if c.id != id]
455 self.post.comments = diaspy.models.Comments(comments)
456 print("Comment deleted.")
457 return
458 else:
459 print("Deleting a comment requires a comment id and a post id, or a number.")
460 print("delete comment <comment id> from <post id>")
461 print("delete comment 5")
462 return
463 if words[0] == "note":
464 if len(words) != 2:
465 print("Deleting a note requires a number.")
466 return
467 try:
468 n = int(words[1])
469 except ValueError:
470 print("Deleting a note requires an integer.")
471 return
472 notes = self.get_notes()
473 if notes:
474 try:
475 os.unlink(self.get_note_path(notes[n-1]))
476 print("Deleted note #%d: %s" % (n, notes[n-1]))
477 except IndexError:
478 print("Use the 'list notes' command to list valid numbers.")
479 else:
480 print("There are no notes to delete.")
481 else:
482 print("Things to delete: comment, note.")
483 return
484 else:
485 print("Delete what?")
486
487 def do_undo(self, line):
488 """Undo an action."""
489 if line != "":
490 print("The 'undo' command does not take an argument.")
491 return
492 if not self.undo:
493 print("There is nothing to undo.")
494 return
495 return self.onecmd(self.undo.pop())
496
497 def do_edit(self, line):
498 """Edit a note with a given name."""
499 if line == "":
500 print("Edit takes the name of a note as an argument.")
501 return
502 file = self.get_note_path(line)
503 if self.editor:
504 subprocess.run([self.editor, file])
505 self.onecmd("notes")
506 else:
507 print("Use the 'editor' command to set an editor.")
508
509 def do_notes(self, line):
510 """List notes"""
511 if line != "":
512 print("The 'notes' command does not take an argument.")
513 return
514 notes = self.get_notes()
515 if notes:
516 for n, note in enumerate(notes):
517 print(self.header("%2d. %s") % (n+1, note))
518 else:
519 print("Use 'edit' to create a note.")
520 else:
521 print("Things to list: notes.")
522
523 def get_notes(self):
524 """Get the list of notes."""
525 return os.listdir(get_notes_dir())
526
527 def get_note_path(self, filename):
528 """Get the correct path for a note."""
529 return os.path.join(get_notes_dir(), filename)
530
531 # Main function
532 def main():
533
534 # Parse args
535 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
536 parser.add_argument('--no-init-file', dest='init_file', action='store_const',
537 const=False, default=True, help='Do not load a init file')
538 args = parser.parse_args()
539
540 # Instantiate client
541 c = DiasporaClient()
542
543 # Process init file
544 seen_pager = False
545 seen_editor = False
546 if args.init_file:
547 rcfile = get_rcfile()
548 if rcfile:
549 print("Using init file %s" % rcfile)
550 with open(rcfile, "r") as fp:
551 for line in fp:
552 line = line.strip()
553 if line != "":
554 c.cmdqueue.append(line)
555 if not seen_pager:
556 seen_pager = line.startswith("pager ");
557 if not seen_editor:
558 seen_editor = line.startswith("editor ");
559 else:
560 print("Use the 'save' command to save your login sequence to an init file.")
561
562 if not seen_pager:
563 # prepend
564 c.cmdqueue.insert(0, "pager %s" % get_pager())
565 if not seen_editor:
566 # prepend
567 c.cmdqueue.insert(0, "editor %s" % get_editor())
568
569 # Endless interpret loop
570 while True:
571 try:
572 c.cmdloop()
573 except KeyboardInterrupt:
574 print("")
575
576 if __name__ == '__main__':
577 main()