merge branch 'feature/password-groups'

This commit is contained in:
2026-05-08 13:16:59 +03:00
20 changed files with 991 additions and 123 deletions

View File

@@ -0,0 +1,5 @@
using System;
namespace KeyKeeper.Models;
public record GroupEditData(string Name, Guid IconType);

View File

@@ -2,7 +2,7 @@ using System;
namespace KeyKeeper.PasswordStore;
public static class EntryIconType
public static class BuiltinEntryIconType
{
public static readonly Guid KEY = Guid.Parse("65ab3d55-1652-4f66-aac9-c3617f14e308");
public static readonly Guid DEFAULT = KEY;

View File

@@ -19,6 +19,7 @@ static class FileFormatConstants
public const uint FILE_FIELD_END = 0x010ba81a;
public const byte ENTRY_PASS_ID = 0x00;
public const byte ENTRY_GROUP_ID = 0x01;
public const byte ENTRY_LINK_ID = 0x02;
public const byte LOGIN_FIELD_PASSWORD_ID = 0x00;
public const byte LOGIN_FIELD_USERNAME_ID = 0x01;
public const byte LOGIN_FIELD_EMAIL_ID = 0x02;

View File

@@ -1,3 +1,4 @@
using System;
using KeyKeeper.PasswordStore.Crypto;
namespace KeyKeeper.PasswordStore;
@@ -6,10 +7,15 @@ public interface IPassStore
{
bool Locked { get; }
IPassStoreDirectory GetRootDirectory();
IPassStoreDirectory? GetGroupByType(byte groupType);
PassStoreEntryGroup GetRootDirectory();
PassStoreEntryGroup? GetGroupByType(byte groupType);
public PassStoreEntry GetEntryById(Guid id);
int GetTotalEntryCount();
void Unlock(CompositeKey key);
void Lock();
void Save();
bool DeleteEntry(PassStoreEntryGroup? group, Guid id);
void AddEntry(PassStoreEntryGroup group, PassStoreEntry entry);
void UpdateEntry(PassStoreEntryGroup? group, Guid id, PassStoreEntry entry);
}

View File

@@ -1,11 +0,0 @@
using System;
using System.Collections.Generic;
namespace KeyKeeper.PasswordStore;
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
{
bool DeleteEntry(Guid id);
void AddEntry(PassStoreEntry entry);
void UpdateEntry(Guid id, PassStoreEntry entry);
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using static KeyKeeper.PasswordStore.FileFormatConstants;
@@ -13,6 +14,7 @@ public abstract class PassStoreEntry
public Guid IconType { get; set; }
public string Name { get; set; }
public PassStoreEntryType Type { get; set; }
public List<PassStoreEntry> Backlinks = new();
public string IconPath
{
get
@@ -20,6 +22,7 @@ public abstract class PassStoreEntry
return $"avares://KeyKeeper/Assets/builtin-entry-icon-{IconType}.svg";
}
}
public virtual string DisplayName => Name;
public void WriteToStream(Stream str)
{
@@ -77,6 +80,9 @@ public abstract class PassStoreEntry
} else if (entryType == ENTRY_PASS_ID)
{
return PassStoreEntryPassword.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
} else if (entryType == ENTRY_LINK_ID)
{
return PassStoreEntryLink.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
} else
{
throw PassStoreFileException.InvalidPassStoreEntry;

View File

@@ -2,16 +2,27 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using static KeyKeeper.PasswordStore.FileFormatConstants;
namespace KeyKeeper.PasswordStore;
public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
public class PassStoreEntryGroup : PassStoreEntry, IEnumerable<PassStoreEntry>
{
public byte GroupType { get; set; }
public Guid? CustomGroupSubtype { get; set; }
public List<PassStoreEntry> ChildEntries { get; set; }
public override string DisplayName => GroupType switch
{
GROUP_TYPE_DEFAULT => "All Passwords",
GROUP_TYPE_FAVOURITES => "Favourites",
GROUP_TYPE_ROOT => ":root:",
_ => Name
};
public IEnumerable<PassStoreEntryGroup> ChildGroups => ChildEntries.OfType<PassStoreEntryGroup>();
public PassStoreEntryGroup(Guid id, DateTime createdAt, DateTime modifiedAt,
Guid iconType, string name, byte groupType,
List<PassStoreEntry>? children = null,
@@ -64,42 +75,6 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
}
}
public void AddEntry(PassStoreEntry entry)
{
entry.Parent = this;
ChildEntries.Add(entry);
}
public bool DeleteEntry(Guid id)
{
if (ChildEntries == null)
return false;
for (int i = 0; i < ChildEntries.Count; i++)
{
if (ChildEntries[i].Id == id)
{
ChildEntries.RemoveAt(i);
return true;
}
}
return false;
}
public void UpdateEntry(Guid id, PassStoreEntry entry)
{
entry.Parent = this;
if (ChildEntries == null)
return;
for (int i = 0; i < ChildEntries.Count; i++)
{
if (ChildEntries[i].Id == id)
{
ChildEntries[i] = entry;
return;
}
}
}
IEnumerator<PassStoreEntry> IEnumerable<PassStoreEntry>.GetEnumerator()
{
return ChildEntries.GetEnumerator();

View File

@@ -0,0 +1,58 @@
using System;
using System.IO;
using static KeyKeeper.PasswordStore.FileFormatConstants;
namespace KeyKeeper.PasswordStore;
public class PassStoreEntryLink : PassStoreEntry
{
public PassStoreEntry? LinkTarget;
public Guid LinkTargetId;
public override string DisplayName => LinkTarget?.DisplayName ?? Name;
public PassStoreEntryLink(Guid id, DateTime createdAt, DateTime modifiedAt,
Guid targetId, PassStoreEntry? target = null)
{
Id = id;
CreationDate = createdAt;
ModificationDate = modifiedAt;
IconType = BuiltinEntryIconType.DEFAULT;
Name = "";
LinkTargetId = targetId;
LinkTarget = target;
}
public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name)
{
BinaryReader rd = new(str);
try
{
byte[] guidBuffer = new byte[16];
if (rd.Read(guidBuffer) < 16)
throw PassStoreFileException.UnexpectedEndOfFile;
Guid linkTargetId = new(guidBuffer);
return new PassStoreEntryLink(id, createdAt, modifiedAt, linkTargetId);
} catch (EndOfStreamException)
{
throw PassStoreFileException.UnexpectedEndOfFile;
}
}
public override string ToString()
{
return string.Format(
"EntryLink (id={0} target_id={1} target={2})",
Id, LinkTargetId, LinkTarget);
}
protected override byte[] InnerSerialize()
{
MemoryStream str = new();
str.WriteByte(ENTRY_LINK_ID);
str.Write(LinkTargetId.ToByteArray());
return str.ToArray();
}
}

View File

@@ -4,4 +4,5 @@ public enum PassStoreEntryType
{
Password,
Directory,
Link,
}

View File

@@ -14,20 +14,22 @@ namespace KeyKeeper.PasswordStore;
/// </summary>
public class PassStoreFileAccessor : IPassStore
{
private const ushort FORMAT_VERSION_MAJOR = 0;
private const ushort FORMAT_VERSION_MINOR = 0;
private const ushort FORMAT_VERSION_MAJOR = 1;
private const ushort FORMAT_VERSION_MINOR = 1;
private static readonly byte[] FORMAT_MAGIC = [0xf5, 0x3a, 0xa4, 0xb7, 0xeb, 0xd9, 0xc2, 0x12];
private string filename;
private byte[]? key;
private InnerEncryptionInfo? innerCrypto;
private OuterEncryptionHeader? outerCryptoHdr;
private PassStoreEntry? root;
private PassStoreEntryGroup? root;
private Dictionary<Guid, PassStoreEntry> allEntries;
public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions)
{
this.filename = filename;
this.key = null;
this.allEntries = new();
if (!create)
{
CheckStoreFile();
@@ -42,14 +44,14 @@ public class PassStoreFileAccessor : IPassStore
get { return key == null; }
}
public IPassStoreDirectory GetRootDirectory()
public PassStoreEntryGroup GetRootDirectory()
{
if (Locked)
throw new InvalidOperationException();
return (IPassStoreDirectory)root!;
return root!;
}
public IPassStoreDirectory? GetGroupByType(byte groupType)
public PassStoreEntryGroup? GetGroupByType(byte groupType)
{
if (Locked)
throw new InvalidOperationException();
@@ -102,7 +104,9 @@ public class PassStoreFileAccessor : IPassStore
case FILE_FIELD_CONFIG:
break;
case FILE_FIELD_STORE:
this.root = PassStoreEntry.ReadFromStream(cryptoReader);
root = PassStoreEntry.ReadFromStream(cryptoReader) as PassStoreEntryGroup;
AddEntriesToDict(root!);
ResolveLinks();
break;
case FILE_FIELD_END:
end = true;
@@ -123,6 +127,8 @@ public class PassStoreFileAccessor : IPassStore
Save();
Array.Fill<byte>(key!, 0);
key = null;
root = null;
allEntries = new();
}
public void Save()
@@ -163,6 +169,97 @@ public class PassStoreFileAccessor : IPassStore
file.SetLength(file.Position);
}
public void AddEntry(PassStoreEntryGroup group, PassStoreEntry entry)
{
if (Locked) throw new InvalidOperationException("store locked");
entry.Parent = group;
group.ChildEntries.Add(entry);
if (entry is PassStoreEntryLink link)
{
link.LinkTarget ??= allEntries[link.LinkTargetId];
if (link.LinkTarget == null)
throw new ArgumentException("invalid link target");
PassStoreEntry t = link.LinkTarget;
if (!t.Backlinks.Contains(entry))
t.Backlinks.Add(entry);
}
allEntries[entry.Id] = entry;
}
public bool DeleteEntry(PassStoreEntryGroup? group, Guid id)
{
if (Locked) throw new InvalidOperationException("store locked");
if (group == null)
group = allEntries[id]?.Parent;
if (group == null || group.ChildEntries == null)
return false;
var ch = group.ChildEntries;
for (int i = 0; i < ch.Count; i++)
{
PassStoreEntry child = ch[i];
if (child.Id == id)
{
if (child is PassStoreEntryLink lnk && lnk.LinkTarget != null)
{
lnk.LinkTarget.Backlinks.Remove(lnk);
}
while (child.Backlinks.Count > 0)
{
PassStoreEntry bl = child.Backlinks[0];
if (bl is PassStoreEntryLink)
DeleteEntry(bl.Parent, bl.Id);
}
allEntries.Remove(child.Id);
ch.RemoveAt(i);
return true;
}
}
return false;
}
public void UpdateEntry(PassStoreEntryGroup? group, Guid id, PassStoreEntry entry)
{
if (Locked) throw new InvalidOperationException("store locked");
if (group == null)
group = allEntries[id]?.Parent;
if (group == null || group.ChildEntries == null)
return;
entry.Parent = group;
entry.Backlinks = allEntries[id].Backlinks;
foreach (PassStoreEntry bl in entry.Backlinks)
{
if (bl is PassStoreEntryLink lnk)
{
lnk.LinkTarget = entry;
}
}
var ch = group.ChildEntries;
for (int i = 0; i < ch.Count; i++)
{
if (ch[i].Id == id)
{
allEntries.Remove(ch[i].Id); // убрать
allEntries[entry.Id] = entry;
ch[i] = entry;
return;
}
}
}
public PassStoreEntry GetEntryById(Guid id)
{
if (Locked) throw new InvalidOperationException("store locked");
return allEntries[id];
}
/// <summary>
/// Проверяет внешнюю целостность файла хранилища, то есть:
/// 1. совпадает сигнатура (magic number)
@@ -213,6 +310,30 @@ public class PassStoreFileAccessor : IPassStore
OuterEncryptionUtil.CheckOuterEncryptionHeader(file);
}
private void AddEntriesToDict(PassStoreEntryGroup root)
{
allEntries.Add(root.Id, root);
foreach (PassStoreEntry entry in root.ChildEntries)
{
if (entry is PassStoreEntryGroup group)
AddEntriesToDict(group);
else
allEntries.Add(entry.Id, entry);
}
}
private void ResolveLinks()
{
foreach (var kv in allEntries)
{
if (kv.Value is PassStoreEntryLink lnk)
{
lnk.LinkTarget = allEntries[lnk.LinkTargetId];
lnk.LinkTarget.Backlinks.Add(lnk);
}
}
}
private void CreateNewAndUnlock(StoreCreationOptions options)
{
using FileStream file = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -257,9 +378,12 @@ public class PassStoreFileAccessor : IPassStore
cryptoWriter.Flush();
cryptoWriter.Dispose();
AddEntriesToDict(root);
ResolveLinks();
}
private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w)
private PassStoreEntryGroup WriteInitialStoreTree(OuterEncryptionWriter w)
{
PassStoreEntryGroup defaultGroup = new(
Guid.NewGuid(),
@@ -269,6 +393,15 @@ public class PassStoreFileAccessor : IPassStore
"",
GROUP_TYPE_DEFAULT
);
PassStoreEntryGroup favourites = new(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
Guid.Empty,
"",
GROUP_TYPE_FAVOURITES
);
PassStoreEntryGroup root = new(
Guid.NewGuid(),
DateTime.UtcNow,
@@ -276,9 +409,10 @@ public class PassStoreFileAccessor : IPassStore
Guid.Empty,
"",
GROUP_TYPE_ROOT,
[defaultGroup]
[defaultGroup, favourites]
);
defaultGroup.Parent = root;
favourites.Parent = root;
root.WriteToStream(w);
return root;
}

