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