diff --git a/clients/network_client.py b/clients/network_client.py index 2c9dcb0..ace2b01 100644 --- a/clients/network_client.py +++ b/clients/network_client.py @@ -6,6 +6,14 @@ import json class BBJ(object): + # YO WHATS UP MAN + # this module isnt exactly complete. The below description claims + # `all of its endpoints are mapped to native methods` though this + # is not yet true. The documentation for the API is not yet + # complete, and neither is this client. Currently this module is + # being adapted to fit the needs of the urwid client. As it evolves, + # and the rest of the project evolves, this client will be completed + # and well documented. """ A python implementation to the BBJ api: all of its endpoints are mapped to native methods, it maps error responses to exceptions, and @@ -463,6 +471,16 @@ class BBJ(object): return response["data"] + def message_delete(self, thread_id, post_id): + """ + Delete message `post_id` from `thread_id`. The same rules apply + to deletions as they do for edits. The same exceptions are raised + with the same descriptions. If post_id is 0, this will also delete + the entire thread. Returns True on success. + """ + return self("delete_post", thread_id=thread_id, post_id=post_id) + + def can_edit(self, thread_id, post_id): """ Return bool True/False that the post at thread_id | post_id diff --git a/clients/urwid/main.py b/clients/urwid/main.py index 4868111..64607d7 100644 --- a/clients/urwid/main.py +++ b/clients/urwid/main.py @@ -163,6 +163,7 @@ editors = ["nano", "vim", "emacs", "vim -u NONE", "emacs -Q", "micro", "ed", "jo default_prefs = { "editor": os.getenv("EDITOR", default="nano"), + "shift_multiplier": 5, "integrate_external_editor": True, "dramatic_exit": True, "date": "%Y/%m/%d", @@ -449,6 +450,34 @@ class App(object): self.compose(init_body=">>%d\n\n" % message["post_id"]) + def deletion_dialog(self, button, message): + """ + Prompts the user to confirm deletion of an item. + This can delete either a thread or a post. + """ + op = message["post_id"] == 0 + buttons = [ + urwid.Text(("bold", "Delete this %s?" % ("whole thred" if op else "post"))), + urwid.Divider(), + cute_button(("10" , ">> Yes"), lambda _: [ + network.message_delete(message["thread_id"], message["post_id"]), + self.remove_overlays(), + self.index() if op else self.refresh(False) + ]), + cute_button(("30", "<< No"), self.remove_overlays) + ] + + # TODO: create a central routine for creating popups. this is getting really ridiculous + popup = OptionsMenu( + urwid.ListBox(urwid.SimpleFocusListWalker(buttons)), + **frame_theme()) + + self.loop.widget = urwid.Overlay( + popup, self.loop.widget, + align=("relative", 50), + valign=("relative", 50), + width=30, height=6) + def on_post(self, button, message): quotes = self.get_quotes(message) @@ -463,6 +492,11 @@ class App(object): self.quote_view_menu, quotes)) if network.can_edit(message["thread_id"], message["post_id"]): + if message["post_id"] == 0: + msg = "Thread" + else: msg = "Post" + + buttons.insert(0, urwid.Button("Delete %s" % msg, self.deletion_dialog, message)) buttons.insert(0, urwid.Button("Edit Post", self.edit_post, message)) widget = OptionsMenu( @@ -632,7 +666,7 @@ class App(object): buttons = [ urwid.Text(("bold", "Discard current post?")), urwid.Divider(), - cute_button(("10" ,">> Yes"), lambda _: [ + cute_button(("10" , ">> Yes"), lambda _: [ self.remove_overlays(), self.index() ]), @@ -837,6 +871,12 @@ class App(object): bbjrc("update", **self.prefs) + def edit_shift(self, editor, content): + self.prefs["shift_multiplier"] = \ + int(content) if content else 0 + bbjrc("update", **self.prefs) + + def options_menu(self): """ Create a popup for the user to configure their account and @@ -889,6 +929,9 @@ class App(object): width_edit = urwid.IntEdit(default=self.prefs["max_text_width"]) urwid.connect_signal(width_edit, "change", self.edit_width) + shift_edit = urwid.IntEdit(default=self.prefs["shift_multiplier"]) + urwid.connect_signal(shift_edit, "change", self.edit_shift) + editor_display = Prompt(edit_text=self.prefs["editor"]) urwid.connect_signal(editor_display, "change", self.set_new_editor, editor_buttons) for editor in editors: @@ -928,6 +971,9 @@ class App(object): urwid.Text(("button", "Max message width:")), urwid.AttrMap(width_edit, "opt_prompt"), urwid.Divider(), + urwid.Text(("button", "Scroll multiplier when holding shift:")), + urwid.AttrMap(shift_edit, "opt_prompt"), + urwid.Divider(), urwid.Text(("button", "Text editor:")), urwid.Text("You can type in your own command or use one of these presets."), urwid.Divider(), @@ -1065,7 +1111,7 @@ class App(object): **frame_theme() ), "bar"), - self.loop.screen_size[1] // 2),]) + self.loop.screen_size[1] // 2)]) self.switch_editor() @@ -1193,6 +1239,7 @@ class FootPrompt(Prompt): app.loop.widget.focus_position = "body" app.set_default_footer() self.callback(self.get_edit_text(), *self.args) + elif key.lower() in ["esc", "ctrl g", "ctrl c"]: app.loop.widget.focus_position = "body" app.set_default_footer() @@ -1222,6 +1269,10 @@ class ExternalEditor(urwid.Terminal): def keypress(self, size, key): + if key.lower() == "ctrl l": + # always do this, and also pass it to the terminal + wipe_screen() + if self.terminated: app.close_editor() with open(self.path) as _: @@ -1242,8 +1293,10 @@ class ExternalEditor(urwid.Terminal): self.terminate() app.close_editor() app.refresh() + elif key == "f2": app.switch_editor() + elif key == "f3": app.formatting_help() @@ -1271,7 +1324,7 @@ class OptionsMenu(urwid.LineBox): self.keypress(size, "down") elif key in ["shift up", "K", "P"]: - for x in range(5): + for x in range(app.prefs["shift_multiplier"]): self.keypress(size, "up") elif key.lower() in ["left", "h", "q"]: @@ -1286,7 +1339,7 @@ class OptionsMenu(urwid.LineBox): elif key in ["ctrl p", "k", "p"]: return self.keypress(size, "up") - elif key == "ctrl l": + elif key.lower() == "ctrl l": wipe_screen() @@ -1308,11 +1361,11 @@ class ActionBox(urwid.ListBox): self._keypress_up(size) elif key in ["shift down", "J", "N"]: - for x in range(5): + for x in range(app.prefs["shift_multiplier"]): self._keypress_down(size) elif key in ["shift up", "K", "P"]: - for x in range(5): + for x in range(app.prefs["shift_multiplier"]): self._keypress_up(size) elif key in ["h", "left"]: diff --git a/server.py b/server.py index d29ebc8..eca942a 100644 --- a/server.py +++ b/server.py @@ -275,6 +275,26 @@ class API(object): database, user["user_id"], args["thread_id"], args["post_id"], args["body"]) + @api_method + def delete_post(self, args, database, user, **kwargs): + """ + Requires the arguments `thread_id` and `post_id`. + + Delete a message from a thread. The same rules apply + here as `edit_post` and `edit_query`: the logged in user + must either be the one who posted the message within 24hrs, + or have admin rights. The same error descriptions and code + are returned on falilure. Boolean true is returned on + success. + """ + if user == db.anon: + raise BBJUserError("Anons cannot delete messages.") + validate(args, ["thread_id", "post_id"]) + return db.message_delete( + database, user["user_id"], args["thread_id"], args["post_id"]) + + + @api_method def is_admin(self, args, database, user, **kwargs): """ diff --git a/src/db.py b/src/db.py index afd51a0..0c9920c 100644 --- a/src/db.py +++ b/src/db.py @@ -16,6 +16,10 @@ hashes that != 64 characters in length, as a basic measure to enforce the use of sha256. """ +# TODO: Move methods from requiring an author id to requiring a +# database user object: these user objects are always resolved on +# incoming requests and re-resolving them from their ID is wasteful. + from src.exceptions import BBJParameterError, BBJUserError from src.utils import ordered_keys, schema_values from src import schema @@ -150,6 +154,43 @@ def thread_reply(connection, author_id, thread_id, body, time_override=None): return scheme +def message_delete(connection, author, thread_id, post_id): + """ + 'Delete' a message from a thread. If the message being + deleted is an OP [pid 0], delete the whole thread. + + Requires an author id, the thread_id, and post_id. + The same rules for edits apply to deletions: the same + error objects are returned. Returns True on success. + """ + message_edit_query(connection, author, thread_id, post_id) + + if post_id == 0: + # NUKE NUKE NUKE NUKE + connection.execute("DELETE FROM threads WHERE thread_id = ?", (thread_id,)) + connection.execute("DELETE FROM messages WHERE thread_id = ?", (thread_id,)) + + else: + connection.execute(""" + UPDATE messages SET + author = ?, + body = ?, + edited = ? + WHERE thread_id = ? + AND post_id = ? + """, (anon["user_id"], "[deleted]", False, thread_id, post_id)) + # DONT deincrement the reply_count of this thread, + # or even delete the message itself. This breaks + # balance between post_id and the post's index when + # the thread is served with the messages in an array. + # *actually* deleting messages, which would be ideal, + # would increase implementation complexity for clients. + # IMO, that is not worth it. Threads are fair game. + + connection.commit() + return True + + def message_edit_query(connection, author, thread_id, post_id): """ Perform all the neccesary sanity checks required to edit a post