View File

@@ -323,7 +323,6 @@ public class EntryEditViewModel : ViewModelBase
}
catch (Exception)
{
// Validation should have caught this, but handle gracefully
totp = null;
}
}
@@ -332,7 +331,7 @@ public class EntryEditViewModel : ViewModelBase
id,
created,
DateTime.UtcNow,
EntryIconType.DEFAULT,
BuiltinEntryIconType.DEFAULT,
EntryName.Trim(),
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() },
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password },

View File

@@ -10,18 +10,43 @@ namespace KeyKeeper.ViewModels;
public class UnlockedRepositoryViewModel : ViewModelBase
{
private IPassStore passStore;
private IPassStoreDirectory currentDirectory;
private PassStoreEntryGroup currentDirectory;
private PassStoreEntryGroup? rootDirectory;
private bool hasUnsavedChanges;
private DispatcherTimer? _totpRefreshTimer;
private Dictionary<Guid, string> _totpCodes = new();
public IEnumerable<PassStoreEntryPassword> Passwords
public IEnumerable<PassStoreEntry> Passwords
{
get
{
return currentDirectory
.Where(entry => entry is PassStoreEntryPassword)
.Select(entry => (entry as PassStoreEntryPassword)!);
.Where(entry => entry is PassStoreEntryPassword || entry is PassStoreEntryLink lnk && lnk.LinkTarget is PassStoreEntryPassword);
}
}
public IEnumerable<PassStoreEntryGroup> PasswordGroups
{
get
{
if (rootDirectory == null) return [];
return rootDirectory
.Where(entry => entry is PassStoreEntryGroup)
.Select(entry => (entry as PassStoreEntryGroup)!);
}
}
public PassStoreEntryGroup SelectedPasswordGroup
{
get
{
return PasswordGroups.First(group => group == currentDirectory);
}
set
{
if (PasswordGroups.Any(group => group == value))
{
ChangeDirectory(value);
}
}
}
@@ -35,10 +60,11 @@ public class UnlockedRepositoryViewModel : ViewModelBase
}
}
public UnlockedRepositoryViewModel(IPassStore store, IPassStoreDirectory directory)
public UnlockedRepositoryViewModel(IPassStore store, PassStoreEntryGroup group)
{
passStore = store;
currentDirectory = directory;
currentDirectory = group;
rootDirectory = group.Parent;
HasUnsavedChanges = false;
InitializeTotpCodes();
StartTotpRefreshTimer();
@@ -62,24 +88,118 @@ public class UnlockedRepositoryViewModel : ViewModelBase
{
if (entry is PassStoreEntryPassword)
{
currentDirectory.AddEntry(entry);
if (currentDirectory.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT)
{
passStore.AddEntry(currentDirectory, entry);
} else
{
// добавляем в All Passwords, но оставляем ссылку в текущей папке
passStore.AddEntry(passStore.GetGroupByType(FileFormatConstants.GROUP_TYPE_DEFAULT)!, entry);
passStore.AddEntry(currentDirectory, new PassStoreEntryLink(Guid.NewGuid(), DateTime.Now, DateTime.Now, entry.Id, entry));
}
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
}
public void AddGroup(PassStoreEntryGroup group)
{
if (rootDirectory == null)
return;
passStore.AddEntry(rootDirectory, group);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void DeleteEntry(Guid id)
{
currentDirectory.DeleteEntry(id);
PassStoreEntry entry = passStore.GetEntryById(id);
if (entry is PassStoreEntryLink lnk)
{
passStore.DeleteEntry(lnk.LinkTarget!.Parent, lnk.LinkTargetId);
passStore.DeleteEntry(currentDirectory, id);
} else
{
passStore.DeleteEntry(currentDirectory, id);
}
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void UpdateEntry(PassStoreEntryPassword updatedEntry)
public bool AddEntryToGroup(PassStoreEntry entry, PassStoreEntryGroup targetGroup)
{
currentDirectory.UpdateEntry(updatedEntry.Id, updatedEntry);
PassStoreEntryPassword? pwd = FollowLinkIfNeeded(entry);
if (pwd == null) return false;
foreach (var bl in pwd.Backlinks)
{
if (bl is PassStoreEntryLink lnk && lnk.Parent == targetGroup)
return false;
}
passStore.AddEntry(targetGroup, new PassStoreEntryLink(Guid.NewGuid(), DateTime.Now, DateTime.Now, pwd.Id, pwd));
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
return true;
}
public void RemoveEntryFromGroup(PassStoreEntry entry)
{
if (currentDirectory.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT)
return;
passStore.DeleteEntry(currentDirectory, entry.Id);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void RemoveEntryFromFavourites(PassStoreEntry entry)
{
var favouritesGroup = PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
if (favouritesGroup == null)
return;
PassStoreEntryPassword? pwd = FollowLinkIfNeeded(entry);
if (pwd == null)
return;
var linkToRemove = pwd.Backlinks.FirstOrDefault(bl => bl is PassStoreEntryLink lnk && lnk.Parent == favouritesGroup);
if (linkToRemove != null)
{
passStore.DeleteEntry(favouritesGroup, linkToRemove.Id);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
}
public void UpdateEntry(PassStoreEntryPassword updatedEntry)
{
passStore.UpdateEntry(null, updatedEntry.Id, updatedEntry);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void UpdateGroup(PassStoreEntryGroup group, string newName, Guid newIconType)
{
group.Name = newName;
group.IconType = newIconType;
group.ModificationDate = DateTime.UtcNow;
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void DeleteGroup(PassStoreEntryGroup group)
{
if (rootDirectory == null)
return;
passStore.DeleteEntry(rootDirectory, group.Id);
if (currentDirectory == group && rootDirectory != null)
{
ChangeDirectory(rootDirectory.ChildGroups.FirstOrDefault() ?? rootDirectory);
}
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void Save()
@@ -88,10 +208,36 @@ public class UnlockedRepositoryViewModel : ViewModelBase
HasUnsavedChanges = false;
}
public static PassStoreEntryPassword? FollowLinkIfNeeded(PassStoreEntry entry)
{
if (entry is PassStoreEntryPassword passwordEntry)
{
return passwordEntry;
}
else if (entry is PassStoreEntryLink link && link.LinkTarget is PassStoreEntryPassword t)
{
return t;
}
return null;
}
private void ChangeDirectory(PassStoreEntryGroup newDir)
{
if (newDir == currentDirectory)
return;
currentDirectory = newDir;
InitializeTotpCodes();
StartTotpRefreshTimer();
OnPropertyChanged(nameof(SelectedPasswordGroup));
OnPropertyChanged(nameof(Passwords));
}
private void InitializeTotpCodes()
{
_totpCodes.Clear();
foreach (var entry in Passwords.Where(e => e.Totp != null))
foreach (var entry in Passwords.Select(FollowLinkIfNeeded).Where(e => e?.Totp != null))
{
_totpCodes[entry.Id] = TotpCodeGenerator.GenerateCode(entry.Totp!);
}
@@ -102,6 +248,10 @@ public class UnlockedRepositoryViewModel : ViewModelBase
// Calculate time until next TOTP period boundary
int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh();
if (_totpRefreshTimer != null)
{
_totpRefreshTimer.Stop();
}
_totpRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(secondsUntilNextCode)
@@ -129,7 +279,7 @@ public class UnlockedRepositoryViewModel : ViewModelBase
private int CalculateSecondsUntilNextTotpRefresh()
{
// Find the minimum seconds until next code change across all TOTP entries
var totpEntries = Passwords.Where(e => e.Totp != null).ToList();
var totpEntries = Passwords.Select(FollowLinkIfNeeded).Where(e => e?.Totp != null).ToList();
if (totpEntries.Count == 0)
return 60; // Default to 60 seconds if no TOTP entries

View File

@@ -16,6 +16,7 @@
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8"
Margin="0,16,0,0">
<Button Content="Save"

View File

@@ -0,0 +1,38 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.ConfirmationDialog"
Title="Confirm Action"
Background="White"
Icon="/Assets/icon.ico"
Width="380" Height="180"
WindowStartupLocation="CenterOwner"
CanResize="False">
<Grid Margin="20" RowDefinitions="*,*,Auto">
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="18"
FontWeight="Bold"
Foreground="#2328C4"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="1"
x:Name="MessageText"
FontSize="14"
Foreground="Black"
TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="10" VerticalAlignment="Bottom">
<Button Content="Cancel"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="ConfirmButton"
Content="Confirm"
Width="80"
Click="ConfirmButton_Click"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace KeyKeeper.Views;
public partial class ConfirmationDialog : Window
{
public bool Confirmed { get; private set; }
public ConfirmationDialog()
{
InitializeComponent();
}
public void SetContent(string title, string message)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = "Confirm";
}
public void SetContent(string title, string message, string confirmButtonText)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = confirmButtonText;
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = false;
Close();
}
private void ConfirmButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = true;
Close();
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Globalization;
using Avalonia.Data;
using Avalonia.Data.Converters;
using KeyKeeper.PasswordStore;
namespace KeyKeeper.Views.Converters;
public class PasswordEntryConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
PassStoreEntry? entry = value as PassStoreEntry;
if (entry is PassStoreEntryLink link)
{
entry = link.LinkTarget;
}
if (entry == null)
return value;
if (parameter is string propertyPath && !string.IsNullOrEmpty(propertyPath))
{
try
{
object? current = entry;
foreach (var prop in propertyPath.Split('.'))
{
if (current == null)
return null;
var propInfo = current.GetType().GetProperty(prop);
current = propInfo?.GetValue(current);
}
return current;
}
catch
{
return value;
}
}
return entry;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return BindingNotification.UnsetValue;
}
}

View File

@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.CreateGroupDialog"
Title="Create New Group"
Background="White"
Icon="/Assets/icon.ico"
Width="420" Height="320"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="20" Spacing="16">
<TextBlock x:Name="TitleText"
Text="Create new group"
FontSize="20"
FontWeight="Bold"
Foreground="#2328C4"/>
<StackPanel Spacing="6">
<TextBlock Text="Group name" FontWeight="SemiBold" Foreground="Black" />
<TextBox x:Name="NameTextBox"
Watermark="Enter group name"
Padding="10,8"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Icon" FontWeight="SemiBold" Foreground="Black" />
<ListBox x:Name="IconListBox"
Height="80"
SelectionMode="Single"
Background="#F5F5F5">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:CompileBindings="False">
<Svg Path="{Binding IconPath}" Width="48" Height="48" Margin="6"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<TextBlock x:Name="ErrorText"
FontSize="12"
Foreground="Red"
Text=""
IsVisible="False"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Cancel"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="ActionButton"
Content="Create"
Width="80"
IsEnabled="False"
Click="ActionButton_Click"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using KeyKeeper.Models;
using KeyKeeper.PasswordStore;
namespace KeyKeeper.Views;
public partial class CreateGroupDialog : Window
{
public GroupEditData? GroupData { get; private set; }
private record IconChoice(Guid Id)
{
public string IconPath => $"avares://KeyKeeper/Assets/builtin-entry-icon-{Id}.svg";
}
public CreateGroupDialog()
{
InitializeComponent();
var icons = new List<IconChoice>
{
new(BuiltinEntryIconType.KEY),
};
IconListBox.ItemsSource = icons;
IconListBox.SelectedIndex = 0;
NameTextBox.TextChanged += (_, _) => UpdateActionButtonState();
KeyDown += OnKeyDown;
}
public void SetupForEdit(PassStoreEntryGroup group)
{
TitleText.Text = "Edit group";
ActionButton.Content = "Save";
NameTextBox.Text = group.Name;
for (int i = 0; i < IconListBox.ItemCount; i++)
{
if (IconListBox.Items[i] is IconChoice choice && choice.Id == group.IconType)
{
IconListBox.SelectedIndex = i;
break;
}
}
}
private void UpdateActionButtonState()
{
ActionButton.IsEnabled = !string.IsNullOrWhiteSpace(NameTextBox.Text);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
Close();
else if (e.Key == Key.Enter && ActionButton.IsEnabled)
Submit();
}
private void ActionButton_Click(object? sender, RoutedEventArgs e) => Submit();
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
GroupData = null;
Close();
}
private void Submit()
{
var name = NameTextBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(name))
{
ErrorText.Text = "Name cannot be empty";
ErrorText.IsVisible = true;
return;
}
var iconType = BuiltinEntryIconType.DEFAULT;
if (IconListBox.SelectedItem is IconChoice choice)
iconType = choice.Id;
GroupData = new GroupEditData(name, iconType);
Close();
}
}

View File

@@ -15,6 +15,7 @@
<Window.Resources>
<converters:TotpCodeConverter x:Key="TotpCodeConverter" />
<converters:PasswordEntryConverter x:Key="PasswordEntryConverter" />
<converters:StringPresenceToOpacityConverter x:Key="StringPresenceToOpacityConverter" />
</Window.Resources>
@@ -57,22 +58,32 @@
</StackPanel>
</Border>
<!-- Рамочка -->
<!-- <Border BorderBrush="Gray"
BorderThickness="1"
CornerRadius="5"
Padding="20"
Background="#F5F5F5"
HorizontalAlignment="Left">
<!-- Группы паролей -->
<TreeView x:Name="GroupTree"
ItemsSource="{Binding PasswordGroups}"
SelectedItem="{Binding SelectedPasswordGroup}"
Background="#FFFFFFFF"
SelectionMode="Single"
ScrollViewer.HorizontalScrollBarVisibility="Disabled" >
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding ChildGroups}">
<Border Background="Transparent"
DoubleTapped="Entry_DoubleTapped">
<TextBlock Text="{Binding DisplayName}"
Foreground="Black"
TextTrimming="CharacterEllipsis"
MaxWidth="180" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="groupCtxMenuEdit" Header="Edit" Click="GroupContextMenuItem_Click" />
<MenuItem Name="groupCtxMenuDelete" Header="Delete" Click="GroupContextMenuItem_Click" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<StackPanel HorizontalAlignment="Left">
<Button Content="All Passwords"
Width="120"
Height="30"
HorizontalAlignment="Left"/>
</StackPanel>
</Border> -->
<!-- Save Passwords -->
<Button Content="Save Passwords"
Classes="accentSidebarButton"
@@ -96,6 +107,14 @@
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
<!-- New Group -->
<Button Content="New Group"
Classes="accentSidebarButton"
Click="AddGroupButton_Click"
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
</StackPanel>
</Border>
@@ -122,11 +141,11 @@
Margin="10"
HorizontalAlignment="Center">
<Svg Path="{Binding IconPath}" Width="48" Height="48"/>
<TextBlock Text="{Binding Name}"
<Svg Path="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=IconPath}" Width="48" Height="48"/>
<TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Name}"
HorizontalAlignment="Center"
Foreground="Black" />
<TextBlock Text="{Binding Username.Value}"
<TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Username.Value}"
Foreground="#666"
HorizontalAlignment="Center" />
@@ -138,17 +157,21 @@
Margin="0,4,0,0">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource TotpCodeConverter}">
<Binding />
<Binding Converter="{StaticResource PasswordEntryConverter}" />
<Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ListBox}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<Border.ContextMenu>
<ContextMenu>
<ContextMenu Opened="EntryContextMenu_Opening">
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuEdit" Header="Edit" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuAddToGroup" Header="Add to group" />
<MenuItem Name="entryCtxMenuRemoveFromGroup" Header="Remove from this group" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuAddToFavourites" Header="Add to Favourites" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuRemoveFromFavourites" Header="Remove from Favourites" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/>
</ContextMenu>
</Border.ContextMenu>

