2 # Copyright (C) 2019 Alex Schroeder <alex@gnu.org>
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
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
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/>.
26 "~/.config/jan-pona-mute/login",
27 "~/.jan-pona-mute.d/login"
32 "~/.config/jan-pona-mute/notes",
33 "~/.jan-pona-mute.d/notes"
57 "g": "notifications reload",
61 """Init file finder"""
62 for path
in _RC_PATHS
:
63 path
= os
.path
.expanduser(path
)
64 if os
.path
.exists(path
):
69 """Notes directory finder"""
71 for path
in _NOTE_DIRS
:
72 path
= os
.path
.expanduser(path
)
73 if os
.path
.isdir(path
):
77 dir = os
.path
.expanduser(_NOTE_DIRS
[0])
78 if not os
.path
.isdir(dir):
85 bin
= shutil
.which(cmd
)
91 return get_binary(_PAGERS
)
95 return get_binary(_EDITORS
)
97 class DiasporaClient(cmd
.Cmd
):
99 prompt
= "\x1b[38;5;255m" + "> " + "\x1b[0m"
100 intro
= "Welcome to Diaspora! Use the intro command for a quick introduction."
102 header_format
= "\x1b[1;38;5;255m" + "%s" + "\x1b[0m"
113 numbers_refer_to
= None
115 post_cache
= {} # key is self.post.uid, and notification.id
120 # dict mapping user ids to usernames
123 def get_username(self
, guid
):
124 if guid
in self
.users
:
125 return self
.users
[guid
]
127 user
= diaspy
.people
.User(connection
= self
.connection
, guid
= guid
)
128 self
.users
[guid
] = user
.handle()
129 return self
.users
[guid
]
131 def do_intro(self
, line
):
134 Use the 'account' and 'password' commands to set up your connection,
135 then use the 'login' command to log in. If everything works as
136 intended, use the 'save' command to save these commands to an init
139 Once you've listed things such as notifications or the home stream,
140 enter a number to select the corresponding item.
143 def do_account(self
, account
):
144 """Set username and pod using the format username@pod."""
146 (self
.username
, self
.pod
) = account
.split('@')
147 print("Username and pod set: %s@%s" % (self
.username
, self
.pod
))
149 print("The account must contain an @ character, e.g. kensanata@pluspora.com.")
150 print("Use the account comand to set the account.")
152 def do_info(self
, line
):
153 """Get some info about things. By default, it is info about yourself."""
154 print(self
.header("Info"))
155 print("Username: %s" % self
.username
)
156 print("Password: %s" % ("None" if self
.password
== None else "set"))
157 print("Pod: %s" % self
.pod
)
158 print("Pager: %s" % self
.pager
)
159 print("Editor: %s" % self
.editor
)
160 print("Cache: %s posts" % len(self
.post_cache
))
162 def do_password(self
, password
):
163 """Set the password."""
164 self
.password
= (None if self
.password
== "" else password
)
165 print("Password %s" % ("unset" if self
.password
== "" else "set"))
167 def do_save(self
, line
):
168 """Save your login information to the init file."""
169 if self
.username
== None or self
.pod
== None:
170 print("Use the 'account' command to set username and pod.")
171 elif self
.password
== None:
172 print("Use the 'password' command.")
174 rcfile
= get_rcfile()
178 seen_password
= False
182 with
open(rcfile
, "r") as fp
:
184 words
= line
.strip().split()
186 if words
[0] == "account":
188 account
= "%s@%s" % (self
.username
, self
.pod
)
189 if len(words
) > 1 and words
[1] != account
:
190 line
= "account %s\n" % account
192 elif words
[0] == "password":
194 if len(words
) > 1 and words
[1] != self
.password
:
195 line
= "password %s\n" % self
.password
197 elif words
[0] == "login":
198 if seen_account
and seen_password
:
201 # skip login if no account or no password given
207 file.append("account %s@%s\n" % (self
.username
, self
.pod
))
209 if not seen_password
:
210 file.append("password %s\n" % self
.password
)
213 file.append("login\n")
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
)
224 print("No changes made, %s left unchanged" % rcfile
)
226 def do_login(self
, line
):
229 self
.onecmd("account %s" % line
)
230 if self
.username
== None or self
.pod
== None:
231 print("Use the 'account' command to set username and pod.")
232 elif self
.password
== None:
233 print("Use the 'password' command.")
235 print("Setting up a connection...")
236 self
.connection
= diaspy
.connection
.Connection(
237 pod
= "https://%s" % self
.pod
, username
= self
.username
, password
= self
.password
)
239 print("Logging in...")
240 self
.connection
.login()
241 except diaspy
.errors
.LoginError
:
242 print("Login failed.")
244 def do_pager(self
, pager
):
245 """Set the pager, e.g. to cat"""
247 print("Pager set: %s" % self
.pager
)
249 def do_editor(self
, editor
):
250 """Set the editor, e.g. to ed"""
252 print("Editor set: %s" % self
.editor
)
254 def header(self
, line
):
255 """Wrap line in header format."""
256 return self
.header_format
% line
258 def do_notifications(self
, line
):
259 """List notifications. Use 'notifications reload' to reload them."""
260 if line
== "" and self
.notifications
:
261 print("Redisplaying the notifications in the cache.")
262 print("Use 'notifications reload' to reload them.")
263 elif line
== "reload" or not self
.notifications
:
264 if self
.connection
== None:
265 print("Use the 'login' command, first.")
267 self
.notifications
= diaspy
.notifications
.Notifications(self
.connection
).last()
269 print("The 'notifications' command only takes the optional argument 'reload'.")
271 if self
.notifications
:
272 for n
, notification
in enumerate(self
.notifications
):
273 if notification
.unread
:
274 print(self
.header("%2d. %s %s") % (n
+1, notification
.when(), notification
))
276 print("%2d. %s %s" % (n
+1, notification
.when(), notification
))
277 print("Enter a number to select the notification.")
278 self
.numbers_refer_to
= 'notifications'
280 print("There are no notifications. 😢")
283 def do_quit(self
, *args
):
284 """Exit jan-pona-mute."""
288 def default(self
, line
):
289 if line
.strip() == "EOF":
290 return self
.onecmd("quit")
292 # Expand abbreviated commands
293 first_word
= line
.split()[0].strip()
294 if first_word
in shortcuts
:
295 full_cmd
= shortcuts
[first_word
]
296 expanded
= line
.replace(first_word
, full_cmd
, 1)
297 return self
.onecmd(expanded
)
299 # Finally, see if it's a notification and show it
302 def do_show(self
, line
):
303 """Show the post given by the index number.
304 The index number must refer to the current list of notifications
305 or the home stream. If no index number is given, show the current
307 if not self
.notifications
and not self
.home
:
308 print("Use the 'login' command to load notifications.")
310 if line
== "" and self
.post
== None:
311 print("Please specify a number.")
315 n
= int(line
.strip())
316 if self
.numbers_refer_to
== 'notifications':
317 notification
= self
.notifications
[n
-1]
318 self
.show(notification
)
319 if not self
.load(notification
.about()):
321 elif self
.numbers_refer_to
== 'home':
322 posts
= sorted(self
.home
, key
=lambda x
: x
.data()["created_at"])
323 self
.post
= posts
[n
-1]
325 print("Internal error: not sure what numbers '%s' refer to." % self
.numbers_refer_to
)
328 print("The 'show' command takes a notification number but '%s' is not a number" % line
)
331 print("Index too high!")
335 print(self
.header("%2d. %s %s") % (n
, self
.post
.data()["created_at"], self
.post
.author()))
340 if(self
.post
.comments
):
341 print("%d comment%s" % (len(self
.post
.comments
), "s" if len(self
.post
.comments
) != 1 else ""))
342 print("Use the 'comments' command to list the latest comments.")
343 print("Use the 'comment' command to leave a comment.")
346 """Load the post belonging to the id (from a notification),
347 or get it from the cache."""
348 if id in self
.post_cache
:
349 self
.post
= self
.post_cache
[id]
350 print("Retrieved post from the cache.")
354 self
.post
= diaspy
.models
.Post(connection
= self
.connection
, id = id)
355 self
.post_cache
[id] = self
.post
356 except diaspy
.errors
.PostError
:
357 print("Cannot load this post.")
361 def do_reload(self
, line
):
362 """Reload the current post."""
363 if self
.post
== None:
364 print("Use the 'show' command to show a post, first.")
366 print("Reloading...")
367 self
.post
= diaspy
.models
.Post(connection
= self
.connection
, id = self
.post
.id)
368 self
.post_cache
[id] = self
.post
370 def show(self
, item
):
371 """Show the current item."""
373 subprocess
.run(self
.pager
, input = str(item
), text
= True)
377 def do_comments(self
, line
):
378 """Show the comments for the current post.
379 Use the 'all' argument to show them all. Use a numerical argument to
380 show that many. The default is to load the last five."""
381 if self
.post
== None:
382 print("Use the 'show' command to show a post, first.")
384 if self
.post
.comments
== None:
385 print("The current post has no comments.")
389 comments
= self
.post
.comments
395 n
= int(line
.strip())
397 print("The 'comments' command takes a number as its argument, or 'all'.")
398 print("The default is to show the last 5 comments.")
405 start
= max(len(comments
) - n
, 0)
408 for n
, comment
in enumerate(comments
[start
:], start
):
410 print(self
.header("%2d. %s %s") % (n
+1, comment
.when(), comment
.author()))
415 print("There are no comments on the selected post.")
416 print("Use the 'comment' command to post a comment.")
418 def do_comment(self
, line
):
419 """Leave a comment on the current post.
420 If you just use a number as your comment, it will refer to a note.
421 Use the 'edit' command to edit notes."""
422 if self
.post
== None:
423 print("Use the 'show' command to show a post, first.")
426 # if the comment is just a number, use a note to post
427 n
= int(line
.strip())
428 notes
= self
.get_notes()
431 line
= self
.read_note(notes
[n
-1])
432 print("Using note #%d: %s" % (n
, notes
[n
-1]))
434 print("Use the 'list notes' command to list valid numbers.")
437 print("There are no notes to use.")
440 # in which case we'll simply comment with the line
442 comment
= self
.post
.comment(line
)
443 self
.post
.comments
.add(comment
)
444 self
.undo
.append("delete comment %s from %s" % (comment
.id, self
.post
.id))
445 print("Comment posted.")
447 def do_post(self
, line
):
448 """Write a post on the current stream.
449 If you just use a number as your post, it will refer to a note.
450 Use the 'edit' command to edit notes."""
451 if self
.home
== None:
452 self
.home
= diaspy
.streams
.Stream(self
.connection
)
454 # if the post is just a number, use a note to post
455 n
= int(line
.strip())
456 notes
= self
.get_notes()
459 line
= self
.read_note(notes
[n
-1])
460 print("Using note #%d: %s" % (n
, notes
[n
-1]))
462 print("Use the 'list notes' command to list valid numbers.")
465 print("There are no notes to use.")
468 # in which case we'll simply post the line
470 self
.post
= self
.home
.post(text
= line
)
471 self
.post_cache
[self
.post
.id] = self
.post
472 self
.undo
.append("delete post %s" % self
.post
.id)
473 print("Posted. Use the 'show' command to show it.")
475 def do_delete(self
, line
):
476 """Delete a comment."""
477 words
= line
.strip().split()
479 if words
[0] == "post":
481 print("Deleting a post takes no argument. It always deletes the selected post.")
484 print("Use the 'show' command to select a post.")
486 if self
.home
and self
.post
in self
.home
:
487 self
.home
._stream
.remove(self
.post
)
488 if self
.post
.id in self
.post_cache
:
489 self
.post_cache
.pop(self
.post
.id)
491 print("Post deleted.")
493 if words
[0] == "comment":
495 post
= self
.post_cache
[words
[3]]
496 post
.delete_comment(words
[1])
497 comments
= [c
.id for c
in post
.comments
if c
.id != id]
498 post
.comments
= diaspy
.models
.Comments(comments
)
499 print("Comment deleted.")
501 if self
.post
== None:
502 print("Use the 'show' command to show a post, first.")
507 comment
= self
.post
.comments
[n
-1]
510 print("Deleting a comment requires an integer.")
513 print("Use the 'comments' command to find valid comment numbers.")
515 # not catching any errors from diaspy
516 self
.post
.delete_comment(id)
517 # there is no self.post.comments.remove(id)
518 comments
= [c
.id for c
in self
.post
.comments
if c
.id != id]
519 self
.post
.comments
= diaspy
.models
.Comments(comments
)
520 print("Comment deleted.")
523 print("Deleting a comment requires a comment id and a post id, or a number.")
524 print("delete comment <comment id> from <post id>")
525 print("delete comment 5")
527 if words
[0] == "note":
529 print("Deleting a note requires a number.")
534 print("Deleting a note requires an integer.")
536 notes
= self
.get_notes()
539 os
.unlink(self
.get_note_path(notes
[n
-1]))
540 print("Deleted note #%d: %s" % (n
, notes
[n
-1]))
542 print("Use the 'list notes' command to list valid numbers.")
544 print("There are no notes to delete.")
546 print("Things to delete: post, comment, note.")
549 print("Delete what?")
551 def do_undo(self
, line
):
552 """Undo an action."""
554 print("The 'undo' command does not take an argument.")
557 print("There is nothing to undo.")
559 return self
.onecmd(self
.undo
.pop())
561 def do_edit(self
, line
):
562 """Edit a note with a given name."""
564 print("Edit takes the name of a note as an argument.")
566 file = self
.get_note_path(line
)
568 subprocess
.run([self
.editor
, file])
571 print("Use the 'editor' command to set an editor.")
573 def do_notes(self
, line
):
576 print("The 'notes' command does not take an argument.")
578 notes
= self
.get_notes()
580 for n
, note
in enumerate(notes
):
581 print(self
.header("%2d. %s") % (n
+1, note
))
582 print("Use the 'edit' command to edit a note.")
583 print("Use the 'preview' command to look at a note.")
584 print("Use the 'post' command to post a note.")
585 print("Use the 'comment' command to post a comment.")
587 print("Use 'edit' to create a note.")
590 """Get the list of notes."""
591 return os
.listdir(get_notes_dir())
593 def get_note_path(self
, filename
):
594 """Get the correct path for a note."""
595 return os
.path
.join(get_notes_dir(), filename
)
597 def read_note(self
, filename
):
598 """Get text of a note."""
599 with
open(self
.get_note_path(filename
), mode
= 'r', encoding
= 'utf-8') as fp
:
602 def do_preview(self
, line
):
603 """Preview a note using your pager.
604 Use the 'pager' command to set your pager to something like 'mdcat'."""
606 print("The 'preview' command the number of a note as an argument.")
607 print("Use the 'notes' command to list all your notes.")
610 n
= int(line
.strip())
611 notes
= self
.get_notes()
614 self
.show(self
.read_note(notes
[n
-1]))
616 print("Use the 'list notes' command to list valid numbers.")
619 print("There are no notes to preview.")
622 print("The 'preview' command takes a number as its argument.")
625 def do_home(self
, line
):
626 """Show the main stream containing the combined posts of the
627 followed users and tags and the community spotlights posts if
628 the user enabled those."""
631 print("Redisplaying the cached statuses of the home stream.")
632 print("Use the 'reload' argument to reload them.")
633 print("Use the 'all' argument to show them all.")
634 print("Use a number to show only that many.")
635 print("The default is 5.")
638 self
.home
= diaspy
.streams
.Stream(self
.connection
)
640 for post
in self
.home
:
641 if post
.id not in self
.post_cache
:
642 self
.post_cache
[post
.id] = post
643 elif line
== "reload":
644 if self
.connection
== None:
645 print("Use the 'login' command, first.")
648 print("Reloading...")
652 self
.home
= diaspy
.streams
.Stream(self
.connection
)
656 posts
= sorted(self
.home
, key
=lambda x
: x
.data()["created_at"])
662 n
= int(line
.strip())
664 print("The 'home' command takes a number as its argument, or 'reload' or 'all'.")
665 print("The default is to show the last 5 posts.")
672 start
= max(len(posts
) - n
, 0)
675 for n
, post
in enumerate(posts
[start
:], start
):
677 print(self
.header("%2d. %s %s") % (n
+1, post
.data()["created_at"], post
.author()))
681 print("%d comment%s" % (len(post
.comments
), "s" if len(post
.comments
) != 1 else ""))
684 print("Enter a number to select the post.")
685 self
.numbers_refer_to
= 'home'
687 print("The people you follow have nothing to say.")
688 print("The tags you follow are empty. 😢")
690 def do_shortcuts(self
, line
):
691 """List all shortcuts."""
693 print("The 'shortcuts' command does not take an argument.")
695 print(self
.header("Shortcuts"))
696 for shortcut
in sorted(shortcuts
):
697 print("%s\t%s" % (shortcut
, shortcuts
[shortcut
]))
698 print("Use the 'shortcut' command to change or add shortcuts.")
700 def do_shortcut(self
, line
):
701 """Define a new shortcut."""
702 words
= line
.strip().split(maxsplit
= 1)
704 return self
.onecmd("shortcuts")
705 elif len(words
) == 1:
707 if shortcut
in shortcuts
:
708 print("%s\t%s" % (shortcut
, shortcuts
[shortcut
]))
710 print("%s is not a shortcut" % shortcut
)
712 shortcuts
[words
[0]] = words
[1]
718 parser
= argparse
.ArgumentParser(description
='A command line Diaspora client.')
719 parser
.add_argument('--no-init-file', dest
='init_file', action
='store_const',
720 const
=False, default
=True, help='Do not load a init file')
721 args
= parser
.parse_args()
730 rcfile
= get_rcfile()
732 print("Using init file %s" % rcfile
)
733 with
open(rcfile
, "r") as fp
:
737 c
.cmdqueue
.append(line
)
739 seen_pager
= line
.startswith("pager ");
741 seen_editor
= line
.startswith("editor ");
743 print("Use the 'save' command to save your login sequence to an init file.")
747 c
.cmdqueue
.insert(0, "pager %s" % get_pager())
750 c
.cmdqueue
.insert(0, "editor %s" % get_editor())
752 # Endless interpret loop
756 except KeyboardInterrupt:
759 if __name__
== '__main__':