From 6bcf553777b1c1e0d581e08abb07a20bbde9b553 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:59:34 -0500 Subject: [PATCH] record-add: --labels=on/off to omit redundant standard-field labels --- keepercommander/commands/record_edit.py | 10 +++- keepercommander/vault_extensions.py | 3 +- unit-tests/test_command_record.py | 74 ++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index aceb2c301..286b0527c 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -42,6 +42,10 @@ record_add_parser.add_argument('-f', '--force', dest='force', action='store_true', help='ignore warnings') record_add_parser.add_argument('-t', '--title', dest='title', action='store', help='record title') record_add_parser.add_argument('-rt', '--record-type', dest='record_type', action='store', help='record type') +record_add_parser.add_argument('--labels', dest='labels', action='store', choices=['on', 'off'], + help='label fields in standard record-type definition. "on" (default) keeps legacy ' + 'labels; "off" omits them. "off" affects only RT-definition fields without their ' + 'own label; RT-definition custom labels and explicitly provided labels are preserved.') record_add_parser.add_argument('-n', '--notes', dest='notes', action='store', help='record notes') record_add_parser.add_argument('--folder', dest='folder', action='store', help='folder name or UID to store record') @@ -865,11 +869,15 @@ def execute(self, params, **kwargs): raise CommandError('record-add', f'Record type \"{record_type}\" cannot be found.') record = vault.TypedRecord() record.type_name = record_type + omit_labels = (kwargs.get('labels') or 'on').lower() == 'off' for rf in rt_fields: ref = rf.get('$ref') if not ref: continue - label = rf.get('label') or ref + # Use the label from the record-type definition when present (both modes). + # When the definition has none: legacy ("on") falls back to the field type; + # "off" leaves it empty so the redundant type-name label is omitted (matches Vault UI). + label = rf.get('label') or ('' if omit_labels else ref) required = rf.get('required', False) default_value = None if ref == 'appFiller': diff --git a/keepercommander/vault_extensions.py b/keepercommander/vault_extensions.py index c13411f46..a1bd49034 100644 --- a/keepercommander/vault_extensions.py +++ b/keepercommander/vault_extensions.py @@ -447,9 +447,10 @@ def extract_typed_field(field): # type: (vault.TypedField) -> dict field_values.append(value) result = { 'type': field_type, - 'label': field.label or '', 'value': field_values } + if field.label: + result['label'] = field.label if field.required is True: result['required'] = True return result diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index ebfeb2f8f..c1d385ef1 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -8,7 +8,7 @@ from data_vault import get_synced_params, VaultEnvironment from helper import KeeperApiHelper -from keepercommander import api, utils, crypto, attachment, vault +from keepercommander import api, utils, crypto, attachment, vault, vault_extensions from keepercommander.commands import record, record_edit from keepercommander.error import CommandError @@ -97,6 +97,78 @@ def artf(p, r, f): self.assertIsNotNone(field) self.assertEqual(field.get_default_value(str), 'BBB') + def _run_add(self, params, **kwargs): + """Run record-add with the API mocked; return the TypedRecord that would be saved.""" + cmd = record_edit.RecordAddCommand() + captured = {} + with mock.patch('keepercommander.api.sync_down'), \ + mock.patch('keepercommander.record_management.add_record_to_folder') as ar: + def artf(p, r, f): + captured['record'] = r + r.record_uid = utils.generate_uid() + ar.side_effect = artf + cmd.execute(params, **kwargs) + return captured.get('record') + + # RT schema with a label-less field (synthesized fallback) and one with a real definition label. + _RT_SCHEMA = [ + {"$ref": "login"}, # no label in RT definition + {"$ref": "password"}, # no label in RT definition + {"$ref": "script", "label": "rotationScripts"}, # real RT-definition label + ] + + def test_add_command_labels_default_is_legacy(self): + # No --labels (and explicit --labels=on): fields with no label in the RT definition fall + # back to the field type as the label; real definition labels are kept. + params = get_synced_params() + for labels in (None, 'on'): + kwargs = dict(force=True, title='L', record_type='login', + fields=['login=user@company.com', 'password=secret']) + if labels is not None: + kwargs['labels'] = labels + with mock.patch.object(record_edit.RecordAddCommand, 'get_record_type_fields', + return_value=list(self._RT_SCHEMA)): + record = self._run_add(params, **kwargs) + self.assertIsInstance(record, vault.TypedRecord) + self.assertEqual(record.get_typed_field('login').label, 'login') # synthesized + self.assertEqual(record.get_typed_field('password').label, 'password') # synthesized + self.assertEqual(record.get_typed_field('script').label, 'rotationScripts') # real, kept + data = vault_extensions.extract_typed_record_data(record) + login_data = next(x for x in data['fields'] if x['type'] == 'login') + self.assertEqual(login_data.get('label'), 'login') + + def test_add_command_labels_off_matches_vault(self): + # --labels=off: omit the synthesized type-name labels (login, password) but KEEP real + # RT-definition labels (script->rotationScripts), matching the Vault UI; an explicitly + # provided cmdline label is always preserved. + params = get_synced_params() + with mock.patch.object(record_edit.RecordAddCommand, 'get_record_type_fields', + return_value=list(self._RT_SCHEMA)): + record = self._run_add(params, force=True, title='L', record_type='login', labels='off', + fields=['login=user@company.com', 'text.MyLabel=val']) + self.assertIsInstance(record, vault.TypedRecord) + self.assertFalse(record.get_typed_field('login').label) # synthesized -> dropped + self.assertFalse(record.get_typed_field('password').label) # synthesized -> dropped + self.assertEqual(record.get_typed_field('script').label, 'rotationScripts') # real -> kept + self.assertEqual(record.get_typed_field('text', 'MyLabel').label, 'MyLabel') # explicit -> kept + + data = vault_extensions.extract_typed_record_data(record) + login_d = next(x for x in data['fields'] if x['type'] == 'login') + script_d = next(x for x in data['fields'] if x['type'] == 'script') + self.assertNotIn('label', login_d) # synthesized label omitted + self.assertEqual(script_d.get('label'), 'rotationScripts') # real label serialized + custom_d = next(x for x in data['custom'] if x.get('label') == 'MyLabel') + self.assertEqual(custom_d['label'], 'MyLabel') + + def test_extract_typed_field_omits_empty_label(self): + # Serializer omits the label key when falsy; keeps it when present. + self.assertNotIn('label', vault_extensions.extract_typed_field( + vault.TypedField.new_field('login', 'admin', ''))) + self.assertNotIn('label', vault_extensions.extract_typed_field( + vault.TypedField.new_field('login', 'admin', None))) + kept = vault_extensions.extract_typed_field(vault.TypedField.new_field('text', 'v', 'MyLabel')) + self.assertEqual(kept.get('label'), 'MyLabel') + def test_remove_command_from_root(self): params = get_synced_params() cmd = record.RecordRemoveCommand()