Remove the comment from the post after undo
[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",
6328099f 28 "p": "print",
1badc74e 29 "c": "comments",
bb8316e5
AS
30 "r": "reload",
31 "n": "notifications",
ebc699fd 32 "e": "edit",
149bc23e
AS
33}
34
1882b5bc
AS
35_RC_PATHS = (
36 "~/.config/jan-pona-mute/login",
ebc699fd 37 "~/.jan-pona-mute.d/login"
1882b5bc
AS
38 "~/.jan-pona-mute"
39)
40
ebc699fd
AS
41_NOTE_DIRS = (
42 "~/.config/jan-pona-mute/notes",
43 "~/.jan-pona-mute.d/notes"
44)
45
6328099f 46_PAGERS = (
ebc699fd 47 os.getenv("PAGER"),
6328099f
AS
48 "mdcat",
49 "fold",
50 "cat"
51)
52
ebc699fd
AS
53_EDITORS = (
54 os.getenv("EDITOR"),
55 "vi",
56 "ed"
57)
58
149bc23e 59def get_rcfile():
ebc699fd
AS
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
149bc23e
AS
65 return None
66
ebc699fd
AS
67def 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
81def 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
6328099f 88def get_pager():
ebc699fd
AS
89 """Pager finder"""
90 return get_binary(_PAGERS)
91
92def get_editor():
93 """Editor finder"""
94 return get_binary(_EDITORS)
6328099f 95
149bc23e
AS
96class DiasporaClient(cmd.Cmd):
97
98 prompt = "\x1b[38;5;255m" + "> " + "\x1b[0m"
6328099f 99 intro = "Welcome to Diaspora! Use the intro command for a quick introduction."
149bc23e 100
8bf63019
AS
101 header_format = "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
102
149bc23e
AS
103 username = None
104 pod = None
105 password = None
6328099f 106 pager = None
ebc699fd 107 editor = None
149bc23e
AS
108
109 connection = None
18e74209 110 notifications = []
1882b5bc 111 index = None
18e74209 112 post = None
bb8316e5 113 post_cache = {} # key is self.post.uid, and notification.id
f1bfb7bc 114
714a2f67
AS
115 undo = []
116
149bc23e
AS
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
6328099f 129 def do_intro(self, line):
d87d6adf 130 """Start here."""
6328099f 131 print("""
bb8316e5
AS
132Use the 'account' and 'password' commands to set up your connection,
133then use the 'login' command to log in. If everything works as
134intended, use the 'save' command to save these commands to an init
135file.
6328099f
AS
136
137Once you've listed things such as notifications, enter a number to
bb8316e5 138select the corresponding item.
6328099f
AS
139""")
140
149bc23e
AS
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('@')
1882b5bc 145 print("Username and pod set: %s@%s" % (self.username, self.pod))
149bc23e
AS
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:")
1882b5bc
AS
153 print("Username: %s" % self.username)
154 print("Password: %s" % ("None" if self.password == None else "set"))
155 print("Pod: %s" % self.pod)
6328099f 156 print("Pager: %s" % self.pager)
ebc699fd 157 print("Editor: %s" % self.editor)
149bc23e
AS
158
159 def do_password(self, password):
160 """Set the password."""
161 self.password = (None if self.password == "" else password)
1882b5bc
AS
162 print("Password %s" % ("unset" if self.password == "" else "set"))
163
164 def do_save(self, line):
d87d6adf 165 """Save your login information to the init file."""
1882b5bc 166 if self.username == None or self.pod == None:
ebc699fd 167 print("Use the 'account' command to set username and pod.")
1882b5bc 168 elif self.password == None:
ebc699fd 169 print("Use the 'password' command.")
1882b5bc
AS
170 else:
171 rcfile = get_rcfile()
172 if rcfile == None:
ebc699fd 173 rfile = _RC_PATHS[0]
1882b5bc
AS
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)
149bc23e
AS
222
223 def do_login(self, line):
224 """Login."""
225 if line != "":
1882b5bc
AS
226 self.onecmd("account %s" % line)
227 if self.username == None or self.pod == None:
ebc699fd 228 print("Use the 'account' command to set username and pod.")
1882b5bc 229 elif self.password == None:
ebc699fd 230 print("Use the 'password' command.")
1882b5bc
AS
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:
ebc699fd 238 print("Login failed.")
149bc23e 239
6328099f
AS
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
ebc699fd
AS
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
8bf63019
AS
250 def header(self, line):
251 """Wrap line in header format."""
252 return self.header_format % line
253
149bc23e 254 def do_notifications(self, line):
bb8316e5
AS
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()
ebc699fd
AS
264 else:
265 print("The 'notifications' command only takes the optional argument 'reload'.")
266 return
bb8316e5
AS
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. 😢")
149bc23e
AS
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
bb8316e5
AS
291 # Finally, see if it's a notification and show it
292 self.do_show(line)
1882b5bc 293
bb8316e5 294 def do_show(self, line):
18e74209
AS
295 """Show the post given by the index number.
296The index number must refer to the current list of notifications."""
bb8316e5
AS
297 if not self.notifications:
298 print("No notifications were loaded.")
299 return
300 if line == "":
ebc699fd 301 print("The 'show' command takes a notification number.")
bb8316e5 302 return
1882b5bc 303 try:
bb8316e5 304 n = int(line.strip())
18e74209
AS
305 notification = self.notifications[n-1]
306 self.index = n
bb8316e5
AS
307 except ValueError:
308 print("The 'show' command takes a notification number but '%s' is not a number" % line)
309 return
1882b5bc 310 except IndexError:
6328099f 311 print("Index too high!")
1882b5bc
AS
312 return
313
18e74209 314 self.show(notification)
bb8316e5 315 self.load(notification.about())
18e74209
AS
316
317 print()
318 self.show(self.post)
1882b5bc 319
bb8316e5
AS
320 if(self.post.comments):
321 print()
88c1a828
AS
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))
bb8316e5
AS
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),
330or get it from the cache."""
331 if id in self.post_cache:
332 self.post = self.post_cache[id]
ebc699fd 333 print("Retrieved post from the cache.")
bb8316e5
AS
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
1882b5bc
AS
349 def show(self, item):
350 """Show the current item."""
6328099f 351 if self.pager:
88c1a828 352 subprocess.run(self.pager, input = str(item), text = True)
6328099f 353 else:
88c1a828 354 print(str(item))
6328099f 355
18e74209 356 def do_comments(self, line):
bb8316e5
AS
357 """Show the comments for the current post.
358Use the 'all' argument to show them all. Use a numerical argument to
359show that many. The default is to load the last five."""
18e74209 360 if self.post == None:
bb8316e5 361 print("Use the 'show' command to show a post, first.")
18e74209
AS
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 != "":
6328099f
AS
373 try:
374 n = int(line.strip())
375 except ValueError:
ebc699fd
AS
376 print("The 'comments' command takes a number as its argument, or 'all'.")
377 print("The default is to show the last 5 comments.")
6328099f 378 return
6328099f 379
ebc699fd
AS
380 if n == None:
381 start = 0
382 else:
383 # n is from the back
384 start = max(len(comments) - n, 0)
18e74209 385
42716c49 386 if comments:
ebc699fd 387 for n, comment in enumerate(comments[start:], start):
42716c49
AS
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.")
1882b5bc 394
714a2f67 395 def do_comment(self, line):
ebc699fd
AS
396 """Leave a comment on the current post.
397If you just use a number as your comment, it will refer to a note.
398Use the 'edit' command to edit notes."""
714a2f67 399 if self.post == None:
bb8316e5 400 print("Use the 'show' command to show a post, first.")
714a2f67 401 return
ebc699fd 402 try:
9200e1e1 403 # if the comment is just a number, use a note to post
ebc699fd
AS
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:
9200e1e1 409 line = fp.read()
ebc699fd
AS
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:
9200e1e1
AS
418 # in which case we'll simply comment with the line
419 pass
714a2f67
AS
420 comment = self.post.comment(line)
421 self.post.comments.add(comment)
ebc699fd 422 self.undo.append("delete comment %s from %s" % (comment.id, self.post.id))
714a2f67
AS
423 print("Comment posted.")
424
f1bfb7bc
AS
425 def do_delete(self, line):
426 """Delete a comment."""
427 words = line.strip().split()
428 if words:
429 if words[0] == "comment":
ebc699fd 430 if len(words) == 4:
b5d7f34e
AS
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)
ebc699fd
AS
435 print("Comment deleted.")
436 return
b5d7f34e
AS
437 if self.post == None:
438 print("Use the 'show' command to show a post, first.")
439 return
ebc699fd
AS
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.")
bb8316e5 460 print("delete comment <comment id> from <post id>")
ebc699fd
AS
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.")
f1bfb7bc 471 return
ebc699fd
AS
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.")
f1bfb7bc 481 else:
ebc699fd 482 print("Things to delete: comment, note.")
f1bfb7bc
AS
483 return
484 else:
485 print("Delete what?")
486
487 def do_undo(self, line):
488 """Undo an action."""
489 if line != "":
ebc699fd 490 print("The 'undo' command does not take an argument.")
f1bfb7bc
AS
491 return
492 if not self.undo:
493 print("There is nothing to undo.")
494 return
495 return self.onecmd(self.undo.pop())
496
ebc699fd
AS
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])
cc59946e 505 self.onecmd("notes")
ebc699fd
AS
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
149bc23e
AS
531# Main function
532def main():
533
534 # Parse args
535 parser = argparse.ArgumentParser(description='A command line Diaspora client.')
1882b5bc
AS
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')
149bc23e
AS
538 args = parser.parse_args()
539
540 # Instantiate client
1882b5bc
AS
541 c = DiasporaClient()
542
543 # Process init file
6328099f 544 seen_pager = False
ebc699fd 545 seen_editor = False
1882b5bc
AS
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()
6328099f
AS
553 if line != "":
554 c.cmdqueue.append(line)
555 if not seen_pager:
556 seen_pager = line.startswith("pager ");
ebc699fd
AS
557 if not seen_editor:
558 seen_editor = line.startswith("editor ");
1882b5bc 559 else:
ebc699fd 560 print("Use the 'save' command to save your login sequence to an init file.")
149bc23e 561
6328099f
AS
562 if not seen_pager:
563 # prepend
564 c.cmdqueue.insert(0, "pager %s" % get_pager())
ebc699fd
AS
565 if not seen_editor:
566 # prepend
567 c.cmdqueue.insert(0, "editor %s" % get_editor())
6328099f 568
149bc23e
AS
569 # Endless interpret loop
570 while True:
571 try:
572 c.cmdloop()
573 except KeyboardInterrupt:
574 print("")
575
576if __name__ == '__main__':
577 main()