From 684c85255f194f40a8aff2361fa3f998b389e126 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 17:29:30 +0100 Subject: [PATCH 1/8] Add basic modify command implementation --- .pylintdict | 1 + cobib/commands/__init__.py | 2 + cobib/commands/modify.py | 117 +++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 cobib/commands/modify.py diff --git a/.pylintdict b/.pylintdict index 8f0239d0..4cd648e5 100644 --- a/.pylintdict +++ b/.pylintdict @@ -5,6 +5,7 @@ SHA TUI TextBuffer TextIOWrapper +argparse args arxiv attr diff --git a/cobib/commands/__init__.py b/cobib/commands/__init__.py index f10a6aba..7524b3e6 100644 --- a/cobib/commands/__init__.py +++ b/cobib/commands/__init__.py @@ -6,6 +6,7 @@ from .edit import EditCommand from .export import ExportCommand from .init import InitCommand from .list import ListCommand +from .modify import ModifyCommand from .open import OpenCommand from .redo import RedoCommand from .search import SearchCommand @@ -20,6 +21,7 @@ __all__ = [ "ExportCommand", "InitCommand", "ListCommand", + "ModifyCommand", "OpenCommand", "RedoCommand", "SearchCommand", diff --git a/cobib/commands/modify.py b/cobib/commands/modify.py new file mode 100644 index 00000000..17ee167b --- /dev/null +++ b/cobib/commands/modify.py @@ -0,0 +1,117 @@ +"""CoBib modify command.""" + +import argparse +import logging +import os +import sys + +from cobib.config import CONFIG +from .base_command import ArgumentParser, Command +from .list import ListCommand + +LOGGER = logging.getLogger(__name__) + + +class ModifyCommand(Command): + """Modify Command.""" + + name = 'modify' + + @staticmethod + def field_value_pair(string): + """Utility method to assert the field-value pair argument type. + + Args: + string (str): the argument string to check. + """ + # try splitting the string into field and value, any errors will be handled by argparse + field, value = string.split(':') + return (field, value) + + def execute(self, args, out=sys.stdout): + """Modify entries. + + Allows bulk modification of entries. + + Args: See base class. + """ + LOGGER.debug('Starting Modify command.') + parser = ArgumentParser(prog="modify", description="Modify subcommand parser.") + parser.add_argument("modification", type=self.field_value_pair, + help="Modification to apply to the specified entries." + "\nThis argument must be a string formatted as : where " + "field can be any field of the entries and value can be any string " + "which should be placed in that field. Be sure to escape this " + "field-value pair properly, especially if the value contains spaces." + ) + parser.add_argument("-s", "--selection", action="store_true", + help="interprets `list_arg` as the list of selected entries.") + parser.add_argument('list_arg', nargs='*', + help="Any arguments for the List subcommand." + "Use this to add filters to specify a subset of modified entries." + "You can should a '--' before the List arguments to ensure separation." + "See also `list --help` for more information on the List arguments." + "Note: when a selection has been made inside the TUI, this list is " + "interpreted as the list of entry labels to be modified. This also " + "requires the --selection argument to be set which you can exploit " + "on the command-line to achieve a similar effect.") + + if not args: + parser.print_usage(sys.stderr) + sys.exit(1) + + try: + largs = parser.parse_intermixed_args(args) + except argparse.ArgumentError as exc: + print("{}: {}".format(exc.argument_name, exc.message), file=sys.stderr) + return + + out = open(os.devnull, 'w') + if largs.selection: + LOGGER.info('Selection given. Interpreting `list_arg` as a list of labels') + labels = largs.list_arg + else: + LOGGER.debug('Gathering filtered list of entries to be modified.') + labels = ListCommand().execute(largs.list_arg, out=out) + + field, value = largs.modification + + for label in labels: + try: + entry = CONFIG.config['BIB_DATA'][label] + entry.data[field] = value + + conf_database = CONFIG.config['DATABASE'] + file = os.path.expanduser(conf_database['file']) + with open(file, 'r') as bib: + lines = bib.readlines() + entry_to_be_replaced = False + with open(file, 'w') as bib: + for line in lines: + if line.startswith(label + ':'): + LOGGER.debug('Entry "%s" found. Starting to replace lines.', label) + entry_to_be_replaced = True + continue + if entry_to_be_replaced and line.startswith('...'): + LOGGER.debug('Reached end of entry "%s".', label) + entry_to_be_replaced = False + bib.writelines('\n'.join(entry.to_yaml().split('\n')[1:])) + continue + if not entry_to_be_replaced: + bib.write(line) + + LOGGER.info('Modified "%s".', label) + except KeyError: + print("Error: No entry with the label '{}' could be found.".format(label)) + + self.git(args=vars(largs)) + + @staticmethod + def tui(tui): + """See base class.""" + LOGGER.debug('Modify command triggered from TUI.') + # handle input via prompt + if tui.selection: + tui.execute_command('modify -s', pass_selection=True) + else: + tui.execute_command('modify') -- GitLab From 5088d550af4d17402d27ea3a593bfc7dcd4aa416 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 18:04:46 +0100 Subject: [PATCH 2/8] Add unittest for ModifyCommand --- test/test_commands.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/test_commands.py b/test/test_commands.py index 3ff5c933..4c58412a 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -312,6 +312,26 @@ def test_delete(database_setup, labels): assert_git_commit_message('delete', {'labels': labels}) +@pytest.mark.parametrize(['arguments'], [ + [['tags:test', '-s', '--', 'einstein']], + [['tags:test', '--', '++ID', 'einstein']], + ]) +def test_modify(database_setup, arguments): + """Test modify command.""" + git = database_setup + # NOTE: again, we depend on AddCommand to work. + commands.AddCommand().execute(['-b', './test/example_literature.bib']) + read_database(fresh=True) + # modify some data + commands.ModifyCommand().execute(arguments) + assert CONFIG.config['BIB_DATA']['einstein'].data['tags'] == 'test' + if git: + # assert the git commit message + assert_git_commit_message('modify', {'selection': '-s' in arguments, + 'modification': arguments[0].split(':'), + 'list_arg': arguments[arguments.index('--')+1:]}) + + # TODO: figure out some very crude and basic way of testing this def test_edit(): """Test edit command.""" -- GitLab From 309f5371aa33446feeb542c8e2b0de2142734587 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 18:12:58 +0100 Subject: [PATCH 3/8] Integrate ModifyCommand with TUI Note: the `list_arg` argument must have at least one value! Otherwise, the modification would be applied to ALL entries (because no filter is specified for the ListCommand). --- cobib/commands/modify.py | 6 ++++-- cobib/tui/tui.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cobib/commands/modify.py b/cobib/commands/modify.py index 17ee167b..55c3c6ff 100644 --- a/cobib/commands/modify.py +++ b/cobib/commands/modify.py @@ -46,7 +46,7 @@ class ModifyCommand(Command): ) parser.add_argument("-s", "--selection", action="store_true", help="interprets `list_arg` as the list of selected entries.") - parser.add_argument('list_arg', nargs='*', + parser.add_argument('list_arg', nargs='+', help="Any arguments for the List subcommand." "Use this to add filters to specify a subset of modified entries." "You can should a '--' before the List arguments to ensure separation." @@ -100,7 +100,9 @@ class ModifyCommand(Command): if not entry_to_be_replaced: bib.write(line) - LOGGER.info('Modified "%s".', label) + msg = f"'{label}' was modified." + print(msg) + LOGGER.info(msg) except KeyError: print("Error: No entry with the label '{}' could be found.".format(label)) diff --git a/cobib/tui/tui.py b/cobib/tui/tui.py index 169585f0..252fab82 100644 --- a/cobib/tui/tui.py +++ b/cobib/tui/tui.py @@ -57,6 +57,7 @@ class TUI: 'Export': commands.ExportCommand.tui, 'Filter': partial(commands.ListCommand.tui, sort_mode=False), 'Help': lambda self: self.help(), + 'Modify': commands.ModifyCommand.tui, 'Open': commands.OpenCommand.tui, 'Prompt': lambda self: self.execute_command(None), 'Quit': lambda self: self.quit(), @@ -79,6 +80,7 @@ class TUI: "Export": "Allows exporting the database to .bib or .zip files.", "Filter": "Allows filtering the list via `++/--` keywords.", "Help": "Displays this help.", + "Modify": "Allows basic modification of multiple entries at once.", "Open": "Opens the associated file of an entry.", "Prompt": "Executes arbitrary CoBib CLI commands in the prompt.", "Quit": "Closes current menu and quit's CoBib.", @@ -120,6 +122,7 @@ class TUI: ord('d'): 'Delete', ord('e'): 'Edit', ord('f'): 'Filter', + ord('m'): 'Modify', ord('o'): 'Open', ord('q'): 'Quit', ord('r'): 'Redo', -- GitLab From 1e81e7bf6427b3d419b908eb1d1018b3076c048e Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 20:20:26 +0100 Subject: [PATCH 4/8] Prepare append mode of modify command --- cobib/commands/modify.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cobib/commands/modify.py b/cobib/commands/modify.py index 55c3c6ff..40342628 100644 --- a/cobib/commands/modify.py +++ b/cobib/commands/modify.py @@ -44,8 +44,10 @@ class ModifyCommand(Command): "which should be placed in that field. Be sure to escape this " "field-value pair properly, especially if the value contains spaces." ) + parser.add_argument("-a", "--append", action="store_true", + help="Appends to the modified field rather than overwriting it.") parser.add_argument("-s", "--selection", action="store_true", - help="interprets `list_arg` as the list of selected entries.") + help="Interprets `list_arg` as the list of selected entries.") parser.add_argument('list_arg', nargs='+', help="Any arguments for the List subcommand." "Use this to add filters to specify a subset of modified entries." @@ -76,6 +78,12 @@ class ModifyCommand(Command): field, value = largs.modification + if largs.append: + msg = 'The append-mode of the `modify` command has not been implemented yet.' + print(msg) + LOGGER.warning(msg) + sys.exit(1) + for label in labels: try: entry = CONFIG.config['BIB_DATA'][label] -- GitLab From 9bff8b896d56b3c35569e5e2650677a03dc9f511 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 20:25:10 +0100 Subject: [PATCH 5/8] Unify positional argument naming and help --- cobib/commands/export.py | 22 ++++++++++------------ cobib/commands/modify.py | 23 ++++++++++------------- cobib/commands/search.py | 12 ++++++------ 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/cobib/commands/export.py b/cobib/commands/export.py index 8e56908e..949cb82f 100644 --- a/cobib/commands/export.py +++ b/cobib/commands/export.py @@ -35,15 +35,13 @@ class ExportCommand(Command): parser.add_argument("-z", "--zip", type=argparse.FileType('a'), help="zip output file") parser.add_argument("-s", "--selection", action="store_true", - help="TUI only: interprets `list_arg` as the list of selected entries.") - parser.add_argument('list_arg', nargs='*', - help="Any arguments for the List subcommand." + - "\nUse this to add filters to specify a subset of exported entries." + - "\nYou can add a '--' before the List arguments to ensure separation." + - "\nSee also `list --help` for more information on the List arguments." + - "\nNote: when a selection has been made inside the TUI, this list is " + - "interpreted as a list of entry labels used for exporting. This also " + - "requires the --selection argument to be set.") + help="When specified, the `filter` argument will be interpreted as " + "a list of entry labels rather than arguments for the `list` command.") + parser.add_argument('filter', nargs='+', + help="You can specify filters as used by the `list` command in order " + "to select a subset of labels to be modified. To ensure this works as " + "expected you should add the pseudo-argument '--' before the list of " + "filters. See also `list --help` for more information.") if not args: parser.print_usage(sys.stderr) @@ -64,11 +62,11 @@ class ExportCommand(Command): largs.zip = ZipFile(largs.zip.name, 'w') out = open(os.devnull, 'w') if largs.selection: - LOGGER.info('Selection from TUI given. Interpreting `list_arg` as a list of labels') - labels = largs.list_arg + LOGGER.info('Selection given. Interpreting `filter` as a list of labels') + labels = largs.filter else: LOGGER.debug('Gathering filtered list of entries to be exported.') - labels = ListCommand().execute(largs.list_arg, out=out) + labels = ListCommand().execute(largs.filter, out=out) try: for label in labels: diff --git a/cobib/commands/modify.py b/cobib/commands/modify.py index 40342628..40a37a0c 100644 --- a/cobib/commands/modify.py +++ b/cobib/commands/modify.py @@ -47,16 +47,13 @@ class ModifyCommand(Command): parser.add_argument("-a", "--append", action="store_true", help="Appends to the modified field rather than overwriting it.") parser.add_argument("-s", "--selection", action="store_true", - help="Interprets `list_arg` as the list of selected entries.") - parser.add_argument('list_arg', nargs='+', - help="Any arguments for the List subcommand." - "Use this to add filters to specify a subset of modified entries." - "You can should a '--' before the List arguments to ensure separation." - "See also `list --help` for more information on the List arguments." - "Note: when a selection has been made inside the TUI, this list is " - "interpreted as the list of entry labels to be modified. This also " - "requires the --selection argument to be set which you can exploit " - "on the command-line to achieve a similar effect.") + help="When specified, the `filter` argument will be interpreted as " + "a list of entry labels rather than arguments for the `list` command.") + parser.add_argument('filter', nargs='+', + help="You can specify filters as used by the `list` command in order " + "to select a subset of labels to be modified. To ensure this works as " + "expected you should add the pseudo-argument '--' before the list of " + "filters. See also `list --help` for more information.") if not args: parser.print_usage(sys.stderr) @@ -70,11 +67,11 @@ class ModifyCommand(Command): out = open(os.devnull, 'w') if largs.selection: - LOGGER.info('Selection given. Interpreting `list_arg` as a list of labels') - labels = largs.list_arg + LOGGER.info('Selection given. Interpreting `filter` as a list of labels') + labels = largs.filter else: LOGGER.debug('Gathering filtered list of entries to be modified.') - labels = ListCommand().execute(largs.list_arg, out=out) + labels = ListCommand().execute(largs.filter, out=out) field, value = largs.modification diff --git a/cobib/commands/search.py b/cobib/commands/search.py index 29444814..c56f6701 100644 --- a/cobib/commands/search.py +++ b/cobib/commands/search.py @@ -35,11 +35,11 @@ class SearchCommand(Command): help="number of context lines to provide for each match") parser.add_argument("-i", "--ignore-case", action="store_true", help="ignore case for searching") - parser.add_argument('list_arg', nargs='*', - help="Any arguments for the List subcommand." + - "\nUse this to add filters to specify a subset of searched entries." + - "\nYou can add a '--' before the List arguments to ensure separation." + - "\nSee also `list --help` for more information on the List arguments.") + parser.add_argument('filter', nargs='+', + help="You can specify filters as used by the `list` command in order " + "to select a subset of labels to be modified. To ensure this works as " + "expected you should add the pseudo-argument '--' before the list of " + "filters. See also `list --help` for more information.") if not args: parser.print_usage(sys.stderr) @@ -51,7 +51,7 @@ class SearchCommand(Command): print("{}: {}".format(exc.argument_name, exc.message), file=sys.stderr) return None - labels = ListCommand().execute(largs.list_arg, out=open(os.devnull, 'w')) + labels = ListCommand().execute(largs.filter, out=open(os.devnull, 'w')) LOGGER.debug('Available entries to search: %s', labels) ignore_case = CONFIG.config['DATABASE'].getboolean('search_ignore_case', False) or \ -- GitLab From 74704d58ba12111d3584fbc46b14489d04cc49bf Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 20:33:51 +0100 Subject: [PATCH 6/8] Update man-page --- cobib.1 | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/cobib.1 b/cobib.1 index b68e33a7..8f95dd6a 100644 --- a/cobib.1 +++ b/cobib.1 @@ -107,6 +107,33 @@ The entry is copied verbatim in \fIYAML\fR format from and to the database file. .in +4n Allows editing a non-existing label to manually add it to the database. .TP +.B cobib modify \fI\fR \fI\fR ... +Applies a modification to multiple entries at once. +The positional arguments may be used to provide \fBFILTERS\fR which the entries +must match in order to be modified \fIor\fR to provide a list of labels of the +entries which are to be modified (this requires the \fI-s\fR flag to be set). +The \fI\fR may be any of the following: +.PP +.in +8n +.BR \fI\fR +.in +4n +The modification must be provided in the format \fI:\fR and will +set the field of all selected entries to the given value. +.PP +.in +8n +.BR \-a ", " \-\-append +.in +4n +This is \fBNOT\fR implemented yet. However, in the future this will provide the +possibility to append the new value to the modified field rather than +overwriting it. +.PP +.in +8n +.BR \-s ", " \-\-selection +.in +4n +This boolean flag enables the \fIselection\fR mode in which the positional args +are interpreted as a list of labels which are to be exported. The name for this +argument is a result of the TUI's selection interface. +.TP .B cobib undo \fI\fR If you enabled the git-integration of CoBib (available since v2.6.0) you can undo the changes done to your database file by commands such as add, edit and @@ -178,7 +205,7 @@ This values defaults to 1. Makes the search case-insensitive. .TP .B cobib export \fI\fR ... -Exports the database to the file configured via the \fIargs\fR. +Exports the database. The positional arguments may be used to provide \fBFILTERS\fR which the entries must match in order to be included in the export \fIor\fR to provide a list of labels of the entries which are to be exported (this requires the \fI-s\fR flag @@ -267,11 +294,16 @@ Toggles wrapping of the viewing buffer. Opens a command prompt which allows running the \fBadd\fR command as if outside of the TUI. .TP +.BR d " " delete +Deletes the current (or \fIselected\fR) label(s). +.TP .BR e " " edit Opens the current label in an external editor. .TP -.BR d " " delete -Deletes the current (or \fIselected\fR) label(s). +.BR m " " modify +Opens a command prompt which allows running the \fBmodify\fR command as if +outside of the TUI. If a \fIselection\fR is present, the \fI-s\fR argument will +be set automatically. .TP .BR u " " undo Undoes the last auto-committed change to the database file. -- GitLab From 84aaac74fc0be2c2799d6df89c5a083f26ebb207 Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 20:36:39 +0100 Subject: [PATCH 7/8] Update Changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d213f5b..cad26972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - the `Prompt` command inside of the TUI: - allows executing arbitrary CoBib CLI commands - the default key binding is `:` +- the `Modify` command: (#60,!24) + - allows bulk modification of multiple entries in a `:` format + - for now, this will **always** overwrite the field with the new value! + - an extension to appending values is planned for a later release + - the set of entries to be modified can be specified just like with the `export` command through `list`-command filters or manual selection (by setting the `--selection` flag) ### Changed - the viewport history is preserved correctly (#21,!22) -- GitLab From 3b285fccf3ce7194afae276acdc1537a7a930a1b Mon Sep 17 00:00:00 2001 From: Max Rossmannek Date: Wed, 30 Dec 2020 20:54:10 +0100 Subject: [PATCH 8/8] Fix unittest --- cobib/commands/export.py | 2 +- cobib/commands/search.py | 2 +- test/test_commands.py | 22 ++++++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cobib/commands/export.py b/cobib/commands/export.py index 949cb82f..4c4111d5 100644 --- a/cobib/commands/export.py +++ b/cobib/commands/export.py @@ -37,7 +37,7 @@ class ExportCommand(Command): parser.add_argument("-s", "--selection", action="store_true", help="When specified, the `filter` argument will be interpreted as " "a list of entry labels rather than arguments for the `list` command.") - parser.add_argument('filter', nargs='+', + parser.add_argument('filter', nargs='*', help="You can specify filters as used by the `list` command in order " "to select a subset of labels to be modified. To ensure this works as " "expected you should add the pseudo-argument '--' before the list of " diff --git a/cobib/commands/search.py b/cobib/commands/search.py index c56f6701..7d658b99 100644 --- a/cobib/commands/search.py +++ b/cobib/commands/search.py @@ -35,7 +35,7 @@ class SearchCommand(Command): help="number of context lines to provide for each match") parser.add_argument("-i", "--ignore-case", action="store_true", help="ignore case for searching") - parser.add_argument('filter', nargs='+', + parser.add_argument('filter', nargs='*', help="You can specify filters as used by the `list` command in order " "to select a subset of labels to be modified. To ensure this works as " "expected you should add the pseudo-argument '--' before the list of " diff --git a/test/test_commands.py b/test/test_commands.py index 4c58412a..f49d229c 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -312,24 +312,30 @@ def test_delete(database_setup, labels): assert_git_commit_message('delete', {'labels': labels}) -@pytest.mark.parametrize(['arguments'], [ - [['tags:test', '-s', '--', 'einstein']], - [['tags:test', '--', '++ID', 'einstein']], +@pytest.mark.parametrize(['modification', 'filters', 'selection'], [ + ['tags:test', ['einstein'], True], + ['tags:test', ['++ID', 'einstein'], False], ]) -def test_modify(database_setup, arguments): +def test_modify(database_setup, modification, filters, selection, append=False): """Test modify command.""" git = database_setup # NOTE: again, we depend on AddCommand to work. commands.AddCommand().execute(['-b', './test/example_literature.bib']) read_database(fresh=True) # modify some data - commands.ModifyCommand().execute(arguments) + args = [modification, '--'] + filters + if selection: + args = ['-s'] + args + if append: + args = ['-a'] + args + commands.ModifyCommand().execute(args) assert CONFIG.config['BIB_DATA']['einstein'].data['tags'] == 'test' if git: # assert the git commit message - assert_git_commit_message('modify', {'selection': '-s' in arguments, - 'modification': arguments[0].split(':'), - 'list_arg': arguments[arguments.index('--')+1:]}) + assert_git_commit_message('modify', {'append': append, + 'selection': selection, + 'modification': modification.split(':'), + 'filter': filters}) # TODO: figure out some very crude and basic way of testing this -- GitLab