View File

@@ -1,9 +1,12 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input;
using KeyKeeper.PasswordStore;
using KeyKeeper.Models;
using KeyKeeper.ViewModels;
using Avalonia.VisualTree;
using Avalonia.Controls.Presenters;
@@ -14,6 +17,7 @@ public partial class RepositoryWindow : Window
{
private bool allowClose;
private bool closeConfirmationShown;
private PassStoreEntry? _contextMenuEntry;
public RepositoryWindow(RepositoryWindowViewModel model)
{
@@ -118,6 +122,37 @@ public partial class RepositoryWindow : Window
}
}
private async void EditEntry(RepositoryWindowViewModel vm1, UnlockedRepositoryViewModel vm2, PassStoreEntry entry)
{
if (entry == null)
{
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected");
return;
}
EntryEditWindow dialog = new();
PassStoreEntry realEntry = entry;
if (realEntry is PassStoreEntryLink lnk)
realEntry = lnk.LinkTarget!;
dialog.SetEntry((realEntry as PassStoreEntryPassword)!);
vm1.StopLockTimer();
await dialog.ShowDialog(this);
vm1.StartLockTimer();
if (dialog.EditedEntry != null)
{
if (entry is PassStoreEntryLink l)
l.LinkTarget = dialog.EditedEntry;
vm2.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
}
}
private async void EditEntryButton_Click(object sender, RoutedEventArgs args)
{
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
@@ -129,15 +164,17 @@ public partial class RepositoryWindow : Window
return;
}
var selectedEntry = listBox.SelectedItem as PassStoreEntryPassword;
if (selectedEntry == null)
{
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected");
return;
}
PassStoreEntry? entry = listBox.SelectedItem as PassStoreEntry;
if (entry == null) return;
EditEntry(vm_, vm, entry);
}
}
EntryEditWindow dialog = new();
dialog.SetEntry(selectedEntry);
private async void AddGroupButton_Click(object sender, RoutedEventArgs args)
{
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
{
CreateGroupDialog dialog = new();
vm_.StopLockTimer();
@@ -145,10 +182,18 @@ public partial class RepositoryWindow : Window
vm_.StartLockTimer();
if (dialog.EditedEntry != null)
if (dialog.GroupData != null)
{
vm.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
var group = new PassStoreEntryGroup(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
dialog.GroupData.IconType,
dialog.GroupData.Name,
FileFormatConstants.GROUP_TYPE_SIMPLE
);
vm.AddGroup(group);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group created");
}
}
}
@@ -163,10 +208,9 @@ public partial class RepositoryWindow : Window
private void Entry_DoubleTapped(object sender, TappedEventArgs args)
{
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd)
if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent)
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
CopyPassword(ent);
}
}
@@ -174,49 +218,248 @@ public partial class RepositoryWindow : Window
{
if (args.Key == Key.C && args.KeyModifiers == KeyModifiers.Control)
{
if (sender is ListBox list && list.SelectedItem is PassStoreEntryPassword pwd)
if (sender is ListBox list && list.SelectedItem is PassStoreEntry ent)
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
CopyPassword(ent);
}
}
}
private void CopyPassword(PassStoreEntry ent)
{
PassStoreEntryPassword? pwd = null;
if (ent is PassStoreEntryPassword p) pwd = p;
else if (ent is PassStoreEntryLink lnk && lnk.LinkTarget is PassStoreEntryPassword p1) pwd = p1;
if (pwd == null) return;
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
private void EntryContextMenu_Opening(object? sender, RoutedEventArgs args)
{
if (sender is not ContextMenu contextMenu || DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
_contextMenuEntry = null;
if (contextMenu.Parent?.Parent is Border border && border.DataContext is PassStoreEntry entry)
{
_contextMenuEntry = entry;
}
var addToGroupItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuAddToGroup");
var removeFromGroupItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuRemoveFromGroup");
var addToFavouritesItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuAddToFavourites");
var removeFromFavouritesItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuRemoveFromFavourites");
var isNonDefaultGroup = pageVm.SelectedPasswordGroup.GroupType != FileFormatConstants.GROUP_TYPE_DEFAULT;
if (removeFromGroupItem != null)
{
removeFromGroupItem.IsVisible = isNonDefaultGroup;
}
// Check if entry is in Favourites group
var favouritesGroup = pageVm.PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
var isInFavourites = false;
if (favouritesGroup != null && _contextMenuEntry != null)
{
PassStoreEntryPassword? pwd = UnlockedRepositoryViewModel.FollowLinkIfNeeded(_contextMenuEntry);
if (pwd != null)
{
isInFavourites = pwd.Backlinks.Any(bl => bl is PassStoreEntryLink lnk && lnk.Parent == favouritesGroup);
}
}
if (addToFavouritesItem != null)
addToFavouritesItem.IsVisible = !isInFavourites;
if (removeFromFavouritesItem != null)
removeFromFavouritesItem.IsVisible = isInFavourites;
if (addToGroupItem == null)
return;
addToGroupItem.Items.Clear();
var nonDefaultGroups = pageVm.PasswordGroups
.Where(g => g.GroupType != FileFormatConstants.GROUP_TYPE_DEFAULT)
.ToList();
EventHandler<RoutedEventArgs> onSubmenuClick = (sender, args) => AddToGroup_Click(sender, args, _contextMenuEntry!);
foreach (var group in nonDefaultGroups)
{
var menuItem = new MenuItem
{
Header = group.DisplayName,
Tag = group
};
menuItem.Click += onSubmenuClick;
addToGroupItem.Items.Add(menuItem);
}
}
private void AddToGroup_Click(object? sender, RoutedEventArgs args, PassStoreEntry entry)
{
if (sender is not MenuItem item || item.Tag is not PassStoreEntryGroup targetGroup)
return;
if (entry == null)
return;
if (DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
var notificationHost = this.FindControlRecursive<ToastNotificationHost>("NotificationHost");
if (pageVm.AddEntryToGroup(entry, targetGroup))
notificationHost?.Show($"Added to {targetGroup.DisplayName}");
else
notificationHost?.Show($"This entry is already in {targetGroup.DisplayName}!");
_contextMenuEntry = null;
}
private async void GroupContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is not StyledElement s || s.DataContext is not PassStoreEntryGroup group)
return;
if (DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
if (group.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT ||
group.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES ||
group.GroupType == FileFormatConstants.GROUP_TYPE_ROOT)
return;
if (s.Name == "groupCtxMenuEdit")
{
await EditGroup(vm, pageVm, group);
}
else if (s.Name == "groupCtxMenuDelete")
{
await DeleteGroup(group);
}
}
private async Task EditGroup(RepositoryWindowViewModel vm, UnlockedRepositoryViewModel pageVm, PassStoreEntryGroup group)
{
CreateGroupDialog dialog = new();
dialog.SetupForEdit(group);
vm.StopLockTimer();
await dialog.ShowDialog(this);
vm.StartLockTimer();
if (dialog.GroupData != null)
{
pageVm.UpdateGroup(group, dialog.GroupData.Name, dialog.GroupData.IconType);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group updated");
}
}
private async Task DeleteGroup(PassStoreEntryGroup group)
{
ConfirmationDialog confirmDialog = new();
confirmDialog.SetContent(
"Delete Group",
$"Are you sure you want to delete the group '{group.DisplayName}'? This action cannot be undone.",
"Delete"
);
if (DataContext is not RepositoryWindowViewModel vm)
return;
vm.StopLockTimer();
await confirmDialog.ShowDialog(this);
vm.StartLockTimer();
if (confirmDialog.Confirmed)
{
if (vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteGroup(group);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group deleted");
}
}
}
private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd)
if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent)
{
PassStoreEntryPassword? pwd = UnlockedRepositoryViewModel.FollowLinkIfNeeded(ent);
if (pwd == null) return;
if (s.Name == "entryCtxMenuCopyUsername")
{
Clipboard!.SetTextAsync(pwd.Username.Value);
await Clipboard!.SetTextAsync(pwd.Username.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
}
else if (s.Name == "entryCtxMenuCopyPassword")
{
Clipboard!.SetTextAsync(pwd.Password.Value);
await Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
else if (s.Name == "entryCtxMenuEdit")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
EntryEditWindow dialog = new();
dialog.SetEntry(pwd);
vm.StopLockTimer();
await dialog.ShowDialog(this);
vm.StartLockTimer();
if (dialog.EditedEntry != null)
EditEntry(vm, pageVm, ent);
}
}
else if (s.Name == "entryCtxMenuRemoveFromGroup")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.RemoveEntryFromGroup(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Removed from group");
}
}
else if (s.Name == "entryCtxMenuAddToFavourites")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
var favouritesGroup = pageVm.PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
if (favouritesGroup != null)
{
pageVm.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
if (pageVm.AddEntryToGroup(ent, favouritesGroup))
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Added to Favourites");
else
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Already in Favourites");
}
}
}
else if (s.Name == "entryCtxMenuRemoveFromFavourites")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.RemoveEntryFromFavourites(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Removed from Favourites");
}
}
else if (s.Name == "entryCtxMenuDelete")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteEntry(pwd.Id);
pageVm.DeleteEntry(ent.Id);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry deleted");
}
}