Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

edit plugin: make temporary file reflect --set CLI arguments #5056

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions beetsplug/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
import codecs
import os
import shlex
import re
import subprocess
from tempfile import NamedTemporaryFile

import yaml

from beets import plugins, ui, util
from beets import plugins, ui, util, config
from beets.dbcore import types
from beets.importer import action
from beets.ui.commands import PromptChoice, _do_query
Expand Down Expand Up @@ -141,6 +142,112 @@ def apply_(obj, data):
obj.set_parse(key, str(value))


def dump_and_prune_fields(data):
"""For a set of "set fields", plus `id` and `path`, make sure that
those fields are presented in a way that makes sense
(this is for import sessions, where fields affected by the `--set` flag are
effectively read-only, and both `id` and `path` can only contain placeholders
for now)
This function takes the data for the editable text, and returns a modified
version of said text.
"""

# this does the following:
# take set fields into account, by setting their values, commenting them out,
# and annotating them
# also pruning the "id" and "path" fields, which can only contain placeholders
# at this time

data = [obj.copy() for obj in data]

# prepare regex matching
# the following regex detects yaml lines setting values for 'id' or 'path'
placeholder_field_detector = re.compile(r"\s*(-\s+)?(id|path):(\s+.*)?")
set_fields = config["import"]["set_fields"]
if set_fields:
# a similar regex, but for setting values for any field
# in the set_fields list
ro_field_detector = re.compile(
r"\s*(-\s+)?({0:s}):(\s+.*)?".format(
"|".join(re.escape(key) for key in set_fields.keys())
)
)

# deal with the values of the set fields
for obj in data:
obj.update({k: v.get() for k, v in set_fields.items()})
else:
# will in theory not be read
# this line is just to make static analysis happy
ro_field_detector = None

# convert to text, then comment/annotate/prune lines (only on import)
data_lines = dump(data).split("\n")

line_i = 0
while line_i < len(data_lines):
line = data_lines[line_i]
if set_fields and ro_field_detector.fullmatch(line):
line = "#" + line + " # [read-only field]"
data_lines[line_i] = line
line_i += 1
elif placeholder_field_detector.fullmatch(line):
del data_lines[line_i]
else:
line_i += 1

return "\n".join(data_lines)


def fix_warn_ro_fields(old_data, new_data):
"""For a set of new data obtained from edited text,
take care of the read-only fields that were commented out or
pruned out of the text prior to editing:
- first re-add the fields that are now missing
- then warned if those fields were changed when the text was edited
"""

set_fields = config["import"]["set_fields"].keys()
set_fields = list(set_fields) + ["id", "path"]
for field in set_fields:
changed = False
for old_obj, new_obj in zip(old_data, new_data):
if field in old_obj and field not in new_obj:
# only copy 'missing fields' if they are missing
# due to us pruning/commenting them out in the editable
# text
new_obj[field] = old_obj[field]
if old_obj.get(field, "") != new_obj.get(field, ""):
changed = True
if field in old_obj:
new_obj[field] = old_obj[field]
else:
del new_obj[field]
if changed:
if field in ("id", "path"):
ui.print_(
ui.colorize(
"text_warning",
f'NOTICE: the field "{field:s}" is read-only '
"because it can only contain placeholder values "
"at this time.\nThe values you have manually set "
"will have to be discarded.",
)
)
else:
ui.print_(
ui.colorize(
"text_warning",
f'NOTICE: the field "{field:s}" is read-only '
"because it was set through the `--set` "
"command line arguemnt or an equivalent in beets's "
"configuration.\nThe manual changes you have made "
"to it will have to be discarded.",
)
)
return new_data


class EditPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
Expand All @@ -158,6 +265,7 @@ def __init__(self):
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_listener
)
self.session_is_import = False

def commands(self):
edit_command = ui.Subcommand("edit", help="interactively edit metadata")
Expand Down Expand Up @@ -236,11 +344,15 @@ def edit_objects(self, objs, fields):
# Get the content to edit as raw data structures.
old_data = [flatten(o, fields) for o in objs]

if self.session_is_import:
old_str = dump_and_prune_fields(old_data)
else:
old_str = dump(old_data)

# Set up a temporary file with the initial data for editing.
new = NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
)
old_str = dump(old_data)
new.write(old_str)
new.close()

Expand Down Expand Up @@ -268,6 +380,10 @@ def edit_objects(self, objs, fields):
else:
return False

# see if any changes to the data need to be discarded:
if self.session_is_import:
new_data = fix_warn_ro_fields(old_data, new_data)

# Show the changes.
# If the objects are not on the DB yet, we need a copy of their
# original state for show_model_changes.
Expand Down Expand Up @@ -370,6 +486,8 @@ def importer_edit(self, session, task):
if not obj._db or obj.id is None:
obj.id = -i

self.session_is_import = True

# Present the YAML to the user and let them change it.
fields = self._get_fields(album=False, extra=[])
success = self.edit_objects(task.items, fields)
Expand Down Expand Up @@ -400,4 +518,5 @@ def importer_edit_candidate(self, session, task):
task.match = task.candidates[sel - 1]
task.apply_metadata()

self.session_is_import = True
return self.importer_edit(session, task)