diff --git a/.pylintdict b/.pylintdict index 8f0239d0b3cc078885cf22250a94418c1b2250c5..4cd648e591a2b6d5e1807f3769b8d766022c7e06 100644 --- a/.pylintdict +++ b/.pylintdict @@ -5,6 +5,7 @@ SHA TUI TextBuffer TextIOWrapper +argparse args arxiv attr diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d213f5becc5b9b0bf5a9f6c206fbb8b62e81b9a..cad269726edc1fbeb5f4c583a02c4f0db3a3f857 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) diff --git a/cobib.1 b/cobib.1 index b68e33a728135d3b864e8e51519b93ef0186e6f7..8f95dd6a5360138e5484d8748978d59e7ae2e258 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. diff --git a/cobib/commands/__init__.py b/cobib/commands/__init__.py index f10a6aba7c55b3fa617214f7686e0c69c4208899..7524b3e63d5035658f7360f8d2fbb6b202c2861f 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/export.py b/cobib/commands/export.py index 8e56908e83941d206a446362808d50c12caba8bd..4c4111d57dadc6f2d0f25276292baaf8754d82ed 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 new file mode 100644 index 0000000000000000000000000000000000000000..40a37a0c915383f4106861096f3a2ca61af193c7 --- /dev/null +++ b/cobib/commands/modify.py @@ -0,0 +1,124 @@ +"""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("-a", "--append", action="store_true", + help="Appends to the modified field rather than overwriting it.") + 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='+', + 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) + 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 `filter` as a list of labels') + labels = largs.filter + else: + LOGGER.debug('Gathering filtered list of entries to be modified.') + labels = ListCommand().execute(largs.filter, out=out) + + 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] + 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) + + msg = f"'{label}' was modified." + print(msg) + LOGGER.info(msg) + 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') diff --git a/cobib/commands/search.py b/cobib/commands/search.py index 294448146f55b8025b7346d1d70a6802e29aac94..7d658b99e9b4ddf8d5bae3abaa104f45e35c3ea6 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 \ diff --git a/cobib/tui/tui.py b/cobib/tui/tui.py index 169585f008bad48b273c8db1495e9054c7948d8a..252fab82f895211133cad83a91977b7fbdccedc9 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', diff --git a/test/test_commands.py b/test/test_commands.py index 3ff5c9330e94c5ad3aa0407778b96e76c64264b8..f49d229c742e9e98c6e3cc56aa605fb21bf611ee 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -312,6 +312,32 @@ def test_delete(database_setup, labels): assert_git_commit_message('delete', {'labels': labels}) +@pytest.mark.parametrize(['modification', 'filters', 'selection'], [ + ['tags:test', ['einstein'], True], + ['tags:test', ['++ID', 'einstein'], False], + ]) +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 + 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', {'append': append, + 'selection': selection, + 'modification': modification.split(':'), + 'filter': filters}) + + # TODO: figure out some very crude and basic way of testing this def test_edit(): """Test edit command."""