/
staeer
/
Zapominalka
Обзор
Документация
Войти
/
staeer
/
Zapominalka
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
Program.cs
6 152 строки
223 KB
staeer
Zapominalka 0.4.8:
10 июл 2026, 10:46
10 июл 2026, 10:46
365e67c
Код
Авторство
О чём код?
// Zapominalka // Версия: 0.4.8 // // Что делает: // Простая Windows-запоминалка для паролей, секретов, произвольных полей и текстовых TXT/MD-записей. // Работает без облака, без браузерных расширений, без автозаполнения и без телеметрии. // Данные хранятся в выбранной папке: AppData, portable data\ рядом с exe или пользовательский/сетевой путь из Zapominalka.paths.json. // Файл vault.lzdb — это зашифрованный JSON: AES-GCM + ключ из мастер-пароля через PBKDF2-SHA256. // // Как пользоваться: // 1. При первом запуске программа попросит создать мастер-пароль. // 2. Добавляй записи слева кнопкой "Добавить". // 3. Редактируй поля справа и жми "Сохранить". // 4. Свои поля можно добавлять с типами: text, secret, password, email, phone, url, notes. // 5. TXT/MD-файл можно импортировать как отдельную запись-документ и редактировать внутри сейфа. // 6. Секретные поля скрываются, их можно показать и скопировать. // 7. Backup копирует vault.lzdb в папку backups\ внутри выбранной папки данных. // 8. Экспорт в .md/.txt делает НЕЗАШИФРОВАННЫЙ файл — это только для ручного переноса/печати. // 9. Ctrl+колесо мыши над текстовым полем меняет размер шрифта только в TextBox-полях. // 10. Размер шрифта и тема окна сохраняются в settings.json внутри выбранной папки данных. // 11. Мастер-пароль можно сменить из программы: перед перешифровкой создаётся backup. // 12. Генератор пароля вынесен отдельной кнопкой и не меняет поля случайным нажатием. // 13. Генератор открывается отдельным НЕмодальным окном: можно сгенерировать, скопировать и сразу вставить в поле. // 14. Настройки генератора сохраняются в settings.json. // 15. В строке состояния показываются режим базы, число записей, размер vault и короткий путь к базе; полный путь есть в подсказке. // 16. Добавлен импорт CSV из Enpass: title/login/pass/url/email/telefon/notes и прочие пары label/value. // 17. Открытие сейфа вынесено в фон: окно не выглядит зависшим, пока идёт PBKDF2/расшифровка. // 18. После открытия список записей грузится быстро, карточка отрисовывается только после выбора. // 19. Названия карточек в списке увеличены, путь к базе вынесен отдельной строкой в статусе. // 20. URL-поля стали активными: кнопка «Открыть», двойной клик и Ctrl+Enter открывают ссылку в браузере. // 21. При переходе с изменённой карточки программа спрашивает: сохранить, не сохранять или отменить переход. // 22. Shift + кнопка «Удалить» удаляет текущую карточку без предупреждения. // 23. Приложение переименовано в Zapominalka; старые пути LocalZapominalka подхватываются для мягкого переезда. // 24. На входе кнопка «Новый сейф» защищена: при найденной базе сначала требуется текущий мастер-пароль. // 25. Существующий vault.lzdb больше нельзя увести в backup и заменить пустым сейфом простым нажатием кнопки. // 26. Выбор записи больше не пересобирает весь список; открывается только редактор выбранной карточки. // 27. После удаления программа выбирает соседнюю карточку и сохраняет позицию списка, а не прыгает в начало. // 28. При открытии карточки сбрасывается горизонтальная/вертикальная прокрутка редактора, чтобы подписи полей не уезжали за экран. // 29. Правая карточка больше не пересоздаётся на каждый клик: для обычных записей и TXT/MD редактор переиспользуется, меняются только значения полей. // 30. У сейфа появилось собственное название: оно хранится внутри зашифрованного vault.lzdb. // 31. Название сейфа показывается в заголовке окна и в компактной верхней панели; менять можно кнопкой «Название» слева. // 32. Верхняя панель сейфа сделана компактной, чтобы интерфейс не разъезжался при крупном шрифте полей. // 33. Названия основных полей карточки стали редактируемыми и сохраняются внутри зашифрованного vault.lzdb. // 34. Кнопки «Встав.» теперь перезаписывают всё поле, а не добавляют текст в позицию курсора. // 35. Добавлен отдельный Zapominalka.theme.json для минимальной настройки цветов и шрифтов. // 36. JSON темы теперь открывается строго в Блокноте, а не в Visual Studio/VS Code по ассоциации Windows. // 37. После закрытия Блокнота тема перечитывается из JSON и применяется автоматически. // 38. Окно цветов стало понятнее: выбор шрифта через FontDialog, кнопка «Сохранить и применить», свои цвета включаются явно. // 39. Запуск стал независимым от AppData: Zapominalka.paths.json реально управляет папкой данных, а settings/theme/vault лежат в выбранной папке. // 40. Если Zapominalka.paths.json битый, программа больше молча не уходит в AppData, а показывает ошибку запуска. // 41. Полностью отключён автоматический AppData-фолбэк: без явного custom пути используется portable data\ рядом с exe; AppData больше не выбирается сам. // 42. Добавлены категории записей: фильтр слева, поле категории в карточке/T-записи, сортировка по категории, короткий префикс T: для текстовых записей. // // Сборка из консоли Windows в папке проекта: // dotnet run --project Zapominalka.csproj // // Сборка компактного exe-файла: // dotnet publish Zapominalka.csproj -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o publish using System; using System.Collections.Generic; using System.Drawing; using System.IO; using System.Linq; using Microsoft.Win32; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using System.Windows.Forms; namespace Zapominalka; internal static class Program { [STAThread] private static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); try { AppPaths.Configure(); AppPaths.EnsureDataDirectories(); AppPaths.MigrateLegacyPortableVaultIfNeeded(); AppSettingsStore.Load(); ThemeSettingsStore.Load(); } catch (Exception ex) { MessageBox.Show( "Не удалось подготовить папку данных Zapominalka:" + Environment.NewLine + ex.Message, "Ошибка запуска", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } using var login = new LoginForm(AppPaths.VaultFile); if (login.ShowDialog() != DialogResult.OK || login.Session == null) return; Application.Run(new MainForm(login.Session)); } } internal static class AppPaths { public static readonly string AppDir = AppContext.BaseDirectory; public static readonly string PathConfigFile = Path.Combine(AppDir, "Zapominalka.paths.json"); public static readonly string CurrentDirPathConfigFile = Path.Combine( Path.GetFullPath(Environment.CurrentDirectory), "Zapominalka.paths.json"); public static readonly string LegacyPathConfigFile = Path.Combine(AppDir, "LocalZapominalka.paths.json"); public static readonly string LegacyCurrentDirPathConfigFile = Path.Combine( Path.GetFullPath(Environment.CurrentDirectory), "LocalZapominalka.paths.json"); public static readonly string PortableFlagFile = Path.Combine(AppDir, "portable.flag"); public static readonly string PortableDataDir = Path.Combine(AppDir, "data"); public static readonly string DataDirPathConfigFile = Path.Combine(PortableDataDir, "Zapominalka.paths.json"); // AppData оставлен только как legacy-путь для ручного переезда. // Автоматически новый запуск туда больше не уходит. public static readonly string DefaultAppDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Zapominalka"); public static readonly string LegacyAppDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LocalZapominalka"); // Эти пути вычисляются при запуске из Zapominalka.paths.json / portable.flag. // Базу можно держать локально, рядом с программой или в сетевой папке. public static string DataDir { get; private set; } = PortableDataDir; public static string VaultFile { get; private set; } = Path.Combine(PortableDataDir, "vault.lzdb"); public static string SettingsFile { get; private set; } = Path.Combine(PortableDataDir, "settings.json"); public static string ThemeFile { get; private set; } = Path.Combine(PortableDataDir, "Zapominalka.theme.json"); public static string BackupsDir { get; private set; } = Path.Combine(PortableDataDir, "backups"); public static string ExportsDir { get; private set; } = Path.Combine(PortableDataDir, "exports"); // Старые пути LocalZapominalka нужны для мягкого переезда без потери базы. public static readonly string LegacyVaultFile = Path.Combine(AppDir, "vault.lzdb"); public static readonly string LegacyAppDataVaultFile = Path.Combine(LegacyAppDataDir, "vault.lzdb"); public static readonly string LegacyAppDataSettingsFile = Path.Combine(LegacyAppDataDir, "settings.json"); public static void Configure() { var configFileExists = AppPathConfigStore.FindConfigPath() != ""; var config = AppPathConfigStore.Load(); // portable.flag — быстрый способ включить переносимый режим без ручного JSON. // Если пользователь уже выбрал custom-путь, custom имеет приоритет. if (File.Exists(PortableFlagFile) && !string.Equals(config.Mode, "custom", StringComparison.OrdinalIgnoreCase)) config.Mode = "portable"; ApplyConfig(config); AppPathConfigStore.SetCurrent(config); // Конфиг всегда пишется рядом с exe и копией в папку данных. // Так видно, какой путь реально активен, и программа не откатывается в AppData. AppPathConfigStore.TrySave(config); } public static void ApplyConfig(AppPathConfig config) { var mode = AppPathConfigStore.NormalizeMode(config.Mode); var dataDir = mode switch { "custom" => string.IsNullOrWhiteSpace(config.CustomDataDir) ? throw new InvalidOperationException("В Zapominalka.paths.json выбран режим custom, но custom_data_dir/data_dir пустой.") : ResolveUserPath(config.CustomDataDir), _ => PortableDataDir }; DataDir = dataDir; VaultFile = Path.Combine(DataDir, "vault.lzdb"); SettingsFile = Path.Combine(DataDir, "settings.json"); ThemeFile = Path.Combine(DataDir, "Zapominalka.theme.json"); BackupsDir = Path.Combine(DataDir, "backups"); ExportsDir = Path.Combine(DataDir, "exports"); } public static void EnsureDataDirectories() { Directory.CreateDirectory(DataDir); Directory.CreateDirectory(BackupsDir); Directory.CreateDirectory(ExportsDir); } public static void MigrateLegacyPortableVaultIfNeeded() { // Если новая база уже есть — ничего не трогаем. if (File.Exists(VaultFile)) return; Directory.CreateDirectory(DataDir); // Старый portable-режим: vault.lzdb лежал рядом с exe. // AppData специально НЕ трогаем: пользователь хочет полностью независимые запуски. if (File.Exists(LegacyVaultFile) && !string.Equals(Path.GetFullPath(LegacyVaultFile), Path.GetFullPath(VaultFile), StringComparison.OrdinalIgnoreCase)) { File.Copy(LegacyVaultFile, VaultFile, overwrite: false); MigrateLegacySettingsIfNeeded(); } } private static void MigrateLegacySettingsIfNeeded() { try { if (!File.Exists(SettingsFile) && File.Exists(LegacyAppDataSettingsFile)) File.Copy(LegacyAppDataSettingsFile, SettingsFile, overwrite: false); } catch { // Настройки можно восстановить руками. База важнее. } } public static string ResolveUserPath(string path) { var expanded = Environment.ExpandEnvironmentVariables(path.Trim()); if (string.IsNullOrWhiteSpace(expanded)) return PortableDataDir; if (!Path.IsPathRooted(expanded)) expanded = Path.Combine(AppDir, expanded); return Path.GetFullPath(expanded); } } internal sealed class AppPathConfig { [JsonPropertyName("version")] public int Version { get; set; } = 1; // portable = data\ рядом с exe // custom = путь из custom_data_dir / data_dir, включая сетевой UNC-путь вида \\NAS\Share\Zapominalka // appdata больше не используется автоматически: старые appdata-конфиги приводятся к portable. [JsonPropertyName("mode")] public string Mode { get; set; } = "portable"; [JsonPropertyName("custom_data_dir")] public string CustomDataDir { get; set; } = ""; } internal static class AppPathConfigStore { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; public static AppPathConfig Current { get; private set; } = new(); // Активный файл путей. Важно: при dotnet run он может лежать в папке проекта, // а при обычном запуске — рядом с Zapominalka.exe. public static string ActiveConfigFile { get; private set; } = AppPaths.PathConfigFile; public static string FindConfigPath() { var candidates = new[] { // Жёсткое правило: конфиг путей берём рядом с exe. // CurrentDirectory не используем: при запуске из файлового менеджера он может быть C:\Windows\System32. AppPaths.PathConfigFile, AppPaths.DataDirPathConfigFile, AppPaths.LegacyPathConfigFile }; foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) { if (File.Exists(candidate)) return candidate; } return ""; } public static AppPathConfig Load() { var configPath = FindConfigPath(); ActiveConfigFile = string.IsNullOrWhiteSpace(configPath) ? AppPaths.PathConfigFile : configPath; if (string.IsNullOrWhiteSpace(configPath)) { Current = new AppPathConfig { Version = 2, Mode = "portable", CustomDataDir = "" }; } else { try { Current = LoadStrictOrLoose(configPath); } catch (Exception ex) { throw new InvalidOperationException( "Не удалось прочитать Zapominalka.paths.json." + Environment.NewLine + "Файл: " + configPath + Environment.NewLine + Environment.NewLine + "Для Windows-пути используй двойные слеши или прямые слеши:" + Environment.NewLine + " C:\\Zapominalka" + Environment.NewLine + " C:/Zapominalka" + Environment.NewLine + Environment.NewLine + "Пример:" + Environment.NewLine + "{\"mode\":\"custom\",\"custom_data_dir\":\"C:/Zapominalka\"}", ex); } } NormalizeLoadedConfig(); return Current; } private static AppPathConfig LoadStrictOrLoose(string configPath) { var json = File.ReadAllText(configPath, Encoding.UTF8); try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var config = JsonSerializer.Deserialize<AppPathConfig>(json, JsonOptions) ?? new AppPathConfig(); var aliasPath = ReadStringProperty(root, "data_dir") ?? ReadStringProperty(root, "dataDir") ?? ReadStringProperty(root, "path") ?? ReadStringProperty(root, "folder") ?? ReadStringProperty(root, "vault_dir"); if (!string.IsNullOrWhiteSpace(aliasPath) && string.IsNullOrWhiteSpace(config.CustomDataDir)) config.CustomDataDir = aliasPath; var modeFromJson = ReadStringProperty(root, "mode"); if (string.IsNullOrWhiteSpace(modeFromJson) && !string.IsNullOrWhiteSpace(config.CustomDataDir)) config.Mode = "custom"; return config; } catch (JsonException) { if (TryLoadLooseJsonLikeConfig(json, out var loose)) return loose; throw; } } private static string? ReadStringProperty(JsonElement root, string name) { if (root.ValueKind != JsonValueKind.Object) return null; foreach (var prop in root.EnumerateObject()) { if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) { return prop.Value.GetString(); } } return null; } private static bool TryLoadLooseJsonLikeConfig(string text, out AppPathConfig config) { config = new AppPathConfig(); var mode = FindLooseStringValue(text, "mode"); var path = FindLooseStringValue(text, "custom_data_dir") ?? FindLooseStringValue(text, "data_dir") ?? FindLooseStringValue(text, "path") ?? FindLooseStringValue(text, "folder") ?? FindLooseStringValue(text, "vault_dir"); if (string.IsNullOrWhiteSpace(mode) && string.IsNullOrWhiteSpace(path)) return false; config.Version = 2; config.Mode = string.IsNullOrWhiteSpace(mode) ? "custom" : mode; config.CustomDataDir = path ?? ""; return true; } private static string? FindLooseStringValue(string text, string key) { var marker = '"' + key + '"'; var keyIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (keyIndex < 0) return null; var colonIndex = text.IndexOf(':', keyIndex + marker.Length); if (colonIndex < 0) return null; var firstQuote = text.IndexOf('"', colonIndex + 1); if (firstQuote < 0) return null; var secondQuote = text.IndexOf('"', firstQuote + 1); if (secondQuote < 0) return null; return text.Substring(firstQuote + 1, secondQuote - firstQuote - 1).Trim(); } private static void NormalizeLoadedConfig() { Current.Mode = NormalizeMode(Current.Mode); if (!string.IsNullOrWhiteSpace(Current.CustomDataDir)) Current.CustomDataDir = Current.CustomDataDir.Trim(); if (Current.Mode == "custom" && string.IsNullOrWhiteSpace(Current.CustomDataDir)) throw new InvalidOperationException("В Zapominalka.paths.json выбран режим custom, но custom_data_dir/data_dir пустой."); } public static void SetCurrent(AppPathConfig config) { config.Mode = NormalizeMode(config.Mode); Current = config; } public static void Save(AppPathConfig config) { config.Version = 2; config.Mode = NormalizeMode(config.Mode); if (config.Mode != "custom") config.CustomDataDir = ""; var json = JsonSerializer.Serialize(config, JsonOptions); SaveTo(ActiveConfigFile, json); // Копия в папке данных нужна для полной переносимости и видимости настроек рядом с vault.lzdb. // Стартовый файл рядом с exe всё равно нужен как указатель, если data_dir/custom находится не рядом с программой. var dataConfigPath = Path.Combine(GetDataDirForConfig(config), "Zapominalka.paths.json"); if (!string.Equals(Path.GetFullPath(ActiveConfigFile), Path.GetFullPath(dataConfigPath), StringComparison.OrdinalIgnoreCase)) SaveTo(dataConfigPath, json); Current = config; } private static string GetDataDirForConfig(AppPathConfig config) { return NormalizeMode(config.Mode) switch { "custom" => AppPaths.ResolveUserPath(config.CustomDataDir), _ => AppPaths.PortableDataDir }; } private static void SaveTo(string path, string json) { var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); File.WriteAllText(path, json, new UTF8Encoding(false)); } public static void TrySave(AppPathConfig config) { try { Save(config); } catch { // Если приложение лежит в защищённой папке, JSON рядом с exe может быть недоступен. // Тогда путь придётся положить рядом с exe вручную или запускать программу из обычной папки. } } public static string NormalizeMode(string? mode) { return mode?.Trim().ToLowerInvariant() switch { "custom" => "custom", _ => "portable" }; } } internal sealed class AppSettings { public int Version { get; set; } = 1; // system = брать тему из Windows, light = светлая, dark = тёмная. public string Theme { get; set; } = "system"; // Размер шрифта меняется только в TextBox-полях через Ctrl+колесо. public float TextFieldFontSize { get; set; } = 10F; // Размер шрифта названий карточек в левом списке. Через Ctrl+колесо не меняется. public float CardTitleFontSize { get; set; } = 12F; // Настройки отдельного генератора паролей. // Генератор ничего сам не вставляет в записи: только создаёт пароль и кладёт его в буфер по кнопке. public string PasswordGeneratorPreset { get; set; } = "Обычный — 16"; public int PasswordGeneratorLength { get; set; } = 16; public bool PasswordGeneratorLower { get; set; } = true; public bool PasswordGeneratorUpper { get; set; } = true; public bool PasswordGeneratorDigits { get; set; } = true; public bool PasswordGeneratorSymbols { get; set; } = true; } internal static class AppSettingsStore { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; public static AppSettings Current { get; private set; } = new(); public static void Load() { try { if (File.Exists(AppPaths.SettingsFile)) { var json = File.ReadAllText(AppPaths.SettingsFile, Encoding.UTF8); Current = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings(); } } catch { Current = new AppSettings(); } Normalize(); Save(); } public static void Save() { try { Directory.CreateDirectory(AppPaths.DataDir); File.WriteAllText(AppPaths.SettingsFile, JsonSerializer.Serialize(Current, JsonOptions), new UTF8Encoding(false)); } catch { // Настройки не критичнее базы. Если файл занят — не валим приложение. } } public static void SetTextFieldFontSize(float value) { Current.TextFieldFontSize = Math.Clamp(value, 8F, 22F); Save(); } public static void SetTheme(string value) { Current.Theme = NormalizeTheme(value); Save(); } public static void SetPasswordGeneratorSettings( string preset, int length, bool lower, bool upper, bool digits, bool symbols) { Current.PasswordGeneratorPreset = NormalizePasswordGeneratorPreset(preset); Current.PasswordGeneratorLength = Math.Clamp(length, 8, 128); Current.PasswordGeneratorLower = lower; Current.PasswordGeneratorUpper = upper; Current.PasswordGeneratorDigits = digits; Current.PasswordGeneratorSymbols = symbols; if (!Current.PasswordGeneratorLower && !Current.PasswordGeneratorUpper && !Current.PasswordGeneratorDigits && !Current.PasswordGeneratorSymbols) { Current.PasswordGeneratorLower = true; Current.PasswordGeneratorUpper = true; Current.PasswordGeneratorDigits = true; Current.PasswordGeneratorSymbols = true; } Save(); } private static void Normalize() { Current.Theme = NormalizeTheme(Current.Theme); Current.TextFieldFontSize = Math.Clamp(Current.TextFieldFontSize, 8F, 22F); Current.CardTitleFontSize = Math.Clamp(Current.CardTitleFontSize, 9F, 18F); Current.PasswordGeneratorPreset = NormalizePasswordGeneratorPreset(Current.PasswordGeneratorPreset); Current.PasswordGeneratorLength = Math.Clamp(Current.PasswordGeneratorLength, 8, 128); if (!Current.PasswordGeneratorLower && !Current.PasswordGeneratorUpper && !Current.PasswordGeneratorDigits && !Current.PasswordGeneratorSymbols) { Current.PasswordGeneratorLower = true; Current.PasswordGeneratorUpper = true; Current.PasswordGeneratorDigits = true; Current.PasswordGeneratorSymbols = true; } } private static string NormalizeTheme(string? value) { return value?.ToLowerInvariant() switch { "light" => "light", "dark" => "dark", _ => "system" }; } private static string NormalizePasswordGeneratorPreset(string? value) { return value switch { "Простой — 12, без символов" => "Простой — 12, без символов", "Обычный — 16" => "Обычный — 16", "Сильный — 24" => "Сильный — 24", "Максимальный — 32" => "Максимальный — 32", "Свой" => "Свой", _ => "Обычный — 16" }; } } internal sealed class CustomThemeSettings { public int Version { get; set; } = 1; // Если false — используется стандартная светлая/тёмная/системная тема. public bool Enabled { get; set; } = false; // Шрифты. Если оставить пустым — используются Segoe UI и Consolas. public string FontFamily { get; set; } = "Segoe UI"; public string MonoFontFamily { get; set; } = "Consolas"; // Цвета в формате #RRGGBB. Пустые значения берутся из текущей темы. public string WindowBack { get; set; } = ""; public string SidebarBack { get; set; } = ""; public string EditorBack { get; set; } = ""; public string InputBack { get; set; } = ""; public string InputFore { get; set; } = ""; public string Fore { get; set; } = ""; public string MutedFore { get; set; } = ""; public string ButtonBack { get; set; } = ""; public string ButtonFore { get; set; } = ""; public CustomThemeSettings Clone() { return new CustomThemeSettings { Version = Version, Enabled = Enabled, FontFamily = FontFamily, MonoFontFamily = MonoFontFamily, WindowBack = WindowBack, SidebarBack = SidebarBack, EditorBack = EditorBack, InputBack = InputBack, InputFore = InputFore, Fore = Fore, MutedFore = MutedFore, ButtonBack = ButtonBack, ButtonFore = ButtonFore }; } } internal static class ThemeSettingsStore { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; public static CustomThemeSettings Current { get; private set; } = new(); public static string TextFontFamily => NormalizeFontFamily(Current.FontFamily, "Segoe UI"); public static string MonoFontFamily => NormalizeFontFamily(Current.MonoFontFamily, "Consolas"); public static void Load() { try { if (File.Exists(AppPaths.ThemeFile)) { var json = File.ReadAllText(AppPaths.ThemeFile, Encoding.UTF8); Current = JsonSerializer.Deserialize<CustomThemeSettings>(json, JsonOptions) ?? new CustomThemeSettings(); } } catch { Current = new CustomThemeSettings(); } Normalize(); Save(); } public static void Set(CustomThemeSettings settings) { Current = settings.Clone(); Normalize(); Save(); } public static void Reset() { Current = new CustomThemeSettings(); Save(); } public static void Save() { try { Directory.CreateDirectory(AppPaths.DataDir); File.WriteAllText(AppPaths.ThemeFile, JsonSerializer.Serialize(Current, JsonOptions), new UTF8Encoding(false)); } catch { // Цвета и шрифты — косметика. Базу из-за этого не роняем. } } public static ThemePalette ApplyTo(ThemePalette basePalette) { if (!Current.Enabled) return basePalette; return new ThemePalette { WindowBack = ReadColor(Current.WindowBack, basePalette.WindowBack), SidebarBack = ReadColor(Current.SidebarBack, basePalette.SidebarBack), EditorBack = ReadColor(Current.EditorBack, basePalette.EditorBack), InputBack = ReadColor(Current.InputBack, basePalette.InputBack), InputFore = ReadColor(Current.InputFore, basePalette.InputFore), Fore = ReadColor(Current.Fore, basePalette.Fore), MutedFore = ReadColor(Current.MutedFore, basePalette.MutedFore), ButtonBack = ReadColor(Current.ButtonBack, basePalette.ButtonBack), ButtonFore = ReadColor(Current.ButtonFore, basePalette.ButtonFore), ErrorFore = basePalette.ErrorFore, IsDark = basePalette.IsDark }; } public static bool TryParseColor(string? value, out Color color) { color = Color.Empty; if (string.IsNullOrWhiteSpace(value)) return false; var text = value.Trim(); if (!text.StartsWith("#", StringComparison.Ordinal)) text = "#" + text; if (text.Length != 7) return false; try { var r = Convert.ToInt32(text.Substring(1, 2), 16); var g = Convert.ToInt32(text.Substring(3, 2), 16); var b = Convert.ToInt32(text.Substring(5, 2), 16); color = Color.FromArgb(r, g, b); return true; } catch { return false; } } public static string ToHex(Color color) { return $"#{color.R:X2}{color.G:X2}{color.B:X2}"; } private static void Normalize() { Current.Version = 1; Current.FontFamily = NormalizeFontFamily(Current.FontFamily, "Segoe UI"); Current.MonoFontFamily = NormalizeFontFamily(Current.MonoFontFamily, "Consolas"); Current.WindowBack = NormalizeColorText(Current.WindowBack); Current.SidebarBack = NormalizeColorText(Current.SidebarBack); Current.EditorBack = NormalizeColorText(Current.EditorBack); Current.InputBack = NormalizeColorText(Current.InputBack); Current.InputFore = NormalizeColorText(Current.InputFore); Current.Fore = NormalizeColorText(Current.Fore); Current.MutedFore = NormalizeColorText(Current.MutedFore); Current.ButtonBack = NormalizeColorText(Current.ButtonBack); Current.ButtonFore = NormalizeColorText(Current.ButtonFore); // Если пользователь руками вписал цвета/шрифты в JSON, но забыл enabled=true, // всё равно включаем кастомную тему. Иначе выглядит как «цвета не применяются». if (!Current.Enabled && HasAnyCustomValue(Current)) Current.Enabled = true; } private static bool HasAnyCustomValue(CustomThemeSettings settings) { if (!string.Equals(settings.FontFamily?.Trim(), "Segoe UI", StringComparison.OrdinalIgnoreCase)) return true; if (!string.Equals(settings.MonoFontFamily?.Trim(), "Consolas", StringComparison.OrdinalIgnoreCase)) return true; return !string.IsNullOrWhiteSpace(settings.WindowBack) || !string.IsNullOrWhiteSpace(settings.SidebarBack) || !string.IsNullOrWhiteSpace(settings.EditorBack) || !string.IsNullOrWhiteSpace(settings.InputBack) || !string.IsNullOrWhiteSpace(settings.InputFore) || !string.IsNullOrWhiteSpace(settings.Fore) || !string.IsNullOrWhiteSpace(settings.MutedFore) || !string.IsNullOrWhiteSpace(settings.ButtonBack) || !string.IsNullOrWhiteSpace(settings.ButtonFore); } private static Color ReadColor(string? value, Color fallback) { return TryParseColor(value, out var color) ? color : fallback; } private static string NormalizeColorText(string? value) { if (string.IsNullOrWhiteSpace(value)) return ""; return TryParseColor(value, out var color) ? ToHex(color) : ""; } private static string NormalizeFontFamily(string? value, string fallback) { var text = value?.Trim(); return string.IsNullOrWhiteSpace(text) ? fallback : text; } } internal sealed class ThemePalette { public Color WindowBack { get; init; } public Color SidebarBack { get; init; } public Color EditorBack { get; init; } public Color InputBack { get; init; } public Color InputFore { get; init; } public Color Fore { get; init; } public Color MutedFore { get; init; } public Color ButtonBack { get; init; } public Color ButtonFore { get; init; } public Color ErrorFore { get; init; } public bool IsDark { get; init; } public static ThemePalette Current() { var mode = AppSettingsStore.Current.Theme; if (mode == "system") mode = IsWindowsDarkMode() ? "dark" : "light"; var palette = mode == "dark" ? Dark() : Light(); return ThemeSettingsStore.ApplyTo(palette); } private static ThemePalette Light() { return new ThemePalette { WindowBack = Color.FromArgb(245, 247, 250), SidebarBack = Color.FromArgb(237, 241, 245), EditorBack = Color.White, InputBack = Color.White, InputFore = Color.Black, Fore = Color.Black, MutedFore = Color.DimGray, ButtonBack = SystemColors.Control, ButtonFore = Color.Black, ErrorFore = Color.Firebrick, IsDark = false }; } private static ThemePalette Dark() { return new ThemePalette { WindowBack = Color.FromArgb(31, 34, 39), SidebarBack = Color.FromArgb(38, 42, 48), EditorBack = Color.FromArgb(24, 26, 30), InputBack = Color.FromArgb(45, 49, 56), InputFore = Color.FromArgb(235, 237, 240), Fore = Color.FromArgb(235, 237, 240), MutedFore = Color.FromArgb(168, 174, 184), ButtonBack = Color.FromArgb(55, 60, 68), ButtonFore = Color.FromArgb(235, 237, 240), ErrorFore = Color.FromArgb(255, 130, 130), IsDark = true }; } private static bool IsWindowsDarkMode() { try { var value = Registry.GetValue( @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme", 1); return value is int intValue && intValue == 0; } catch { return false; } } } internal sealed class VaultSession { public VaultSession(string vaultPath, string masterPassword, VaultData data) { VaultPath = vaultPath; MasterPassword = masterPassword; Data = data; } public string VaultPath { get; } public string MasterPassword { get; private set; } public VaultData Data { get; } public void ChangeMasterPassword(string newMasterPassword) { MasterPassword = newMasterPassword; } } internal sealed class VaultData { public string App { get; set; } = "Zapominalka"; public int Version { get; set; } = 1; public string VaultName { get; set; } = "Основной сейф"; public Dictionary<string, string> FixedFieldLabels { get; set; } = global::Zapominalka.FixedFieldLabels.Defaults(); public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public List<VaultRecord> Records { get; set; } = new(); public static VaultData Empty() { var now = DateTime.UtcNow; return new VaultData { VaultName = "Основной сейф", CreatedUtc = now, UpdatedUtc = now, Records = new List<VaultRecord>() }; } } internal static class VaultNames { public const string DefaultName = "Основной сейф"; public static string Normalize(string? name) { var value = (name ?? "").Trim(); if (string.IsNullOrWhiteSpace(value)) return DefaultName; value = value.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); while (value.Contains(" ", StringComparison.Ordinal)) value = value.Replace(" ", " "); return value.Length > 80 ? value[..80].Trim() : value; } public static string Display(VaultData? data) { return Normalize(data?.VaultName); } } internal static class RecordCategories { public const string EmptyDisplay = "Без категории"; public const string AllDisplay = "Все категории"; public static string Normalize(string? value) { var text = (value ?? "").Trim(); if (string.IsNullOrWhiteSpace(text) || string.Equals(text, EmptyDisplay, StringComparison.CurrentCultureIgnoreCase) || string.Equals(text, AllDisplay, StringComparison.CurrentCultureIgnoreCase)) return ""; text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); while (text.Contains(" ", StringComparison.Ordinal)) text = text.Replace(" ", " "); return text.Length > 50 ? text[..50].Trim() : text; } public static string Display(string? value) { var normalized = Normalize(value); return string.IsNullOrWhiteSpace(normalized) ? EmptyDisplay : normalized; } public static IEnumerable<string> ListFor(VaultData data) { return data.Records .Select(r => Normalize(r.Category)) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.CurrentCultureIgnoreCase) .OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase); } } internal static class FixedFieldLabels { // Внутренние ключи полей не меняются, чтобы старые vault.lzdb открывались без миграции. // Меняются только отображаемые подписи слева от полей. public static readonly string[] Keys = { "title", "url", "login", "pass", "secret1", "telefon", "email", "secret2", "secret3", "notes1", "notes2" }; private static readonly Dictionary<string, string> DefaultValues = new(StringComparer.OrdinalIgnoreCase) { ["title"] = "title", ["url"] = "url", ["login"] = "login", ["pass"] = "pass", ["secret1"] = "secret1", ["telefon"] = "telefon", ["email"] = "email", ["secret2"] = "secret2", ["secret3"] = "secret3", ["notes1"] = "notes1", ["notes2"] = "notes2" }; public static Dictionary<string, string> Defaults() { return new Dictionary<string, string>(DefaultValues, StringComparer.OrdinalIgnoreCase); } public static string Get(VaultData? data, string key) { return Get(data?.FixedFieldLabels, key); } public static string Get(Dictionary<string, string>? labels, string key) { var defaults = Defaults(); if (labels == null) return defaults.TryGetValue(key, out var defaultValue) ? defaultValue : key; if (!labels.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) return defaults.TryGetValue(key, out var defaultValue) ? defaultValue : key; return Normalize(value, key); } public static Dictionary<string, string> Normalize(Dictionary<string, string>? labels) { var result = Defaults(); if (labels == null) return result; foreach (var key in Keys) result[key] = Get(labels, key); return result; } public static string Normalize(string? value, string fallbackKey) { var fallback = DefaultValues.TryGetValue(fallbackKey, out var defaultValue) ? defaultValue : fallbackKey; var text = (value ?? "").Trim(); if (string.IsNullOrWhiteSpace(text)) return fallback; text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); while (text.Contains(" ", StringComparison.Ordinal)) text = text.Replace(" ", " "); return text.Length > 40 ? text[..40].Trim() : text; } } internal sealed class VaultRecord { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string RecordType { get; set; } = "fields"; // fields = обычная карточка, document = TXT/MD-запись public string Category { get; set; } = ""; public string Title { get; set; } = ""; public string SourceFileName { get; set; } = ""; public string DocumentFormat { get; set; } = "md"; public string DocumentText { get; set; } = ""; public string Url { get; set; } = ""; public string Login { get; set; } = ""; public string Pass { get; set; } = ""; public string Secret1 { get; set; } = ""; public string Telefon { get; set; } = ""; public string Email { get; set; } = ""; public string Secret2 { get; set; } = ""; public string Secret3 { get; set; } = ""; public string Notes1 { get; set; } = ""; public string Notes2 { get; set; } = ""; public List<CustomField> CustomFields { get; set; } = new(); public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } internal sealed class CustomField { public string Name { get; set; } = ""; public string Type { get; set; } = "text"; public string Value { get; set; } = ""; } internal sealed class VaultEnvelope { public string Magic { get; set; } = VaultStore.Magic; public int Version { get; set; } = 1; public string Kdf { get; set; } = "PBKDF2-SHA256"; public int Iterations { get; set; } = VaultStore.Iterations; public string Salt { get; set; } = ""; public string Nonce { get; set; } = ""; public string Tag { get; set; } = ""; public string CipherText { get; set; } = ""; } internal static class VaultStore { public const string Magic = "LZDB1"; public const int Iterations = 250_000; private const int SaltSize = 16; private const int NonceSize = 12; private const int TagSize = 16; private const int KeySize = 32; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; public static VaultData Load(string path, string password) { var envelopeJson = File.ReadAllText(path, Encoding.UTF8); var envelope = JsonSerializer.Deserialize<VaultEnvelope>(envelopeJson, JsonOptions) ?? throw new InvalidDataException("Файл базы пустой или повреждён."); if (!string.Equals(envelope.Magic, Magic, StringComparison.Ordinal)) throw new InvalidDataException("Это не файл Zapominalka/LocalZapominalka."); var salt = Convert.FromBase64String(envelope.Salt); var nonce = Convert.FromBase64String(envelope.Nonce); var tag = Convert.FromBase64String(envelope.Tag); var cipherText = Convert.FromBase64String(envelope.CipherText); var key = DeriveKey(password, salt, envelope.Iterations); var plain = new byte[cipherText.Length]; using (var aes = new AesGcm(key, TagSize)) { aes.Decrypt(nonce, cipherText, tag, plain, Encoding.UTF8.GetBytes(Magic)); } var plainJson = Encoding.UTF8.GetString(plain); return JsonSerializer.Deserialize<VaultData>(plainJson, JsonOptions) ?? throw new InvalidDataException("Расшифровка прошла, но данные базы повреждены."); } public static void Save(string path, string password, VaultData data) { data.UpdatedUtc = DateTime.UtcNow; var salt = RandomNumberGenerator.GetBytes(SaltSize); var nonce = RandomNumberGenerator.GetBytes(NonceSize); var key = DeriveKey(password, salt, Iterations); var plain = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data, JsonOptions)); var cipherText = new byte[plain.Length]; var tag = new byte[TagSize]; using (var aes = new AesGcm(key, TagSize)) { aes.Encrypt(nonce, plain, cipherText, tag, Encoding.UTF8.GetBytes(Magic)); } CryptographicOperations.ZeroMemory(key); CryptographicOperations.ZeroMemory(plain); var envelope = new VaultEnvelope { Magic = Magic, Version = 1, Kdf = "PBKDF2-SHA256", Iterations = Iterations, Salt = Convert.ToBase64String(salt), Nonce = Convert.ToBase64String(nonce), Tag = Convert.ToBase64String(tag), CipherText = Convert.ToBase64String(cipherText) }; var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); var tempPath = path + ".tmp"; try { File.WriteAllText(tempPath, JsonSerializer.Serialize(envelope, JsonOptions), new UTF8Encoding(false)); File.Move(tempPath, path, overwrite: true); } catch { try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { // Уборка tmp не критичнее сохранности основного vault. } throw; } } public static string CreateBackup(string vaultPath, string backupDir) { if (!File.Exists(vaultPath)) throw new FileNotFoundException("Файл базы ещё не создан.", vaultPath); Directory.CreateDirectory(backupDir); var backupPath = Path.Combine(backupDir, $"vault_{DateTime.Now:yyyyMMdd_HHmmss}.lzdb"); File.Copy(vaultPath, backupPath, overwrite: false); return backupPath; } private static byte[] DeriveKey(string password, byte[] salt, int iterations) { using var kdf = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); return kdf.GetBytes(KeySize); } } internal static class Exporter { public static string ToMarkdown(VaultData data) { var labels = FixedFieldLabels.Normalize(data.FixedFieldLabels); var sb = new StringBuilder(); sb.AppendLine("# " + VaultNames.Display(data)); sb.AppendLine(); sb.AppendLine("Zapominalka export"); sb.AppendLine(); sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine(); foreach (var r in data.Records .OrderBy(x => RecordCategories.Display(x.Category), StringComparer.CurrentCultureIgnoreCase) .ThenBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase)) { sb.AppendLine($"## {SafeTitle(r.Title)}"); if (IsDocumentRecord(r)) { AppendMd(sb, "title", r.Title); AppendMd(sb, "category", RecordCategories.Normalize(r.Category)); AppendMd(sb, "type", "document"); AppendMd(sb, "source_file", r.SourceFileName); AppendMd(sb, "format", r.DocumentFormat); sb.AppendLine(); sb.AppendLine("### text"); sb.AppendLine(); sb.AppendLine(r.DocumentText.TrimEnd()); sb.AppendLine(); continue; } AppendMd(sb, "category", RecordCategories.Normalize(r.Category)); AppendMd(sb, FixedFieldLabels.Get(labels, "title"), r.Title); AppendMd(sb, FixedFieldLabels.Get(labels, "url"), r.Url); AppendMd(sb, FixedFieldLabels.Get(labels, "login"), r.Login); AppendMd(sb, FixedFieldLabels.Get(labels, "pass"), r.Pass); AppendMd(sb, FixedFieldLabels.Get(labels, "secret1"), r.Secret1); AppendMd(sb, FixedFieldLabels.Get(labels, "telefon"), r.Telefon); AppendMd(sb, FixedFieldLabels.Get(labels, "email"), r.Email); AppendMd(sb, FixedFieldLabels.Get(labels, "secret2"), r.Secret2); AppendMd(sb, FixedFieldLabels.Get(labels, "secret3"), r.Secret3); AppendMd(sb, FixedFieldLabels.Get(labels, "notes1"), r.Notes1); AppendMd(sb, FixedFieldLabels.Get(labels, "notes2"), r.Notes2); if (r.CustomFields.Count > 0) { sb.AppendLine(); sb.AppendLine("### custom fields"); foreach (var f in r.CustomFields) AppendMd(sb, $"{f.Name} ({f.Type})", f.Value); } sb.AppendLine(); } return sb.ToString(); } public static string ToText(VaultData data) { var labels = FixedFieldLabels.Normalize(data.FixedFieldLabels); var sb = new StringBuilder(); sb.AppendLine(VaultNames.Display(data)); sb.AppendLine("Zapominalka export"); sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine(new string('=', 72)); foreach (var r in data.Records .OrderBy(x => RecordCategories.Display(x.Category), StringComparer.CurrentCultureIgnoreCase) .ThenBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase)) { sb.AppendLine(); sb.AppendLine(SafeTitle(r.Title)); sb.AppendLine(new string('-', 72)); if (IsDocumentRecord(r)) { AppendTxt(sb, "title", r.Title); AppendTxt(sb, "category", RecordCategories.Normalize(r.Category)); AppendTxt(sb, "type", "document"); AppendTxt(sb, "source_file", r.SourceFileName); AppendTxt(sb, "format", r.DocumentFormat); sb.AppendLine(); sb.AppendLine(r.DocumentText.TrimEnd()); continue; } AppendTxt(sb, "category", RecordCategories.Normalize(r.Category)); AppendTxt(sb, FixedFieldLabels.Get(labels, "title"), r.Title); AppendTxt(sb, FixedFieldLabels.Get(labels, "url"), r.Url); AppendTxt(sb, FixedFieldLabels.Get(labels, "login"), r.Login); AppendTxt(sb, FixedFieldLabels.Get(labels, "pass"), r.Pass); AppendTxt(sb, FixedFieldLabels.Get(labels, "secret1"), r.Secret1); AppendTxt(sb, FixedFieldLabels.Get(labels, "telefon"), r.Telefon); AppendTxt(sb, FixedFieldLabels.Get(labels, "email"), r.Email); AppendTxt(sb, FixedFieldLabels.Get(labels, "secret2"), r.Secret2); AppendTxt(sb, FixedFieldLabels.Get(labels, "secret3"), r.Secret3); AppendTxt(sb, FixedFieldLabels.Get(labels, "notes1"), r.Notes1); AppendTxt(sb, FixedFieldLabels.Get(labels, "notes2"), r.Notes2); foreach (var f in r.CustomFields) AppendTxt(sb, $"custom:{f.Name} ({f.Type})", f.Value); } return sb.ToString(); } private static string SafeTitle(string value) => string.IsNullOrWhiteSpace(value) ? "Без названия" : value.Trim(); private static bool IsDocumentRecord(VaultRecord record) { return string.Equals(record.RecordType, "document", StringComparison.OrdinalIgnoreCase); } private static void AppendMd(StringBuilder sb, string key, string value) { if (string.IsNullOrWhiteSpace(value)) return; sb.AppendLine(); sb.AppendLine($"**{key}:**"); sb.AppendLine(); sb.AppendLine(value.TrimEnd()); } private static void AppendTxt(StringBuilder sb, string key, string value) { if (string.IsNullOrWhiteSpace(value)) return; sb.AppendLine($"{key}: {value}"); } } internal sealed class LoginForm : Form { private readonly string _vaultPath; private bool _vaultExists; private readonly TextBox _passwordBox = new(); private readonly TextBox _confirmBox = new(); private readonly TextBox _vaultNameBox = new(); private readonly Label _titleLabel = new(); private readonly Label _vaultInfoLabel = new(); private readonly Label _statusLabel = new(); private Button? _okButton; private Button? _newVaultButton; private bool _busy; public LoginForm(string vaultPath) { _vaultPath = vaultPath; _vaultExists = File.Exists(vaultPath); BuildUi(); } public VaultSession? Session { get; private set; } private void BuildUi() { Text = "Zapominalka — сейф"; StartPosition = FormStartPosition.CenterScreen; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(820, 420); MinimumSize = new Size(820, 420); Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point); BackColor = ThemePalette.Current().WindowBack; var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(18), ColumnCount = 1, RowCount = 8 }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 72)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); Controls.Add(root); _titleLabel.AutoSize = true; _titleLabel.Font = new Font(Font, FontStyle.Bold); _titleLabel.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_titleLabel, 0, 0); _vaultInfoLabel.AutoSize = false; _vaultInfoLabel.AutoEllipsis = true; _vaultInfoLabel.Dock = DockStyle.Fill; _vaultInfoLabel.ForeColor = Color.DimGray; _vaultInfoLabel.TextAlign = ContentAlignment.TopLeft; _vaultInfoLabel.Margin = new Padding(0, 0, 0, 8); var tooltip = new ToolTip(); tooltip.SetToolTip(_vaultInfoLabel, _vaultPath + Environment.NewLine + "Конфиг: " + AppPathConfigStore.ActiveConfigFile + Environment.NewLine + "Папка данных: " + AppPaths.DataDir); root.Controls.Add(_vaultInfoLabel, 0, 1); _passwordBox.UseSystemPasswordChar = true; _passwordBox.Dock = DockStyle.Top; _passwordBox.PlaceholderText = "Мастер-пароль"; _passwordBox.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_passwordBox, 0, 2); _confirmBox.UseSystemPasswordChar = true; _confirmBox.Dock = DockStyle.Top; _confirmBox.PlaceholderText = "Повтор мастер-пароля"; _confirmBox.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_confirmBox, 0, 3); _vaultNameBox.Dock = DockStyle.Top; _vaultNameBox.PlaceholderText = "Название сейфа, например: Пароли, Токены, Лицензии"; _vaultNameBox.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_vaultNameBox, 0, 4); _statusLabel.AutoSize = false; _statusLabel.Dock = DockStyle.Top; _statusLabel.Height = 42; _statusLabel.ForeColor = Color.Firebrick; _statusLabel.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_statusLabel, 0, 5); var buttons = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 4, RowCount = 1, Margin = new Padding(0), Padding = new Padding(0) }; buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 142)); buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 108)); buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 108)); root.Controls.Add(buttons, 0, 7); _newVaultButton = new Button { Text = "Новый сейф", Dock = DockStyle.Fill, Height = 36, Margin = new Padding(0, 5, 8, 5) }; _newVaultButton.Click += async (_, _) => await SwitchToCreateNewVaultModeAsync(); buttons.Controls.Add(_newVaultButton, 1, 0); var cancelButton = new Button { Text = "Выход", Dock = DockStyle.Fill, Height = 36, Margin = new Padding(0, 5, 8, 5) }; cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; buttons.Controls.Add(cancelButton, 2, 0); _okButton = new Button { Dock = DockStyle.Fill, Height = 36, Margin = new Padding(0, 5, 0, 5) }; _okButton.Click += async (_, _) => await TryOpenOrCreateAsync(); buttons.Controls.Add(_okButton, 3, 0); AcceptButton = _okButton; CancelButton = cancelButton; ApplyTheme(); UpdateLoginModeUi(); Shown += (_, _) => _passwordBox.Focus(); } private void UpdateLoginModeUi() { Text = _vaultExists ? "Zapominalka — открыть сейф" : "Zapominalka — создать сейф"; _titleLabel.Text = _vaultExists ? "Введите мастер-пароль" : "Новой базы нет — создай мастер-пароль"; _vaultInfoLabel.Text = _vaultExists ? $"База найдена: {_vaultPath}{Environment.NewLine}Конфиг путей: {AppPathConfigStore.ActiveConfigFile}{Environment.NewLine}Папка данных: {AppPaths.DataDir}" : $"Будет создана база: {_vaultPath}{Environment.NewLine}Конфиг путей: {AppPathConfigStore.ActiveConfigFile}{Environment.NewLine}Папка данных: {AppPaths.DataDir}"; _confirmBox.Visible = !_vaultExists; _confirmBox.Enabled = !_vaultExists && !_busy; _vaultNameBox.Visible = !_vaultExists; _vaultNameBox.Enabled = !_vaultExists && !_busy; if (!_vaultExists && string.IsNullOrWhiteSpace(_vaultNameBox.Text)) _vaultNameBox.Text = VaultNames.DefaultName; if (_okButton != null) _okButton.Text = _vaultExists ? "Открыть" : "Создать"; if (_newVaultButton != null) { _newVaultButton.Visible = _vaultExists; _newVaultButton.Enabled = !_busy; } _statusLabel.Text = _vaultExists ? "База найдена. Для нового пустого сейфа сначала введи текущий мастер-пароль." : "Введи новый мастер-пароль два раза. Пустой пароль запрещён."; _statusLabel.ForeColor = ThemePalette.Current().MutedFore; } private async Task SwitchToCreateNewVaultModeAsync() { if (_busy) return; if (!File.Exists(_vaultPath)) { _vaultExists = false; UpdateLoginModeUi(); ApplyTheme(); _passwordBox.Focus(); return; } var currentPassword = _passwordBox.Text; if (string.IsNullOrWhiteSpace(currentPassword)) { _statusLabel.ForeColor = ThemePalette.Current().ErrorFore; _statusLabel.Text = "Новый сейф не создаётся поверх найденной базы без текущего мастер-пароля."; _passwordBox.Focus(); return; } _busy = true; UseWaitCursor = true; Cursor = Cursors.WaitCursor; _passwordBox.Enabled = false; _confirmBox.Enabled = false; _vaultNameBox.Enabled = false; if (_newVaultButton != null) _newVaultButton.Enabled = false; if (_okButton != null) _okButton.Enabled = false; _statusLabel.ForeColor = ThemePalette.Current().MutedFore; _statusLabel.Text = "Проверяю текущий мастер-пароль перед созданием нового сейфа..."; Refresh(); try { await Task.Run(() => VaultStore.Load(_vaultPath, currentPassword)); } catch (CryptographicException) { _statusLabel.ForeColor = ThemePalette.Current().ErrorFore; _statusLabel.Text = "Новый сейф не создан: текущий мастер-пароль неверный."; return; } catch (Exception ex) { _statusLabel.ForeColor = ThemePalette.Current().ErrorFore; _statusLabel.Text = "Не удалось проверить базу: " + ex.Message; return; } finally { if (!IsDisposed) { _busy = false; UseWaitCursor = false; Cursor = Cursors.Default; _passwordBox.Enabled = true; _confirmBox.Enabled = !_vaultExists; _vaultNameBox.Enabled = !_vaultExists; if (_newVaultButton != null) _newVaultButton.Enabled = _vaultExists; if (_okButton != null) _okButton.Enabled = true; } } var answer = MessageBox.Show( this, "Текущий мастер-пароль принят." + Environment.NewLine + Environment.NewLine + "Создать новый пустой сейф?" + Environment.NewLine + Environment.NewLine + "Существующий vault.lzdb будет перенесён в backups\\ перед созданием новой базы.", "Новый сейф", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (answer != DialogResult.Yes) return; try { Directory.CreateDirectory(AppPaths.BackupsDir); var backupPath = Path.Combine(AppPaths.BackupsDir, $"vault_before_new_{DateTime.Now:yyyyMMdd_HHmmss}.lzdb"); File.Move(_vaultPath, backupPath, overwrite: false); _vaultExists = false; _passwordBox.Clear(); _confirmBox.Clear(); _vaultNameBox.Text = VaultNames.DefaultName; UpdateLoginModeUi(); _statusLabel.ForeColor = ThemePalette.Current().MutedFore; _statusLabel.Text = "Старый vault перенесён в backup. Задай новый мастер-пароль два раза."; ApplyTheme(); _passwordBox.Focus(); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Не удалось перенести старый vault", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; foreach (var control in AllControls(this)) { switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; textBox.Font = new Font("Segoe UI", AppSettingsStore.Current.TextFieldFontSize, FontStyle.Regular, GraphicsUnit.Point); break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case Label label when ReferenceEquals(label, _statusLabel): label.ForeColor = palette.ErrorFore; break; case Label label: label.ForeColor = palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = palette.WindowBack; control.ForeColor = palette.Fore; break; } } } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } private async Task TryOpenOrCreateAsync() { if (_busy) return; var password = _passwordBox.Text; if (string.IsNullOrWhiteSpace(password)) { _statusLabel.Text = "Мастер-пароль пустой. Это сейф, не проходной двор."; return; } if (!_vaultExists && password != _confirmBox.Text) { _statusLabel.Text = "Пароли не совпадают."; return; } _busy = true; UseWaitCursor = true; Cursor = Cursors.WaitCursor; _passwordBox.Enabled = false; _confirmBox.Enabled = false; _vaultNameBox.Enabled = false; if (_newVaultButton != null) _newVaultButton.Enabled = false; if (_okButton != null) _okButton.Enabled = false; _statusLabel.ForeColor = ThemePalette.Current().MutedFore; _statusLabel.Text = _vaultExists ? "Открываю сейф: расшифровка может занять несколько секунд." : "Создаю сейф..."; Refresh(); try { var data = await Task.Run(() => { if (_vaultExists) return VaultStore.Load(_vaultPath, password); var created = VaultData.Empty(); created.VaultName = VaultNames.Normalize(_vaultNameBox.Text); VaultStore.Save(_vaultPath, password, created); return created; }); Session = new VaultSession(_vaultPath, password, data); DialogResult = DialogResult.OK; Close(); } catch (CryptographicException) { _statusLabel.ForeColor = ThemePalette.Current().ErrorFore; _statusLabel.Text = "Неверный мастер-пароль или файл базы повреждён. Новый сейф поверх найденной базы без пароля не создаётся."; } catch (Exception ex) { _statusLabel.ForeColor = ThemePalette.Current().ErrorFore; _statusLabel.Text = "Ошибка: " + ex.Message; } finally { if (!IsDisposed) { _busy = false; UseWaitCursor = false; Cursor = Cursors.Default; _passwordBox.Enabled = true; _confirmBox.Enabled = !_vaultExists; _vaultNameBox.Enabled = !_vaultExists; if (_newVaultButton != null) _newVaultButton.Enabled = _vaultExists; if (_okButton != null) _okButton.Enabled = true; } } } } internal static class PasswordGenerator { private const string Lower = "abcdefghijkmnopqrstuvwxyz"; private const string Upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; private const string Digits = "23456789"; private const string Symbols = "!@#$%*-_=+?"; public static string Generate(int length, bool lower, bool upper, bool digits, bool symbols) { var sets = new List<string>(); if (lower) sets.Add(Lower); if (upper) sets.Add(Upper); if (digits) sets.Add(Digits); if (symbols) sets.Add(Symbols); if (sets.Count == 0) throw new InvalidOperationException("Выбери хотя бы один набор символов."); length = Math.Clamp(length, sets.Count, 128); var all = string.Concat(sets); var chars = new List<char>(length); foreach (var set in sets) chars.Add(RandomChar(set)); while (chars.Count < length) chars.Add(RandomChar(all)); Shuffle(chars); return new string(chars.ToArray()); } private static char RandomChar(string chars) { return chars[RandomNumberGenerator.GetInt32(chars.Length)]; } private static void Shuffle(List<char> chars) { for (var i = chars.Count - 1; i > 0; i--) { var j = RandomNumberGenerator.GetInt32(i + 1); (chars[i], chars[j]) = (chars[j], chars[i]); } } } internal sealed class PasswordGeneratorForm : Form { private static readonly string[] Presets = { "Простой — 12, без символов", "Обычный — 16", "Сильный — 24", "Максимальный — 32", "Свой" }; private readonly ComboBox _presetBox = new(); private readonly NumericUpDown _lengthBox = new(); private readonly CheckBox _lowerBox = new(); private readonly CheckBox _upperBox = new(); private readonly CheckBox _digitsBox = new(); private readonly CheckBox _symbolsBox = new(); private readonly TextBox _passwordBox = new(); private readonly Label _statusLabel = new(); private readonly Action<string, string>? _copyAction; private bool _updatingUi; public PasswordGeneratorForm(Action<string, string>? copyAction = null) { _copyAction = copyAction; BuildUi(); } public string GeneratedPassword { get; private set; } = ""; private void BuildUi() { Text = "Генератор пароля"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(660, 350); Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point); var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 6, Padding = new Padding(16) }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); Controls.Add(root); var settingsPanel = new TableLayoutPanel { Dock = DockStyle.Top, ColumnCount = 4, RowCount = 1, AutoSize = true, Margin = new Padding(0, 0, 0, 10) }; settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 92)); settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 70)); settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); root.Controls.Add(settingsPanel, 0, 0); settingsPanel.Controls.Add(new Label { Text = "Сложность:", Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 0) }, 0, 0); _presetBox.Dock = DockStyle.Fill; _presetBox.DropDownStyle = ComboBoxStyle.DropDownList; _presetBox.Items.AddRange(Presets.Cast<object>().ToArray()); _presetBox.SelectedIndexChanged += (_, _) => ApplyPresetFromUi(); settingsPanel.Controls.Add(_presetBox, 1, 0); settingsPanel.Controls.Add(new Label { Text = "Длина:", Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(12, 0, 8, 0) }, 2, 0); _lengthBox.Minimum = 8; _lengthBox.Maximum = 128; _lengthBox.Value = 16; _lengthBox.Dock = DockStyle.Fill; _lengthBox.ValueChanged += (_, _) => MarkCustomPreset(); settingsPanel.Controls.Add(_lengthBox, 3, 0); var options = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = true, Margin = new Padding(0, 0, 0, 10) }; SetupCheck(_lowerBox, "a-z", true); SetupCheck(_upperBox, "A-Z", true); SetupCheck(_digitsBox, "0-9", true); SetupCheck(_symbolsBox, "!@#", true); _lowerBox.CheckedChanged += (_, _) => MarkCustomPreset(); _upperBox.CheckedChanged += (_, _) => MarkCustomPreset(); _digitsBox.CheckedChanged += (_, _) => MarkCustomPreset(); _symbolsBox.CheckedChanged += (_, _) => MarkCustomPreset(); options.Controls.AddRange(new Control[] { _lowerBox, _upperBox, _digitsBox, _symbolsBox }); root.Controls.Add(options, 0, 1); _passwordBox.Dock = DockStyle.Top; _passwordBox.ReadOnly = true; _passwordBox.Margin = new Padding(0, 0, 0, 10); _passwordBox.Font = new Font("Consolas", AppSettingsStore.Current.TextFieldFontSize, FontStyle.Regular, GraphicsUnit.Point); root.Controls.Add(_passwordBox, 0, 2); var hint = new Label { Text = "Генератор открыт отдельным окном: сгенерировал → скопировал → главное окно остаётся доступным → вставил в нужное поле. Похожие символы 0/O/1/l исключены.", AutoSize = false, Dock = DockStyle.Fill, TextAlign = ContentAlignment.TopLeft }; root.Controls.Add(hint, 0, 3); _statusLabel.Dock = DockStyle.Fill; _statusLabel.TextAlign = ContentAlignment.MiddleLeft; _statusLabel.Text = "Пароль создаётся локально через RandomNumberGenerator. Настройки сохраняются в settings.json."; root.Controls.Add(_statusLabel, 0, 4); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; root.Controls.Add(buttons, 0, 5); var closeButton = new Button { Text = "Закрыть", Width = 100, Height = 34, Margin = new Padding(8, 6, 0, 0) }; closeButton.Click += (_, _) => Close(); buttons.Controls.Add(closeButton); var copyCloseButton = new Button { Text = "Копир. и закрыть", Width = 135, Height = 34, Margin = new Padding(8, 6, 0, 0) }; copyCloseButton.Click += (_, _) => { if (CopyGeneratedPassword()) Close(); }; buttons.Controls.Add(copyCloseButton); var copyButton = new Button { Text = "Скопировать", Width = 120, Height = 34, Margin = new Padding(8, 6, 0, 0) }; copyButton.Click += (_, _) => CopyGeneratedPassword(); buttons.Controls.Add(copyButton); var generateButton = new Button { Text = "Сгенерировать", Width = 140, Height = 34, Margin = new Padding(8, 6, 0, 0) }; generateButton.Click += (_, _) => TryGenerate(); buttons.Controls.Add(generateButton); AcceptButton = generateButton; CancelButton = closeButton; FormClosing += (_, _) => SaveGeneratorSettingsFromUi(); ApplyTheme(); Shown += (_, _) => { LoadSavedGeneratorSettingsToUi(); TryGenerate(); }; } private static void SetupCheck(CheckBox box, string text, bool isChecked) { box.Text = text; box.Checked = isChecked; box.AutoSize = true; box.Margin = new Padding(0, 0, 18, 6); } private void LoadSavedGeneratorSettingsToUi() { var settings = AppSettingsStore.Current; _updatingUi = true; var preset = Presets.Contains(settings.PasswordGeneratorPreset) ? settings.PasswordGeneratorPreset : "Обычный — 16"; _presetBox.SelectedItem = preset; if (preset == "Свой") { _lengthBox.Value = Math.Clamp(settings.PasswordGeneratorLength, (int)_lengthBox.Minimum, (int)_lengthBox.Maximum); _lowerBox.Checked = settings.PasswordGeneratorLower; _upperBox.Checked = settings.PasswordGeneratorUpper; _digitsBox.Checked = settings.PasswordGeneratorDigits; _symbolsBox.Checked = settings.PasswordGeneratorSymbols; } else { ApplyPreset(preset); } if (!_lowerBox.Checked && !_upperBox.Checked && !_digitsBox.Checked && !_symbolsBox.Checked) _lowerBox.Checked = true; _updatingUi = false; } private void ApplyPresetFromUi() { if (_updatingUi) return; var preset = _presetBox.SelectedItem?.ToString() ?? "Обычный — 16"; ApplyPreset(preset); SaveGeneratorSettingsFromUi(); TryGenerate(); } private void ApplyPreset(string preset) { if (preset == "Свой") return; _updatingUi = true; switch (preset) { case "Простой — 12, без символов": _lengthBox.Value = 12; _lowerBox.Checked = true; _upperBox.Checked = true; _digitsBox.Checked = true; _symbolsBox.Checked = false; break; case "Сильный — 24": _lengthBox.Value = 24; _lowerBox.Checked = true; _upperBox.Checked = true; _digitsBox.Checked = true; _symbolsBox.Checked = true; break; case "Максимальный — 32": _lengthBox.Value = 32; _lowerBox.Checked = true; _upperBox.Checked = true; _digitsBox.Checked = true; _symbolsBox.Checked = true; break; default: _lengthBox.Value = 16; _lowerBox.Checked = true; _upperBox.Checked = true; _digitsBox.Checked = true; _symbolsBox.Checked = true; break; } _updatingUi = false; } private void MarkCustomPreset() { if (_updatingUi) return; _updatingUi = true; _presetBox.SelectedItem = "Свой"; _updatingUi = false; SaveGeneratorSettingsFromUi(); } private void SaveGeneratorSettingsFromUi() { if (_updatingUi) return; AppSettingsStore.SetPasswordGeneratorSettings( _presetBox.SelectedItem?.ToString() ?? "Свой", (int)_lengthBox.Value, _lowerBox.Checked, _upperBox.Checked, _digitsBox.Checked, _symbolsBox.Checked); } private bool TryGenerate() { try { SaveGeneratorSettingsFromUi(); GeneratedPassword = PasswordGenerator.Generate( (int)_lengthBox.Value, _lowerBox.Checked, _upperBox.Checked, _digitsBox.Checked, _symbolsBox.Checked); _passwordBox.Text = GeneratedPassword; _statusLabel.Text = "Сгенерировано. Поля записи не изменены."; return true; } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Генератор пароля", MessageBoxButtons.OK, MessageBoxIcon.Warning); return false; } } private bool CopyGeneratedPassword() { if (string.IsNullOrEmpty(GeneratedPassword) && !TryGenerate()) return false; if (string.IsNullOrEmpty(GeneratedPassword)) return false; if (_copyAction != null) _copyAction(GeneratedPassword, "генератор"); else Clipboard.SetText(GeneratedPassword); _statusLabel.Text = "Скопировано. Главное окно доступно — вставляй в нужное поле."; return true; } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; foreach (var control in AllControls(this)) { switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; break; case NumericUpDown numeric: numeric.BackColor = palette.InputBack; numeric.ForeColor = palette.InputFore; break; case ComboBox comboBox: comboBox.BackColor = palette.InputBack; comboBox.ForeColor = palette.InputFore; break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case CheckBox checkBox: checkBox.ForeColor = palette.Fore; break; case Label label: label.ForeColor = ReferenceEquals(label, _statusLabel) ? palette.MutedFore : palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = palette.WindowBack; control.ForeColor = palette.Fore; break; } } } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } } internal sealed class ChangeMasterPasswordForm : Form { private readonly TextBox _oldBox = new(); private readonly TextBox _newBox = new(); private readonly TextBox _confirmBox = new(); public ChangeMasterPasswordForm() { BuildUi(); } public string OldPassword => _oldBox.Text; public string NewPassword => _newBox.Text; public string ConfirmPassword => _confirmBox.Text; private void BuildUi() { Text = "Смена мастер-пароля"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(520, 270); Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point); var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 6, Padding = new Padding(16) }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); Controls.Add(root); var title = new Label { Text = "Перед сменой программа создаст backup и перешифрует vault.lzdb.", AutoSize = false, Height = 40, Dock = DockStyle.Top, TextAlign = ContentAlignment.TopLeft, Margin = new Padding(0, 0, 0, 8) }; root.Controls.Add(title, 0, 0); AddPasswordRow(root, _oldBox, "Старый мастер-пароль"); AddPasswordRow(root, _newBox, "Новый мастер-пароль"); AddPasswordRow(root, _confirmBox, "Повтор нового мастер-пароля"); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; root.Controls.Add(buttons, 0, 5); var okButton = new Button { Text = "Сменить", Width = 105, Height = 34, Margin = new Padding(8, 6, 0, 0) }; okButton.Click += (_, _) => { DialogResult = DialogResult.OK; Close(); }; buttons.Controls.Add(okButton); var cancelButton = new Button { Text = "Отмена", Width = 95, Height = 34, Margin = new Padding(8, 6, 0, 0) }; cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; buttons.Controls.Add(cancelButton); AcceptButton = okButton; CancelButton = cancelButton; ApplyTheme(); Shown += (_, _) => _oldBox.Focus(); } private static void AddPasswordRow(TableLayoutPanel root, TextBox box, string placeholder) { box.UseSystemPasswordChar = true; box.Dock = DockStyle.Top; box.PlaceholderText = placeholder; box.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(box, 0, root.Controls.Count); } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; foreach (var control in AllControls(this)) { switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; textBox.Font = new Font("Segoe UI", AppSettingsStore.Current.TextFieldFontSize, FontStyle.Regular, GraphicsUnit.Point); break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case Label label: label.ForeColor = palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = palette.WindowBack; control.ForeColor = palette.Fore; break; } } } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } } internal sealed class VaultNameForm : Form { private readonly TextBox _nameBox = new(); public VaultNameForm(string currentName) { BuildUi(currentName); } public string VaultName => _nameBox.Text; private void BuildUi(string currentName) { Text = "Название сейфа"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(560, 170); Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point); var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(16) }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); Controls.Add(root); var label = new Label { Text = "Название видно в заголовке окна и в верхней панели. Пароли/токены/ключи от этого не меняются.", AutoSize = false, Height = 54, Dock = DockStyle.Top, TextAlign = ContentAlignment.TopLeft, Margin = new Padding(0, 0, 0, 8) }; root.Controls.Add(label, 0, 0); _nameBox.Dock = DockStyle.Top; _nameBox.Text = VaultNames.Normalize(currentName); _nameBox.PlaceholderText = "Например: Пароли, Токены, Проекты, Лицензии"; _nameBox.Margin = new Padding(0, 0, 0, 8); root.Controls.Add(_nameBox, 0, 1); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; root.Controls.Add(buttons, 0, 3); var okButton = new Button { Text = "Сохранить", Width = 105, Height = 34, Margin = new Padding(8, 6, 0, 0) }; okButton.Click += (_, _) => { _nameBox.Text = VaultNames.Normalize(_nameBox.Text); DialogResult = DialogResult.OK; Close(); }; buttons.Controls.Add(okButton); var cancelButton = new Button { Text = "Отмена", Width = 95, Height = 34, Margin = new Padding(8, 6, 0, 0) }; cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; buttons.Controls.Add(cancelButton); AcceptButton = okButton; CancelButton = cancelButton; ApplyTheme(); Shown += (_, _) => { _nameBox.Focus(); _nameBox.SelectAll(); }; } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; foreach (var control in AllControls(this)) { switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; textBox.Font = new Font("Segoe UI", AppSettingsStore.Current.TextFieldFontSize, FontStyle.Regular, GraphicsUnit.Point); break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case Label label: label.ForeColor = palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = palette.WindowBack; control.ForeColor = palette.Fore; break; } } } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } } internal sealed class ThemeEditorForm : Form { private readonly CheckBox _enabledBox = new(); private readonly TextBox _fontBox = new(); private readonly TextBox _monoFontBox = new(); private readonly TextBox _windowBackBox = new(); private readonly TextBox _sidebarBackBox = new(); private readonly TextBox _editorBackBox = new(); private readonly TextBox _inputBackBox = new(); private readonly TextBox _inputForeBox = new(); private readonly TextBox _foreBox = new(); private readonly TextBox _mutedForeBox = new(); private readonly TextBox _buttonBackBox = new(); private readonly TextBox _buttonForeBox = new(); public ThemeEditorForm() { BuildUi(); LoadFromSettings(ThemeSettingsStore.Current); } public CustomThemeSettings Settings { get; private set; } = new(); private void BuildUi() { Text = "Цвета и шрифты"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(740, 600); Font = new Font("Segoe UI", 10F, FontStyle.Regular, GraphicsUnit.Point); var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(16) }; root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); Controls.Add(root); var info = new Label { Text = "Минимальная настройка темы хранится отдельно: Zapominalka.theme.json. Цвета: #RRGGBB. Шрифт выбирается кнопкой \"...\" или вводится именем.", AutoSize = false, Height = 54, Dock = DockStyle.Top, TextAlign = ContentAlignment.TopLeft, Margin = new Padding(0, 0, 0, 8) }; root.Controls.Add(info, 0, 0); _enabledBox.Text = "Включить свои цвета и шрифты (снять галку = выключить)"; _enabledBox.AutoSize = true; _enabledBox.Margin = new Padding(0, 0, 0, 10); root.Controls.Add(_enabledBox, 0, 1); var grid = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, AutoScroll = true, Margin = new Padding(0) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 180)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 46)); root.Controls.Add(grid, 0, 2); AddFontRow(grid, "Шрифт полей", _fontBox, "Segoe UI"); AddFontRow(grid, "Шрифт TXT/MD", _monoFontBox, "Consolas"); AddColorRow(grid, "Фон окна", _windowBackBox, ThemePalette.Current().WindowBack); AddColorRow(grid, "Фон левой панели", _sidebarBackBox, ThemePalette.Current().SidebarBack); AddColorRow(grid, "Фон редактора", _editorBackBox, ThemePalette.Current().EditorBack); AddColorRow(grid, "Фон полей", _inputBackBox, ThemePalette.Current().InputBack); AddColorRow(grid, "Текст полей", _inputForeBox, ThemePalette.Current().InputFore); AddColorRow(grid, "Основной текст", _foreBox, ThemePalette.Current().Fore); AddColorRow(grid, "Статус/приглушённый", _mutedForeBox, ThemePalette.Current().MutedFore); AddColorRow(grid, "Фон кнопок", _buttonBackBox, ThemePalette.Current().ButtonBack); AddColorRow(grid, "Текст кнопок", _buttonForeBox, ThemePalette.Current().ButtonFore); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; root.Controls.Add(buttons, 0, 3); var saveButton = new Button { Text = "Сохранить и применить", Width = 165, Height = 34, Margin = new Padding(8, 7, 0, 0) }; saveButton.Click += (_, _) => SaveAndClose(); buttons.Controls.Add(saveButton); var cancelButton = new Button { Text = "Отмена", Width = 95, Height = 34, Margin = new Padding(8, 7, 0, 0) }; cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; buttons.Controls.Add(cancelButton); var resetButton = new Button { Text = "Сброс", Width = 95, Height = 34, Margin = new Padding(8, 7, 0, 0) }; resetButton.Click += (_, _) => LoadFromSettings(new CustomThemeSettings()); buttons.Controls.Add(resetButton); var openJsonButton = new Button { Text = "JSON в Блокноте", Width = 130, Height = 34, Margin = new Padding(8, 7, 0, 0) }; openJsonButton.Click += (_, _) => OpenThemeJson(); buttons.Controls.Add(openJsonButton); AcceptButton = saveButton; CancelButton = cancelButton; ApplyTheme(); } private void LoadFromSettings(CustomThemeSettings settings) { _enabledBox.Checked = true; _fontBox.Text = string.IsNullOrWhiteSpace(settings.FontFamily) ? "Segoe UI" : settings.FontFamily; _monoFontBox.Text = string.IsNullOrWhiteSpace(settings.MonoFontFamily) ? "Consolas" : settings.MonoFontFamily; _windowBackBox.Text = settings.WindowBack; _sidebarBackBox.Text = settings.SidebarBack; _editorBackBox.Text = settings.EditorBack; _inputBackBox.Text = settings.InputBack; _inputForeBox.Text = settings.InputFore; _foreBox.Text = settings.Fore; _mutedForeBox.Text = settings.MutedFore; _buttonBackBox.Text = settings.ButtonBack; _buttonForeBox.Text = settings.ButtonFore; } private void SaveAndClose() { var boxes = new[] { _windowBackBox, _sidebarBackBox, _editorBackBox, _inputBackBox, _inputForeBox, _foreBox, _mutedForeBox, _buttonBackBox, _buttonForeBox }; foreach (var box in boxes) { if (!string.IsNullOrWhiteSpace(box.Text) && !ThemeSettingsStore.TryParseColor(box.Text, out _)) { MessageBox.Show(this, "Неверный цвет: " + box.Text + Environment.NewLine + "Формат должен быть #RRGGBB.", "Цвета", MessageBoxButtons.OK, MessageBoxIcon.Warning); box.Focus(); box.SelectAll(); return; } } Settings = new CustomThemeSettings { Version = 1, Enabled = _enabledBox.Checked, FontFamily = string.IsNullOrWhiteSpace(_fontBox.Text) ? "Segoe UI" : _fontBox.Text.Trim(), MonoFontFamily = string.IsNullOrWhiteSpace(_monoFontBox.Text) ? "Consolas" : _monoFontBox.Text.Trim(), WindowBack = NormalizeColorBox(_windowBackBox), SidebarBack = NormalizeColorBox(_sidebarBackBox), EditorBack = NormalizeColorBox(_editorBackBox), InputBack = NormalizeColorBox(_inputBackBox), InputFore = NormalizeColorBox(_inputForeBox), Fore = NormalizeColorBox(_foreBox), MutedFore = NormalizeColorBox(_mutedForeBox), ButtonBack = NormalizeColorBox(_buttonBackBox), ButtonFore = NormalizeColorBox(_buttonForeBox) }; DialogResult = DialogResult.OK; Close(); } private static string NormalizeColorBox(TextBox box) { return ThemeSettingsStore.TryParseColor(box.Text, out var color) ? ThemeSettingsStore.ToHex(color) : ""; } private void OpenThemeJson() { try { ThemeSettingsStore.Save(); var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "notepad.exe", Arguments = QuoteArgument(AppPaths.ThemeFile), UseShellExecute = false }); if (process != null) { _ = Task.Run(() => { try { process.WaitForExit(); process.Dispose(); if (!IsDisposed && IsHandleCreated) { BeginInvoke(new Action(() => { ThemeSettingsStore.Load(); LoadFromSettings(ThemeSettingsStore.Current); ApplyTheme(); })); } } catch { // Редактор темы не должен падать из-за Блокнота. } }); } } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка открытия JSON темы", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private static string QuoteArgument(string value) { return "\"" + value.Replace("\"", "\\\"") + "\""; } private static void AddFontRow(TableLayoutPanel grid, string caption, TextBox box, string fallback) { var row = grid.RowCount++; grid.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = caption, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 8), AutoSize = false, Height = 32 }; grid.Controls.Add(label, 0, row); box.Dock = DockStyle.Top; box.Margin = new Padding(0, 0, 8, 8); box.Height = 30; box.PlaceholderText = fallback; grid.Controls.Add(box, 1, row); var pickButton = new Button { Text = "...", Dock = DockStyle.Top, Height = 30, Margin = new Padding(0, 0, 0, 8) }; pickButton.Click += (_, _) => PickFont(box, fallback); grid.Controls.Add(pickButton, 2, row); } private static void PickFont(TextBox box, string fallback) { var currentFamily = string.IsNullOrWhiteSpace(box.Text) ? fallback : box.Text.Trim(); Font currentFont; try { currentFont = new Font(currentFamily, 10F, FontStyle.Regular, GraphicsUnit.Point); } catch { currentFont = new Font(fallback, 10F, FontStyle.Regular, GraphicsUnit.Point); } using (currentFont) using (var dialog = new FontDialog { Font = currentFont, ShowEffects = false, AllowVerticalFonts = false }) { if (dialog.ShowDialog() == DialogResult.OK) box.Text = dialog.Font.FontFamily.Name; } } private static bool HasCustomThemeValues(CustomThemeSettings settings) { if (!string.Equals(settings.FontFamily?.Trim(), "Segoe UI", StringComparison.OrdinalIgnoreCase)) return true; if (!string.Equals(settings.MonoFontFamily?.Trim(), "Consolas", StringComparison.OrdinalIgnoreCase)) return true; return !string.IsNullOrWhiteSpace(settings.WindowBack) || !string.IsNullOrWhiteSpace(settings.SidebarBack) || !string.IsNullOrWhiteSpace(settings.EditorBack) || !string.IsNullOrWhiteSpace(settings.InputBack) || !string.IsNullOrWhiteSpace(settings.InputFore) || !string.IsNullOrWhiteSpace(settings.Fore) || !string.IsNullOrWhiteSpace(settings.MutedFore) || !string.IsNullOrWhiteSpace(settings.ButtonBack) || !string.IsNullOrWhiteSpace(settings.ButtonFore); } private void AddColorRow(TableLayoutPanel grid, string caption, TextBox box, Color fallback) { var row = grid.RowCount++; grid.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = caption, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 8), AutoSize = false, Height = 32 }; grid.Controls.Add(label, 0, row); box.Dock = DockStyle.Top; box.Margin = new Padding(0, 0, 8, 8); box.Height = 30; box.PlaceholderText = ThemeSettingsStore.ToHex(fallback); grid.Controls.Add(box, 1, row); var pickButton = new Button { Text = "...", Dock = DockStyle.Top, Height = 30, Margin = new Padding(0, 0, 0, 8) }; pickButton.Click += (_, _) => PickColor(box, fallback); grid.Controls.Add(pickButton, 2, row); } private void PickColor(TextBox box, Color fallback) { using var dialog = new ColorDialog { FullOpen = true, Color = ThemeSettingsStore.TryParseColor(box.Text, out var color) ? color : fallback }; if (dialog.ShowDialog(this) == DialogResult.OK) box.Text = ThemeSettingsStore.ToHex(dialog.Color); } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; foreach (var control in AllControls(this)) { switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case CheckBox checkBox: checkBox.BackColor = palette.WindowBack; checkBox.ForeColor = palette.Fore; break; case Label label: label.ForeColor = palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = palette.WindowBack; control.ForeColor = palette.Fore; break; } } } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } } internal sealed class SmoothPanel : Panel { public SmoothPanel() { DoubleBuffered = true; ResizeRedraw = true; } } internal sealed class MainForm : Form { private static readonly string[] CustomFieldTypes = { "text", "secret", "password", "email", "phone", "url", "notes" }; private VaultSession _session; private readonly TextBox _searchBox = new(); private readonly ComboBox _categoryFilterBox = new(); private readonly ListBox _recordList = new(); private readonly Panel _editorHost = new SmoothPanel(); private readonly Label _vaultTitleLabel = new(); private readonly Label _vaultSubTitleLabel = new(); private readonly Label _statusLabel = new(); private readonly ComboBox _themeBox = new(); private readonly ToolTip _statusToolTip = new(); private readonly Dictionary<string, TextBox> _fixedBoxes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TextBox> _fixedLabelBoxes = new(StringComparer.OrdinalIgnoreCase); private readonly List<CustomFieldControls> _customRows = new(); private readonly System.Windows.Forms.Timer _clipboardTimer = new(); private PasswordGeneratorForm? _passwordGeneratorForm; private ComboBox? _recordCategoryBox; private TextBox? _documentTitleBox; private TextBox? _documentSourceBox; private ComboBox? _documentFormatBox; private TextBox? _documentTextBox; private string? _currentRecordId; private string? _lastCopiedText; private string _currentEditorKind = ""; private int _currentCustomRowCount = -1; private bool _loadingEditor; private bool _refreshingList; private bool _dirty; private bool _updatingThemeBox; private bool _updatingCategoryFilter; public MainForm(VaultSession session) { _session = session; BuildUi(); UpdateVaultTitle(); RefreshList(selectId: null, loadSelected: false); ClearEditor(); SetStatus($"База открыта: {_session.Data.Records.Count} записей. Выбери запись слева."); } private void BuildUi() { Text = "Zapominalka — " + VaultNames.Display(_session.Data); StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(1100, 680); Size = new Size(1280, 780); Font = MakeFont(ThemeSettingsStore.TextFontFamily, 10F, FontStyle.Regular); BackColor = ThemePalette.Current().WindowBack; KeyPreview = true; AllowDrop = true; DragEnter += MainDragEnter; DragDrop += MainDragDrop; KeyDown += (_, e) => { if (e.Control && e.KeyCode == Keys.S) { SaveVault("Сохранено"); e.SuppressKeyPress = true; } }; FormClosing += (_, e) => { // При закрытии больше не сохраняем молча. // Если пользователь менял карточку и не нажал «Сохранить», даём выбор. if (!ConfirmSaveDirtyEditorBeforeLeaving()) e.Cancel = true; }; _clipboardTimer.Interval = 30_000; _clipboardTimer.Tick += (_, _) => ClearClipboardIfStillOurs(); var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); Controls.Add(root); root.Controls.Add(BuildVaultHeader(), 0, 0); var main = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1 }; main.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 330)); main.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); root.Controls.Add(main, 0, 1); var left = BuildLeftPanel(); main.Controls.Add(left, 0, 0); _editorHost.Dock = DockStyle.Fill; _editorHost.AutoScroll = true; _editorHost.BackColor = Color.White; _editorHost.Padding = new Padding(18); _editorHost.AllowDrop = true; _editorHost.DragEnter += MainDragEnter; _editorHost.DragDrop += MainDragDrop; _editorHost.Resize += (_, _) => FitEditorWidth(); main.Controls.Add(_editorHost, 1, 0); _statusLabel.Dock = DockStyle.Fill; _statusLabel.TextAlign = ContentAlignment.MiddleLeft; _statusLabel.Padding = new Padding(12, 0, 12, 0); _statusLabel.ForeColor = Color.DimGray; _statusLabel.AutoEllipsis = true; _statusLabel.Cursor = Cursors.Hand; _statusLabel.DoubleClick += (_, _) => OpenAppFolder(); root.Controls.Add(_statusLabel, 0, 2); ApplyTheme(); ApplyTextFieldFonts(); SetStatus("Готово"); } private Control BuildVaultHeader() { // Компактная верхняя панель. // В 0.4.1 здесь была большая таблица с отдельной кнопкой «Название» справа, // и на крупном шрифте/масштабе Windows она начинала визуально разъезжаться. // Кнопка «Название» уже есть слева, поэтому сверху оставляем только название сейфа. var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(12, 5, 12, 4), Margin = new Padding(0), Name = "vaultHeaderPanel" }; _vaultTitleLabel.Dock = DockStyle.Fill; _vaultTitleLabel.AutoEllipsis = true; _vaultTitleLabel.TextAlign = ContentAlignment.MiddleLeft; _vaultTitleLabel.Font = new Font("Segoe UI", 11.5F, FontStyle.Bold, GraphicsUnit.Point); _vaultTitleLabel.Margin = new Padding(0); panel.Controls.Add(_vaultTitleLabel); return panel; } private void UpdateVaultTitle() { var name = VaultNames.Display(_session.Data); Text = "Zapominalka — " + name; _vaultTitleLabel.Text = "Сейф: " + name; _vaultSubTitleLabel.Text = ""; _statusToolTip.SetToolTip(_vaultTitleLabel, _session.VaultPath); } private void RenameVault() { if (!ConfirmSaveDirtyEditorBeforeLeaving()) return; using var dialog = new VaultNameForm(VaultNames.Display(_session.Data)); if (dialog.ShowDialog(this) != DialogResult.OK) return; var newName = VaultNames.Normalize(dialog.VaultName); if (string.Equals(newName, VaultNames.Display(_session.Data), StringComparison.Ordinal)) { SetStatus("Название сейфа не изменилось."); return; } try { _session.Data.VaultName = newName; _session.Data.UpdatedUtc = DateTime.UtcNow; VaultStore.Save(_session.VaultPath, _session.MasterPassword, _session.Data); _dirty = false; UpdateVaultTitle(); SetStatus("Название сейфа изменено: " + newName); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка переименования сейфа", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void RegisterTextBox(TextBox box, bool monospace = false) { box.Tag = monospace ? "font:mono" : "font:text"; ApplyTextBoxFont(box); box.MouseWheel += TextBoxMouseWheel; } private void TextBoxMouseWheel(object? sender, MouseEventArgs e) { if ((ModifierKeys & Keys.Control) != Keys.Control) return; var step = e.Delta > 0 ? 1F : -1F; AppSettingsStore.SetTextFieldFontSize(AppSettingsStore.Current.TextFieldFontSize + step); ApplyTextFieldFonts(); SetStatus($"Размер текста в полях: {AppSettingsStore.Current.TextFieldFontSize:0.#}"); if (e is HandledMouseEventArgs handled) handled.Handled = true; } private void ApplyTextFieldFonts() { foreach (var textBox in AllControls(this).OfType<TextBox>()) ApplyTextBoxFont(textBox); } private static void ApplyTextBoxFont(TextBox box) { var family = string.Equals(box.Tag?.ToString(), "font:mono", StringComparison.Ordinal) ? ThemeSettingsStore.MonoFontFamily : ThemeSettingsStore.TextFontFamily; box.Font = MakeFont(family, AppSettingsStore.Current.TextFieldFontSize, FontStyle.Regular); } private static Font MakeFont(string family, float size, FontStyle style) { try { return new Font(family, size, style, GraphicsUnit.Point); } catch { return new Font("Segoe UI", size, style, GraphicsUnit.Point); } } private void ApplyTheme() { var palette = ThemePalette.Current(); BackColor = palette.WindowBack; ForeColor = palette.Fore; ApplyThemeRecursive(this, palette, insideEditor: false, insideLeft: false); } private void ApplyThemeRecursive(Control control, ThemePalette palette, bool insideEditor, bool insideLeft) { var nowEditor = insideEditor || ReferenceEquals(control, _editorHost); var nowLeft = insideLeft || string.Equals(control.Name, "leftPanel", StringComparison.Ordinal); switch (control) { case TextBox textBox: textBox.BackColor = palette.InputBack; textBox.ForeColor = palette.InputFore; break; case ListBox listBox: listBox.BackColor = palette.InputBack; listBox.ForeColor = palette.InputFore; break; case ComboBox comboBox: comboBox.BackColor = palette.InputBack; comboBox.ForeColor = palette.InputFore; break; case Button button: button.UseVisualStyleBackColor = false; button.BackColor = palette.ButtonBack; button.ForeColor = palette.ButtonFore; break; case Label label when ReferenceEquals(label, _statusLabel): label.ForeColor = palette.MutedFore; break; case Label label: label.ForeColor = palette.Fore; break; case TableLayoutPanel or FlowLayoutPanel or Panel: control.BackColor = nowEditor ? palette.EditorBack : nowLeft ? palette.SidebarBack : palette.WindowBack; control.ForeColor = palette.Fore; break; } foreach (Control child in control.Controls) ApplyThemeRecursive(child, palette, nowEditor, nowLeft); } private static IEnumerable<Control> AllControls(Control root) { foreach (Control child in root.Controls) { yield return child; foreach (var nested in AllControls(child)) yield return nested; } } private void SyncThemeBoxFromSettings() { _updatingThemeBox = true; _themeBox.SelectedItem = ThemeDisplayName(AppSettingsStore.Current.Theme); if (_themeBox.SelectedIndex < 0) _themeBox.SelectedIndex = 0; _updatingThemeBox = false; } private static string ThemeDisplayName(string? value) { return value switch { "light" => "Светлая", "dark" => "Тёмная", _ => "Система" }; } private static string ThemeValueFromDisplay(string? value) { return value switch { "Светлая" => "light", "Тёмная" => "dark", _ => "system" }; } private Control BuildLeftPanel() { var left = new TableLayoutPanel { Dock = DockStyle.Fill, Name = "leftPanel", BackColor = ThemePalette.Current().SidebarBack, Padding = new Padding(12), ColumnCount = 1, RowCount = 4 }; left.RowStyles.Add(new RowStyle(SizeType.Absolute, 82)); left.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); left.RowStyles.Add(new RowStyle(SizeType.Absolute, 370)); left.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); left.Controls.Add(BuildSearchAndCategoryPanel(), 0, 0); _recordList.Dock = DockStyle.Fill; _recordList.Font = MakeFont(ThemeSettingsStore.TextFontFamily, Math.Max(13F, AppSettingsStore.Current.CardTitleFontSize), FontStyle.Regular); _recordList.ItemHeight = Math.Max(20, (int)Math.Ceiling(_recordList.Font.GetHeight()) + 4); _recordList.IntegralHeight = false; _recordList.AllowDrop = true; _recordList.DragEnter += MainDragEnter; _recordList.DragDrop += MainDragDrop; _recordList.SelectedIndexChanged += (_, _) => { if (_loadingEditor || _refreshingList) return; var targetId = (_recordList.SelectedItem as RecordListItem)?.Record.Id; if (string.Equals(targetId, _currentRecordId, StringComparison.Ordinal)) return; if (!ConfirmSaveDirtyEditorBeforeLeaving()) { SelectRecordInList(_currentRecordId); return; } if (!string.IsNullOrWhiteSpace(targetId)) { // Важно: при обычном выборе записи НЕ пересобираем левый список. // Иначе на большой базе появляются лишние лаги, прыжки позиции и мерцание. // Перерисовывается только правая карточка, потому что набор полей у записей может отличаться. LoadRecord(targetId); } else { ClearEditor(); } }; left.Controls.Add(_recordList, 0, 1); var buttons = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 8, Margin = new Padding(0, 8, 0, 4), Padding = new Padding(0) }; buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); for (var i = 0; i < 8; i++) buttons.RowStyles.Add(new RowStyle(SizeType.Percent, 12.5F)); left.Controls.Add(buttons, 0, 2); AddLeftButton(buttons, "Карточка", AddRecord, 0, 0); AddLeftButton(buttons, "Текст", AddTextRecord, 1, 0); AddLeftButton(buttons, "Импорт TXT/MD", ImportTextFilesDialog, 0, 1); AddLeftButton(buttons, "Импорт CSV", ImportEnpassCsvDialog, 1, 1); AddLeftButton(buttons, "Сохранить", () => SaveVault("Сохранено"), 0, 2); AddLeftButton(buttons, "Удалить", DeleteCurrentRecord, 1, 2); AddLeftButton(buttons, "Backup", CreateBackup, 0, 3); AddLeftButton(buttons, "Экспорт", ExportVault, 1, 3); AddLeftButton(buttons, "Папка", OpenAppFolder, 0, 4); AddLeftButton(buttons, "Путь базы", ChooseDataFolder, 1, 4); AddLeftButton(buttons, "Пароль", ChangeMasterPassword, 0, 5); AddLeftButton(buttons, "Генератор", OpenPasswordGenerator, 1, 5); AddLeftButton(buttons, "Блокировка", LockVault, 0, 6); AddLeftButton(buttons, "Название", RenameVault, 1, 6); AddLeftButton(buttons, "Цвета", OpenThemeEditor, 0, 7); AddLeftButton(buttons, "JSON темы", OpenThemeJson, 1, 7); left.Controls.Add(BuildThemePanel(), 0, 3); return left; } private Control BuildSearchAndCategoryPanel() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, Margin = new Padding(0), Padding = new Padding(0) }; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); _searchBox.Dock = DockStyle.Fill; _searchBox.PlaceholderText = "Поиск"; _searchBox.Margin = new Padding(0, 0, 0, 6); _searchBox.TextChanged += (_, _) => { // Поиск не должен молча сохранять открытую карточку. // Карточка остаётся открытой, список просто фильтруется. RefreshList(_currentRecordId, loadSelected: false); }; RegisterTextBox(_searchBox); panel.Controls.Add(_searchBox, 0, 0); _categoryFilterBox.Dock = DockStyle.Fill; _categoryFilterBox.DropDownStyle = ComboBoxStyle.DropDownList; _categoryFilterBox.Margin = new Padding(0, 0, 0, 4); _categoryFilterBox.SelectedIndexChanged += (_, _) => { if (_updatingCategoryFilter) return; RefreshList(_currentRecordId, loadSelected: false); SetStatus("Фильтр категории: " + (_categoryFilterBox.SelectedItem?.ToString() ?? RecordCategories.AllDisplay)); }; RegisterCategoryFilterInitial(); panel.Controls.Add(_categoryFilterBox, 0, 1); return panel; } private void RegisterCategoryFilterInitial() { UpdateCategoryFilterItems(_categoryFilterBox.SelectedItem?.ToString() ?? RecordCategories.AllDisplay); } private void UpdateCategoryFilterItems(string? keepValue) { if (_categoryFilterBox.IsDisposed) return; var wanted = string.IsNullOrWhiteSpace(keepValue) ? RecordCategories.AllDisplay : keepValue.Trim(); _updatingCategoryFilter = true; try { _categoryFilterBox.Items.Clear(); _categoryFilterBox.Items.Add(RecordCategories.AllDisplay); _categoryFilterBox.Items.Add(RecordCategories.EmptyDisplay); foreach (var category in RecordCategories.ListFor(_session.Data)) _categoryFilterBox.Items.Add(category); var index = 0; for (var i = 0; i < _categoryFilterBox.Items.Count; i++) { if (string.Equals(_categoryFilterBox.Items[i]?.ToString(), wanted, StringComparison.CurrentCultureIgnoreCase)) { index = i; break; } } _categoryFilterBox.SelectedIndex = index; } finally { _updatingCategoryFilter = false; } } private string CurrentCategoryFilter() { return _categoryFilterBox.SelectedItem?.ToString() ?? RecordCategories.AllDisplay; } private string CurrentCategoryForNewRecord() { var filter = CurrentCategoryFilter(); if (string.Equals(filter, RecordCategories.AllDisplay, StringComparison.CurrentCultureIgnoreCase)) return ""; return RecordCategories.Normalize(filter); } private bool MatchesCategoryFilter(VaultRecord record) { var filter = CurrentCategoryFilter(); if (string.Equals(filter, RecordCategories.AllDisplay, StringComparison.CurrentCultureIgnoreCase)) return true; var category = RecordCategories.Normalize(record.Category); if (string.Equals(filter, RecordCategories.EmptyDisplay, StringComparison.CurrentCultureIgnoreCase)) return string.IsNullOrWhiteSpace(category); return string.Equals(category, RecordCategories.Normalize(filter), StringComparison.CurrentCultureIgnoreCase); } private IEnumerable<string> CategoryEditorItems(string? currentCategory) { var seen = new HashSet<string>(StringComparer.CurrentCultureIgnoreCase); yield return RecordCategories.EmptyDisplay; seen.Add(RecordCategories.EmptyDisplay); var current = RecordCategories.Normalize(currentCategory); if (!string.IsNullOrWhiteSpace(current) && seen.Add(current)) yield return current; foreach (var category in RecordCategories.ListFor(_session.Data)) if (seen.Add(category)) yield return category; } private void FillCategoryEditor(ComboBox box, string? category) { var normalized = RecordCategories.Normalize(category); var display = RecordCategories.Display(normalized); box.Items.Clear(); foreach (var item in CategoryEditorItems(normalized)) box.Items.Add(item); box.Text = display; } private string CurrentEditorCategory() { return RecordCategories.Normalize(_recordCategoryBox?.Text); } private Control BuildThemePanel() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0, 2, 0, 0), Padding = new Padding(0) }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 62)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); var label = new Label { Text = "Тема", AutoSize = true, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 0, 8, 0) }; panel.Controls.Add(label, 0, 0); _themeBox.Dock = DockStyle.Fill; _themeBox.DropDownStyle = ComboBoxStyle.DropDownList; _themeBox.Items.Clear(); _themeBox.Items.AddRange(new object[] { "Система", "Светлая", "Тёмная" }); _themeBox.Margin = new Padding(0, 10, 0, 10); SyncThemeBoxFromSettings(); _themeBox.SelectedIndexChanged += (_, _) => { if (_updatingThemeBox) return; AppSettingsStore.SetTheme(ThemeValueFromDisplay(_themeBox.SelectedItem?.ToString())); ApplyTheme(); ApplyTextFieldFonts(); SetStatus("Тема: " + (_themeBox.SelectedItem?.ToString() ?? "Система")); }; panel.Controls.Add(_themeBox, 1, 0); return panel; } private static Button AddLeftButton(TableLayoutPanel panel, string text, Action action, int column, int row) { var button = new Button { Text = text, Dock = DockStyle.Fill, Height = 34, Margin = new Padding(column == 0 ? 0 : 4, 0, column == 0 ? 4 : 0, 7) }; button.Click += (_, _) => action(); panel.Controls.Add(button, column, row); return button; } private void RefreshList(string? selectId, bool loadSelected) { var previousTopIndex = SafeListTopIndex(); _refreshingList = true; _recordList.BeginUpdate(); _recordList.Items.Clear(); UpdateCategoryFilterItems(CurrentCategoryFilter()); var query = _searchBox.Text.Trim(); var records = _session.Data.Records .Where(r => MatchesSearch(r, query)) .Where(MatchesCategoryFilter) .OrderBy(r => RecordCategories.Display(r.Category), StringComparer.CurrentCultureIgnoreCase) .ThenBy(r => r.Title, StringComparer.CurrentCultureIgnoreCase) .ToList(); foreach (var record in records) _recordList.Items.Add(new RecordListItem(record)); var selectedIndex = -1; if (!string.IsNullOrWhiteSpace(selectId)) selectedIndex = IndexOfRecordInList(selectId); if (selectedIndex < 0 && _recordList.Items.Count > 0 && loadSelected) selectedIndex = 0; _recordList.SelectedIndex = selectedIndex; _recordList.EndUpdate(); RestoreListTopIndex(previousTopIndex); var selectedItem = _recordList.SelectedItem as RecordListItem; _refreshingList = false; if (!loadSelected) return; if (selectedItem != null) { if (selectedItem.Record.Id != _currentRecordId || _editorHost.Controls.Count == 0) LoadRecord(selectedItem.Record.Id); } else { ClearEditor(); } } private int IndexOfRecordInList(string recordId) { if (string.IsNullOrWhiteSpace(recordId)) return -1; for (var i = 0; i < _recordList.Items.Count; i++) { if (_recordList.Items[i] is RecordListItem item && item.Record.Id == recordId) return i; } return -1; } private int SafeListTopIndex() { try { return _recordList.Items.Count == 0 ? 0 : _recordList.TopIndex; } catch { return 0; } } private void RestoreListTopIndex(int topIndex) { try { if (_recordList.Items.Count == 0) return; _recordList.TopIndex = Math.Max(0, Math.Min(topIndex, _recordList.Items.Count - 1)); } catch { // У ListBox бывают капризы на смене размера окна. Позиция списка не критичнее данных. } } private void SelectRecordAtListIndex(int index, bool loadSelected) { if (_recordList.Items.Count == 0) { ClearEditor(); return; } var selectedIndex = Math.Max(0, Math.Min(index, _recordList.Items.Count - 1)); _refreshingList = true; try { _recordList.SelectedIndex = selectedIndex; } finally { _refreshingList = false; } if (loadSelected && _recordList.Items[selectedIndex] is RecordListItem item) LoadRecord(item.Record.Id); } private void SelectRecordInList(string? recordId) { _refreshingList = true; try { var index = -1; if (!string.IsNullOrWhiteSpace(recordId)) { for (var i = 0; i < _recordList.Items.Count; i++) { if (_recordList.Items[i] is RecordListItem item && item.Record.Id == recordId) { index = i; break; } } } _recordList.SelectedIndex = index; } finally { _refreshingList = false; } } private bool ConfirmSaveDirtyEditorBeforeLeaving() { if (!_dirty) return true; var title = CurrentEditorDisplayTitle(); var answer = MessageBox.Show( this, $"В карточке «{title}» есть несохранённые изменения." + Environment.NewLine + Environment.NewLine + "Сохранить перед переходом?", "Несохранённые изменения", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); if (answer == DialogResult.Cancel) { SetStatus("Переход отменён: изменения не сохранены."); return false; } if (answer == DialogResult.No) { _dirty = false; SetStatus("Изменения отброшены"); return true; } try { SaveCurrentEditorToRecord(); VaultStore.Save(_session.VaultPath, _session.MasterPassword, _session.Data); _dirty = false; SetStatus("Сохранено"); return true; } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } } private string CurrentEditorDisplayTitle() { string? title = null; if (_documentTitleBox != null) title = _documentTitleBox.Text; else if (_fixedBoxes.TryGetValue("title", out var titleBox)) title = titleBox.Text; else title = CurrentRecord()?.Title; return string.IsNullOrWhiteSpace(title) ? "Без названия" : title.Trim(); } private static bool MatchesSearch(VaultRecord r, string query) { if (string.IsNullOrWhiteSpace(query)) return true; var haystack = string.Join("\n", new[] { r.RecordType, r.Category, r.Title, r.SourceFileName, r.DocumentFormat, r.DocumentText, r.Url, r.Login, r.Pass, r.Secret1, r.Telefon, r.Email, r.Secret2, r.Secret3, r.Notes1, r.Notes2, string.Join("\n", r.CustomFields.Select(f => $"{f.Name}\n{f.Type}\n{f.Value}")) }); return haystack.Contains(query, StringComparison.CurrentCultureIgnoreCase); } private void LoadRecord(string id) { var record = FindRecord(id); if (record == null) { ClearEditor(); return; } if (IsDocumentRecord(record)) { LoadDocumentRecord(record); return; } // На обычном клике по другой записи НЕ пересоздаём правую часть, // если уже открыт такой же редактор карточки. Это убирает белый // мигающий прямоугольник и лаги на большой базе после CSV-импорта. if (_currentEditorKind == "fields" && _fixedBoxes.Count > 0 && _currentCustomRowCount == record.CustomFields.Count) { PopulateFieldsEditor(record); return; } BuildFieldsEditor(record); } private void PopulateFieldsEditor(VaultRecord record) { _loadingEditor = true; try { _currentRecordId = record.Id; _currentEditorKind = "fields"; _currentCustomRowCount = record.CustomFields.Count; _documentTitleBox = null; _documentSourceBox = null; _documentFormatBox = null; _documentTextBox = null; if (_recordCategoryBox != null) FillCategoryEditor(_recordCategoryBox, record.Category); PopulateFixedLabelBoxes(); SetFixedBox("title", record.Title); SetFixedBox("url", record.Url); SetFixedBox("login", record.Login); SetFixedBox("pass", record.Pass); SetFixedBox("secret1", record.Secret1); SetFixedBox("telefon", record.Telefon); SetFixedBox("email", record.Email); SetFixedBox("secret2", record.Secret2); SetFixedBox("secret3", record.Secret3); SetFixedBox("notes1", record.Notes1); SetFixedBox("notes2", record.Notes2); for (var i = 0; i < _customRows.Count && i < record.CustomFields.Count; i++) { var field = record.CustomFields[i]; var row = _customRows[i]; row.SecretVisible = false; row.NameBox.Text = field.Name; row.TypeBox.SelectedItem = CustomFieldTypes.Contains(field.Type, StringComparer.OrdinalIgnoreCase) ? field.Type.ToLowerInvariant() : "text"; row.ValueBox.Text = field.Value; ApplyCustomFieldType(row); } ResetSecretFixedFields(); ResetEditorScroll(); } finally { _loadingEditor = false; } _dirty = false; SetStatus("Запись открыта"); } private void PopulateFixedLabelBoxes() { foreach (var key in FixedFieldLabels.Keys) { if (_fixedLabelBoxes.TryGetValue(key, out var box)) box.Text = GetFixedFieldLabel(key); } } private string GetFixedFieldLabel(string key) { return FixedFieldLabels.Get(_session.Data, key); } private void SaveFixedFieldLabelsFromEditor() { if (_fixedLabelBoxes.Count == 0) return; var labels = FixedFieldLabels.Normalize(_session.Data.FixedFieldLabels); foreach (var key in FixedFieldLabels.Keys) { if (_fixedLabelBoxes.TryGetValue(key, out var box)) labels[key] = FixedFieldLabels.Normalize(box.Text, key); } _session.Data.FixedFieldLabels = labels; } private void SetFixedBox(string key, string value) { if (_fixedBoxes.TryGetValue(key, out var box)) box.Text = value ?? ""; } private void ResetSecretFixedFields() { foreach (var key in new[] { "pass", "secret1", "secret2", "secret3" }) { if (_fixedBoxes.TryGetValue(key, out var box)) box.UseSystemPasswordChar = true; } foreach (var button in AllControls(_editorHost).OfType<Button>()) { if (button.Text == "Скрыть") button.Text = "Показать"; } } private void BuildFieldsEditor(VaultRecord record) { _loadingEditor = true; _currentRecordId = record.Id; _currentEditorKind = "fields"; _currentCustomRowCount = record.CustomFields.Count; _documentTitleBox = null; _documentSourceBox = null; _documentFormatBox = null; _documentTextBox = null; _recordCategoryBox = null; _fixedBoxes.Clear(); _fixedLabelBoxes.Clear(); _customRows.Clear(); var table = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 3, Padding = new Padding(0), BackColor = Color.White, Tag = "fit-width" }; table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 125)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 300)); AddHeader(table, "Основные поля"); AddCategoryRow(table, record.Category); AddFixedRow(table, "title", record.Title, secret: false, multiline: false); AddFixedRow(table, "url", record.Url, secret: false, multiline: false, isUrl: true); AddFixedRow(table, "login", record.Login, secret: false, multiline: false); AddFixedRow(table, "pass", record.Pass, secret: true, multiline: false); AddFixedRow(table, "secret1", record.Secret1, secret: true, multiline: false); AddFixedRow(table, "telefon", record.Telefon, secret: false, multiline: false); AddFixedRow(table, "email", record.Email, secret: false, multiline: false); AddFixedRow(table, "secret2", record.Secret2, secret: true, multiline: false); AddFixedRow(table, "secret3", record.Secret3, secret: true, multiline: false); AddFixedRow(table, "notes1", record.Notes1, secret: false, multiline: true); AddFixedRow(table, "notes2", record.Notes2, secret: false, multiline: true); AddCustomFieldsBlock(table, record); ReplaceEditorContent(table, autoScroll: true); _loadingEditor = false; _dirty = false; ApplyTheme(); ApplyTextFieldFonts(); SetStatus("Запись открыта"); } private void ClearEditor() { _loadingEditor = true; _currentRecordId = null; _currentEditorKind = ""; _currentCustomRowCount = -1; _documentTitleBox = null; _documentSourceBox = null; _documentFormatBox = null; _documentTextBox = null; _recordCategoryBox = null; _fixedBoxes.Clear(); _fixedLabelBoxes.Clear(); _customRows.Clear(); var empty = new Label { Text = _session.Data.Records.Count == 0 ? "Записей нет. Нажми «Добавить». Робот одобряет простоту." : "База открыта. Выбери запись слева — карточка загрузится только после выбора.", AutoSize = true, ForeColor = Color.DimGray, Dock = DockStyle.Top, Padding = new Padding(8) }; ReplaceEditorContent(empty, autoScroll: true); _loadingEditor = false; ApplyTheme(); } private void LoadDocumentRecord(VaultRecord record) { if (_currentEditorKind == "document" && _documentTitleBox != null && _documentTextBox != null) { PopulateDocumentEditor(record); return; } BuildDocumentEditor(record); } private void PopulateDocumentEditor(VaultRecord record) { _loadingEditor = true; try { _currentRecordId = record.Id; _currentEditorKind = "document"; _currentCustomRowCount = -1; _fixedBoxes.Clear(); _fixedLabelBoxes.Clear(); _customRows.Clear(); if (_recordCategoryBox != null) FillCategoryEditor(_recordCategoryBox, record.Category); if (_documentTitleBox != null) _documentTitleBox.Text = record.Title; if (_documentSourceBox != null) _documentSourceBox.Text = record.SourceFileName; if (_documentFormatBox != null) _documentFormatBox.SelectedItem = NormalizeDocumentFormat(record.DocumentFormat); if (_documentTextBox != null) _documentTextBox.Text = NormalizeLineEndings(record.DocumentText); ResetEditorScroll(); } finally { _loadingEditor = false; } _dirty = false; SetStatus("TXT/MD запись открыта"); } private void BuildDocumentEditor(VaultRecord record) { _loadingEditor = true; _currentRecordId = record.Id; _currentEditorKind = "document"; _currentCustomRowCount = -1; _fixedBoxes.Clear(); _fixedLabelBoxes.Clear(); _customRows.Clear(); _recordCategoryBox = null; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, BackColor = Color.White, Padding = new Padding(0) }; layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); var info = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2, BackColor = Color.White, Margin = new Padding(0, 0, 0, 10) }; info.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 125)); info.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); layout.Controls.Add(info, 0, 0); var header = new Label { Text = "TXT/MD запись — простой редактор внутри сейфа", AutoSize = true, Font = new Font(Font, FontStyle.Bold), Margin = new Padding(0, 0, 0, 10) }; var headerRow = info.RowCount++; info.RowStyles.Add(new RowStyle(SizeType.AutoSize)); info.Controls.Add(header, 0, headerRow); info.SetColumnSpan(header, 2); _documentTitleBox = new TextBox { Text = record.Title, Dock = DockStyle.Top, Margin = new Padding(0, 0, 0, 8), PlaceholderText = "Название записи" }; _documentTitleBox.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(_documentTitleBox); AddDocumentInfoRow(info, "title", _documentTitleBox); _recordCategoryBox = BuildCategoryCombo(record.Category); AddDocumentInfoRow(info, "category", _recordCategoryBox); _documentSourceBox = new TextBox { Text = record.SourceFileName, Dock = DockStyle.Top, Margin = new Padding(0, 0, 0, 8), PlaceholderText = "исходный файл, если был импорт" }; _documentSourceBox.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(_documentSourceBox); AddDocumentInfoRow(info, "source", _documentSourceBox); _documentFormatBox = new ComboBox { Dock = DockStyle.Top, DropDownStyle = ComboBoxStyle.DropDownList, Margin = new Padding(0, 0, 0, 8) }; _documentFormatBox.Items.AddRange(new object[] { "md", "txt" }); _documentFormatBox.SelectedItem = NormalizeDocumentFormat(record.DocumentFormat); _documentFormatBox.SelectedIndexChanged += (_, _) => MarkDirty(); AddDocumentInfoRow(info, "format", _documentFormatBox); _documentTextBox = new TextBox { Text = NormalizeLineEndings(record.DocumentText), Dock = DockStyle.Fill, Multiline = true, AcceptsReturn = true, AcceptsTab = true, ScrollBars = ScrollBars.Vertical, WordWrap = true, Font = new Font("Consolas", 10F, FontStyle.Regular, GraphicsUnit.Point), Margin = new Padding(0, 0, 0, 8) }; _documentTextBox.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(_documentTextBox, monospace: true); layout.Controls.Add(_documentTextBox, 0, 1); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, Margin = new Padding(0) }; layout.Controls.Add(buttons, 0, 2); AddEditorButton(buttons, "Сохранить", 110, () => SaveVault("Сохранено")); AddEditorButton(buttons, "Копировать текст", 150, () => CopyToClipboard(_documentTextBox.Text, "текст")); AddEditorButton(buttons, "Вставить текст", 130, () => PasteFromClipboard(_documentTextBox, "текст")); AddEditorButton(buttons, "Экспорт файла", 130, ExportCurrentDocument); ReplaceEditorContent(layout, autoScroll: false); _loadingEditor = false; _dirty = false; ApplyTheme(); ApplyTextFieldFonts(); SetStatus("TXT/MD запись открыта"); } private void ReplaceEditorContent(Control content, bool autoScroll) { _editorHost.SuspendLayout(); try { var oldControls = _editorHost.Controls.Cast<Control>().ToList(); _editorHost.AutoScroll = autoScroll; _editorHost.Controls.Add(content); _editorHost.Controls.SetChildIndex(content, 0); foreach (var old in oldControls) { _editorHost.Controls.Remove(old); old.Dispose(); } FitEditorWidth(); ResetEditorScroll(); } finally { _editorHost.ResumeLayout(true); } } private void FitEditorWidth() { if (_editorHost.Controls.Count == 0) return; var control = _editorHost.Controls[0]; if (control.Tag?.ToString() != "fit-width") return; var width = _editorHost.ClientSize.Width - _editorHost.Padding.Left - _editorHost.Padding.Right - SystemInformation.VerticalScrollBarWidth - 8; control.Width = Math.Max(760, width); } private void ResetEditorScroll() { try { _editorHost.AutoScrollPosition = new Point(0, 0); } catch { // Сброс прокрутки — косметика. Данные важнее. } } private void AddDocumentInfoRow(TableLayoutPanel table, string caption, Control editor) { var row = table.RowCount++; table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = caption, AutoSize = true, Dock = DockStyle.Top, TextAlign = ContentAlignment.TopLeft, Margin = new Padding(0, 7, 10, 8) }; table.Controls.Add(label, 0, row); table.Controls.Add(editor, 1, row); } private static void AddEditorButton(FlowLayoutPanel panel, string text, int width, Action action) { var button = new Button { Text = text, Width = width, Height = 34, Margin = new Padding(0, 0, 8, 8) }; button.Click += (_, _) => action(); panel.Controls.Add(button); } private void ExportCurrentDocument() { SaveCurrentEditorToRecord(); var record = CurrentRecord(); if (record == null || !IsDocumentRecord(record)) return; Directory.CreateDirectory(AppPaths.ExportsDir); var ext = NormalizeDocumentFormat(record.DocumentFormat) == "txt" ? ".txt" : ".md"; var fileName = MakeSafeFileName(string.IsNullOrWhiteSpace(record.Title) ? "document" : record.Title) + ext; using var dialog = new SaveFileDialog { Title = "Экспорт TXT/MD записи", InitialDirectory = AppPaths.ExportsDir, FileName = fileName, Filter = "Markdown (*.md)|*.md|Text (*.txt)|*.txt", AddExtension = true, OverwritePrompt = true }; if (dialog.ShowDialog(this) != DialogResult.OK) return; try { File.WriteAllText(dialog.FileName, record.DocumentText, new UTF8Encoding(false)); SetStatus("Файл экспортирован: " + dialog.FileName); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка экспорта файла", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void AddHeader(TableLayoutPanel table, string text) { var row = table.RowCount++; table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = text, AutoSize = true, Font = new Font(Font, FontStyle.Bold), Margin = new Padding(0, 0, 0, 10) }; table.Controls.Add(label, 0, row); table.SetColumnSpan(label, 3); } private void AddCategoryRow(TableLayoutPanel table, string category) { var row = table.RowCount++; table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = "category", AutoSize = true, Dock = DockStyle.Top, TextAlign = ContentAlignment.TopLeft, Margin = new Padding(0, 7, 10, 8) }; table.Controls.Add(label, 0, row); _recordCategoryBox = BuildCategoryCombo(category); table.Controls.Add(_recordCategoryBox, 1, row); var hint = new Label { Text = "выбери или впиши новую", AutoSize = true, Dock = DockStyle.Top, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, 7, 0, 8) }; table.Controls.Add(hint, 2, row); } private ComboBox BuildCategoryCombo(string? category) { var box = new ComboBox { Dock = DockStyle.Top, DropDownStyle = ComboBoxStyle.DropDown, Margin = new Padding(0, 0, 10, 8), Height = 30, AutoCompleteMode = AutoCompleteMode.SuggestAppend, AutoCompleteSource = AutoCompleteSource.ListItems }; FillCategoryEditor(box, category); box.TextChanged += (_, _) => MarkDirty(); box.SelectedIndexChanged += (_, _) => MarkDirty(); return box; } private void AddFixedRow(TableLayoutPanel table, string key, string value, bool secret, bool multiline, bool isUrl = false) { var row = table.RowCount++; table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var labelBox = new TextBox { Text = GetFixedFieldLabel(key), Dock = DockStyle.Top, Margin = new Padding(0, 0, 10, 8), BorderStyle = BorderStyle.FixedSingle, PlaceholderText = key, Tag = "field-label", Height = 30 }; labelBox.TextChanged += (_, _) => MarkDirty(); labelBox.Leave += (_, _) => { var normalized = FixedFieldLabels.Normalize(labelBox.Text, key); if (!string.Equals(labelBox.Text, normalized, StringComparison.Ordinal)) labelBox.Text = normalized; }; table.Controls.Add(labelBox, 0, row); _fixedLabelBoxes[key] = labelBox; var box = new TextBox { Text = value, Dock = DockStyle.Top, Margin = new Padding(0, 0, 10, 8), UseSystemPasswordChar = secret, Multiline = multiline, ScrollBars = multiline ? ScrollBars.Vertical : ScrollBars.None, Height = multiline ? 84 : 30 }; box.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(box); if (isUrl) MakeUrlTextBoxActive(box); table.Controls.Add(box, 1, row); _fixedBoxes[key] = box; var buttons = new FlowLayoutPanel { Dock = DockStyle.Top, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, AutoSize = false, Width = 290, Height = 34, Margin = new Padding(0) }; var copyButton = new Button { Text = "Копир.", Width = 66, Height = 30, Margin = new Padding(0, 0, 6, 8) }; copyButton.Click += (_, _) => CopyToClipboard(box.Text, GetFixedFieldLabel(key)); buttons.Controls.Add(copyButton); var pasteButton = new Button { Text = "Встав.", Width = 66, Height = 30, Margin = new Padding(0, 0, 6, 8) }; pasteButton.Click += (_, _) => PasteFromClipboard(box, GetFixedFieldLabel(key)); buttons.Controls.Add(pasteButton); if (isUrl) { var openButton = new Button { Text = "Открыть", Width = 76, Height = 30, Margin = new Padding(0, 0, 6, 8) }; openButton.Click += (_, _) => OpenUrlFromText(box.Text); buttons.Controls.Add(openButton); } if (secret) { var showButton = new Button { Text = "Показать", Width = 76, Height = 30, Margin = new Padding(0, 0, 0, 8) }; showButton.Click += (_, _) => { box.UseSystemPasswordChar = !box.UseSystemPasswordChar; showButton.Text = box.UseSystemPasswordChar ? "Показать" : "Скрыть"; }; buttons.Controls.Add(showButton); } table.Controls.Add(buttons, 2, row); } private void MakeUrlTextBoxActive(TextBox box) { box.Cursor = Cursors.Hand; box.DoubleClick += (_, _) => OpenUrlFromText(box.Text); box.KeyDown += (_, e) => { if (e.Control && e.KeyCode == Keys.Enter) { OpenUrlFromText(box.Text); e.SuppressKeyPress = true; } }; } private void MakeCustomUrlTextBoxActive(TextBox box, ComboBox typeBox, TextBox nameBox) { box.DoubleClick += (_, _) => { if (IsUrlField(typeBox.SelectedItem?.ToString(), nameBox.Text)) OpenUrlFromText(box.Text); }; box.KeyDown += (_, e) => { if (e.Control && e.KeyCode == Keys.Enter && IsUrlField(typeBox.SelectedItem?.ToString(), nameBox.Text)) { OpenUrlFromText(box.Text); e.SuppressKeyPress = true; } }; } private void OpenUrlFromText(string? rawValue) { var url = NormalizeUrlForBrowser(rawValue); if (string.IsNullOrWhiteSpace(url)) { SetStatus("URL пустой — открывать нечего."); return; } try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true }); SetStatus("URL открыт в браузере: " + ShortenMiddle(url, 120)); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка открытия URL", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private static string NormalizeUrlForBrowser(string? rawValue) { if (string.IsNullOrWhiteSpace(rawValue)) return ""; var value = rawValue.Trim().Trim('"', '\'', '<', '>'); if (value.Length == 0) return ""; // Если случайно вставили несколько строк или буквальные \n, берём первую непустую строку. value = value .Replace("\\r\\n", "\n", StringComparison.Ordinal) .Replace("\\n", "\n", StringComparison.Ordinal) .Replace("\\r", "\n", StringComparison.Ordinal); value = value .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()) .FirstOrDefault(x => x.Length > 0) ?? ""; if (value.Length == 0) return ""; if (!value.Contains("://", StringComparison.Ordinal)) value = "https://" + value.TrimStart('/'); if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return ""; if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return ""; return uri.AbsoluteUri; } private void AddCustomFieldsBlock(TableLayoutPanel outerTable, VaultRecord record) { AddHeader(outerTable, "Свои поля"); var row = outerTable.RowCount++; outerTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var panel = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Top, ColumnCount = 5, BackColor = Color.White, Margin = new Padding(0, 0, 0, 12) }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 160)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 105)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 375)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); outerTable.Controls.Add(panel, 0, row); outerTable.SetColumnSpan(panel, 3); AddCustomHeader(panel); for (var i = 0; i < record.CustomFields.Count; i++) AddCustomRow(panel, record.CustomFields[i], i); var addRow = outerTable.RowCount++; outerTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var addButton = new Button { Text = "Добавить своё поле", AutoSize = true, Height = 34, Padding = new Padding(14, 4, 14, 4), Margin = new Padding(0, 2, 0, 14) }; addButton.Click += (_, _) => AddCustomField(); outerTable.Controls.Add(addButton, 0, addRow); outerTable.SetColumnSpan(addButton, 3); } private static void AddCustomHeader(TableLayoutPanel panel) { var row = panel.RowCount++; panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); AddSmallHeader(panel, "name", 0, row); AddSmallHeader(panel, "type", 1, row); AddSmallHeader(panel, "value", 2, row); AddSmallHeader(panel, "actions", 3, row); AddSmallHeader(panel, "", 4, row); } private static void AddSmallHeader(TableLayoutPanel panel, string text, int col, int row) { var label = new Label { Text = text, AutoSize = true, ForeColor = Color.DimGray, Margin = new Padding(0, 0, 8, 4) }; panel.Controls.Add(label, col, row); } private void AddCustomRow(TableLayoutPanel panel, CustomField field, int index) { var row = panel.RowCount++; panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var nameBox = new TextBox { Text = field.Name, Dock = DockStyle.Top, Margin = new Padding(0, 0, 8, 8), PlaceholderText = "field_name" }; nameBox.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(nameBox); panel.Controls.Add(nameBox, 0, row); var typeBox = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Top, Margin = new Padding(0, 0, 8, 8) }; typeBox.Items.AddRange(CustomFieldTypes.Cast<object>().ToArray()); typeBox.SelectedItem = CustomFieldTypes.Contains(field.Type, StringComparer.OrdinalIgnoreCase) ? field.Type.ToLowerInvariant() : "text"; typeBox.SelectedIndexChanged += (_, _) => MarkDirty(); panel.Controls.Add(typeBox, 1, row); var valueBox = new TextBox { Text = field.Value, Dock = DockStyle.Top, Margin = new Padding(0, 0, 8, 8) }; valueBox.TextChanged += (_, _) => MarkDirty(); RegisterTextBox(valueBox); MakeCustomUrlTextBoxActive(valueBox, typeBox, nameBox); panel.Controls.Add(valueBox, 2, row); var buttons = new FlowLayoutPanel { Dock = DockStyle.Top, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, AutoSize = false, Width = 360, Height = 34, Margin = new Padding(0) }; var copyButton = new Button { Text = "Копир.", Width = 66, Height = 30, Margin = new Padding(0, 0, 6, 8) }; copyButton.Click += (_, _) => CopyToClipboard(valueBox.Text, string.IsNullOrWhiteSpace(nameBox.Text) ? "custom" : nameBox.Text); buttons.Controls.Add(copyButton); var pasteButton = new Button { Text = "Встав.", Width = 66, Height = 30, Margin = new Padding(0, 0, 6, 8) }; pasteButton.Click += (_, _) => PasteFromClipboard(valueBox, string.IsNullOrWhiteSpace(nameBox.Text) ? "custom" : nameBox.Text); buttons.Controls.Add(pasteButton); var showButton = new Button { Text = "Показать", Width = 82, Height = 30, Margin = new Padding(0, 0, 0, 8) }; buttons.Controls.Add(showButton); panel.Controls.Add(buttons, 3, row); var removeButton = new Button { Text = "Удалить", Width = 76, Height = 30, Margin = new Padding(0, 0, 0, 8) }; removeButton.Click += (_, _) => RemoveCustomField(index); panel.Controls.Add(removeButton, 4, row); var controls = new CustomFieldControls(nameBox, typeBox, valueBox, showButton); _customRows.Add(controls); ApplyCustomFieldType(controls); typeBox.SelectedIndexChanged += (_, _) => ApplyCustomFieldType(controls); nameBox.TextChanged += (_, _) => ApplyCustomFieldType(controls); showButton.Click += (_, _) => { if (IsUrlField(controls.Type, controls.NameBox.Text)) { OpenUrlFromText(controls.ValueBox.Text); return; } if (!IsSecretType(controls.Type)) return; controls.SecretVisible = !controls.SecretVisible; ApplyCustomFieldType(controls); }; } private static void ApplyCustomFieldType(CustomFieldControls controls) { var type = controls.Type; var isSecret = IsSecretType(type); var isUrl = IsUrlField(type, controls.NameBox.Text); var isNotes = string.Equals(type, "notes", StringComparison.OrdinalIgnoreCase); controls.ValueBox.Multiline = isNotes; controls.ValueBox.ScrollBars = isNotes ? ScrollBars.Vertical : ScrollBars.None; controls.ValueBox.Height = isNotes ? 70 : 30; controls.ValueBox.Cursor = isUrl ? Cursors.Hand : Cursors.IBeam; controls.ShowButton.Enabled = isSecret || isUrl; controls.ShowButton.Text = isUrl ? "Открыть" : !isSecret ? "—" : controls.SecretVisible ? "Скрыть" : "Показать"; controls.ValueBox.UseSystemPasswordChar = isSecret && !controls.SecretVisible; } private void AddCustomField() { SaveCurrentEditorToRecord(); var record = CurrentRecord(); if (record == null) return; record.CustomFields.Add(new CustomField { Name = "new_field", Type = "text", Value = "" }); record.UpdatedUtc = DateTime.UtcNow; _dirty = true; LoadRecord(record.Id); _dirty = true; SetStatus("Добавлено своё поле"); } private void RemoveCustomField(int index) { SaveCurrentEditorToRecord(); var record = CurrentRecord(); if (record == null || index < 0 || index >= record.CustomFields.Count) return; record.CustomFields.RemoveAt(index); record.UpdatedUtc = DateTime.UtcNow; _dirty = true; LoadRecord(record.Id); _dirty = true; SetStatus("Своё поле удалено"); } private void AddRecord() { if (!ConfirmSaveDirtyEditorBeforeLeaving()) return; var record = new VaultRecord { Id = Guid.NewGuid().ToString("N"), RecordType = "fields", Category = CurrentCategoryForNewRecord(), Title = "Новая запись", CreatedUtc = DateTime.UtcNow, UpdatedUtc = DateTime.UtcNow }; _session.Data.Records.Insert(0, record); _dirty = true; RefreshList(record.Id, loadSelected: true); SaveVault("Запись добавлена"); } private void AddTextRecord() { if (!ConfirmSaveDirtyEditorBeforeLeaving()) return; var record = new VaultRecord { Id = Guid.NewGuid().ToString("N"), RecordType = "document", Category = CurrentCategoryForNewRecord(), Title = "Новая T запись", SourceFileName = "", DocumentFormat = "md", DocumentText = "", CreatedUtc = DateTime.UtcNow, UpdatedUtc = DateTime.UtcNow }; _session.Data.Records.Insert(0, record); _dirty = true; RefreshList(record.Id, loadSelected: true); SaveVault("Текстовая запись добавлена", refreshList: false); } private void ImportTextFilesDialog() { using var dialog = new OpenFileDialog { Title = "Импорт TXT/MD в сейф", Filter = "Text / Markdown (*.txt;*.md;*.markdown)|*.txt;*.md;*.markdown|All files (*.*)|*.*", Multiselect = true, CheckFileExists = true }; if (dialog.ShowDialog(this) != DialogResult.OK) return; ImportTextFiles(dialog.FileNames); } private void ImportEnpassCsvDialog() { using var dialog = new OpenFileDialog { Title = "Импорт Enpass CSV в сейф", Filter = "CSV Enpass (*.csv)|*.csv|All files (*.*)|*.*", Multiselect = true, CheckFileExists = true }; if (dialog.ShowDialog(this) != DialogResult.OK) return; var answer = MessageBox.Show( this, "CSV из Enpass — это обычный открытый текст с паролями." + Environment.NewLine + "Программа импортирует его в зашифрованный vault.lzdb и сделает backup перед импортом." + Environment.NewLine + Environment.NewLine + "После проверки импорта исходный CSV лучше удалить вручную.", "Импорт Enpass CSV", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning); if (answer != DialogResult.OK) return; ImportEnpassCsvFiles(dialog.FileNames); } private void ImportEnpassCsvFiles(IEnumerable<string> fileNames) { if (!ConfirmSaveDirtyEditorBeforeLeaving()) return; var recordsToImport = new List<VaultRecord>(); var skipped = new List<string>(); foreach (var fileName in fileNames) { try { var csvText = ReadTextFile(fileName); var rows = ParseCsvRows(csvText); var rowNumber = 0; foreach (var row in rows) { rowNumber++; if (TryCreateRecordFromEnpassCsvRow(row, Path.GetFileName(fileName), rowNumber, out var record, out var skipReason)) { record.Category = CurrentCategoryForNewRecord(); recordsToImport.Add(record); } else if (!string.IsNullOrWhiteSpace(skipReason)) { skipped.Add($"{Path.GetFileName(fileName)} строка {rowNumber}: {skipReason}"); } } } catch (Exception ex) { skipped.Add($"{Path.GetFileName(fileName)} ({ex.Message})"); } } if (recordsToImport.Count == 0) { MessageBox.Show(this, "Из Enpass CSV ничего не импортировано." + FormatSkipped(skipped), "Импорт Enpass CSV", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } string backupPath; try { backupPath = File.Exists(_session.VaultPath) ? VaultStore.CreateBackup(_session.VaultPath, AppPaths.BackupsDir) : "backup не нужен: vault ещё не создан"; } catch (Exception ex) { MessageBox.Show(this, "Backup перед импортом не создан:" + Environment.NewLine + ex.Message, "Импорт Enpass CSV", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } foreach (var record in recordsToImport) _session.Data.Records.Insert(0, record); _dirty = true; var lastId = recordsToImport.LastOrDefault()?.Id; try { VaultStore.Save(_session.VaultPath, _session.MasterPassword, _session.Data); _dirty = false; RefreshList(lastId, loadSelected: true); SetStatus($"Импорт Enpass CSV: {recordsToImport.Count}. Backup: {backupPath}"); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка сохранения импорта Enpass CSV", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (skipped.Count > 0) MessageBox.Show(this, "Импортировано: " + recordsToImport.Count + FormatSkipped(skipped), "Импорт Enpass CSV", MessageBoxButtons.OK, MessageBoxIcon.Information); } private static bool TryCreateRecordFromEnpassCsvRow( List<string> fields, string sourceFile, int rowNumber, out VaultRecord record, out string skipReason) { record = new VaultRecord(); skipReason = ""; if (fields.Count == 0 || fields.All(string.IsNullOrWhiteSpace)) return false; var title = CleanCsvCell(fields[0]); if (string.IsNullOrWhiteSpace(title)) { skipReason = "пустое название"; return false; } if (LooksLikeCsvHeader(title)) return false; var now = DateTime.UtcNow; var result = new VaultRecord { Id = Guid.NewGuid().ToString("N"), RecordType = "fields", Title = title, CreatedUtc = now, UpdatedUtc = now }; var mappedValues = 0; for (var i = 1; i < fields.Count; i += 2) { var rawLabel = CleanCsvCell(fields[i]); var value = i + 1 < fields.Count ? CleanCsvCell(fields[i + 1]) : ""; if (string.IsNullOrWhiteSpace(rawLabel) && string.IsNullOrWhiteSpace(value)) continue; if (string.IsNullOrWhiteSpace(value)) continue; var label = NormalizeImportLabel(rawLabel); if (string.IsNullOrWhiteSpace(label)) { AddImportedCustomField(result, "enpass_field", "text", value); mappedValues++; continue; } if (IsImportLoginLabel(label)) { if (string.IsNullOrWhiteSpace(result.Login)) result.Login = value; else AddImportedCustomField(result, rawLabel, "text", value); mappedValues++; continue; } if (IsImportPasswordLabel(label)) { if (string.IsNullOrWhiteSpace(result.Pass)) result.Pass = value; else AddImportedCustomField(result, rawLabel, "password", value); mappedValues++; continue; } if (IsImportUrlLabel(label)) { if (string.IsNullOrWhiteSpace(result.Url)) result.Url = value; else AddImportedCustomField(result, rawLabel, "url", value); mappedValues++; continue; } if (IsImportEmailLabel(label)) { if (string.IsNullOrWhiteSpace(result.Email)) result.Email = value; else AddImportedCustomField(result, rawLabel, "email", value); mappedValues++; continue; } if (IsImportPhoneLabel(label)) { if (string.IsNullOrWhiteSpace(result.Telefon)) result.Telefon = value; else AddImportedCustomField(result, rawLabel, "phone", value); mappedValues++; continue; } if (IsImportNotesLabel(label)) { result.Notes1 = AppendImportedNote(result.Notes1, value); mappedValues++; continue; } if (IsImportOtpLabel(label)) { if (string.IsNullOrWhiteSpace(result.Secret1)) result.Secret1 = value; else AddImportedCustomField(result, rawLabel, "secret", value); mappedValues++; continue; } if (IsImportSecurityQuestionLabel(label)) { result.Notes2 = AppendImportedNote(result.Notes2, "Секретный вопрос: " + value); mappedValues++; continue; } if (IsImportSecurityAnswerLabel(label)) { if (string.IsNullOrWhiteSpace(result.Secret2)) result.Secret2 = value; else AddImportedCustomField(result, rawLabel, "secret", value); mappedValues++; continue; } AddImportedCustomField(result, rawLabel, GuessImportedFieldType(label, value), value); mappedValues++; } // Enpass иногда экспортирует только title + URL/поля. Если есть хотя бы title — запись оставляем. // Но полностью пустую строку без пар label/value выше уже отсеяли. if (mappedValues == 0 && fields.Count <= 1) { skipReason = "нет полей для импорта"; return false; } if (string.IsNullOrWhiteSpace(result.Notes2)) result.Notes2 = "Импортировано из Enpass CSV: " + sourceFile; else result.Notes2 = AppendImportedNote(result.Notes2, "Импортировано из Enpass CSV: " + sourceFile); record = result; return true; } private static List<List<string>> ParseCsvRows(string csvText) { var rows = new List<List<string>>(); var row = new List<string>(); var field = new StringBuilder(); var inQuotes = false; for (var i = 0; i < csvText.Length; i++) { var ch = csvText[i]; if (inQuotes) { if (ch == '"') { if (i + 1 < csvText.Length && csvText[i + 1] == '"') { field.Append('"'); i++; } else { inQuotes = false; } } else { field.Append(ch); } continue; } if (ch == '"') { inQuotes = true; continue; } if (ch == ',') { row.Add(field.ToString()); field.Clear(); continue; } if (ch == '\r' || ch == '\n') { row.Add(field.ToString()); field.Clear(); if (row.Any(x => !string.IsNullOrWhiteSpace(x))) rows.Add(row); row = new List<string>(); if (ch == '\r' && i + 1 < csvText.Length && csvText[i + 1] == '\n') i++; continue; } field.Append(ch); } row.Add(field.ToString()); if (row.Any(x => !string.IsNullOrWhiteSpace(x))) rows.Add(row); return rows; } private static string CleanCsvCell(string? value) { return (value ?? "").Trim().Trim('\uFEFF'); } private static bool LooksLikeCsvHeader(string title) { var normalized = NormalizeImportLabel(title); return normalized is "title" or "название"; } private static string NormalizeImportLabel(string label) { var cleaned = CleanCsvCell(label) .Trim('*') .Trim(':') .Trim() .Replace('ё', 'е') .Replace('Ё', 'Е') .ToLowerInvariant(); while (cleaned.Contains(" ", StringComparison.Ordinal)) cleaned = cleaned.Replace(" ", " ", StringComparison.Ordinal); return cleaned; } private static bool IsImportLoginLabel(string label) { return label is "username" or "user name" or "user" or "login" or "логин" or "имя пользователя" or "пользователь"; } private static bool IsImportPasswordLabel(string label) { return label is "password" or "pass" or "пароль"; } private static bool IsImportUrlLabel(string label) { return label is "url" or "uri" or "website" or "web site" or "сайт" or "адрес" or "адрес сайта"; } private static bool IsImportEmailLabel(string label) { return label is "email" or "e-mail" or "mail" or "почта" or "электронная почта"; } private static bool IsImportPhoneLabel(string label) { return label is "phone" or "telephone" or "tel" or "телефон" or "номер телефона"; } private static bool IsImportNotesLabel(string label) { return label is "notes" or "note" or "заметки" or "примечание" or "дополнительная информация" or "additional information" or "additional info"; } private static bool IsImportOtpLabel(string label) { return label is "otp" or "totp" or "one-time password" or "one time password" or "одноразовый код" or "код"; } private static bool IsImportSecurityQuestionLabel(string label) { return label is "security question" or "секретный вопрос"; } private static bool IsImportSecurityAnswerLabel(string label) { return label is "security answer" or "answer" or "ответ на секретный вопрос"; } private static string GuessImportedFieldType(string normalizedLabel, string value) { if (IsImportPasswordLabel(normalizedLabel) || normalizedLabel.Contains("secret", StringComparison.Ordinal) || normalizedLabel.Contains("секрет", StringComparison.Ordinal)) return "secret"; if (IsImportEmailLabel(normalizedLabel) || value.Contains('@', StringComparison.Ordinal)) return "email"; if (IsImportUrlLabel(normalizedLabel) || value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return "url"; if (IsImportPhoneLabel(normalizedLabel)) return "phone"; if (value.Contains(Environment.NewLine, StringComparison.Ordinal)) return "notes"; return "text"; } private static void AddImportedCustomField(VaultRecord record, string name, string type, string value) { record.CustomFields.Add(new CustomField { Name = string.IsNullOrWhiteSpace(name) ? "enpass_field" : MakeSafeFieldName(name), Type = NormalizeFieldType(type), Value = value }); } private static string MakeSafeFieldName(string value) { var cleaned = CleanCsvCell(value).Trim('*').Trim(':').Trim(); return string.IsNullOrWhiteSpace(cleaned) ? "enpass_field" : cleaned; } private static string AppendImportedNote(string current, string value) { if (string.IsNullOrWhiteSpace(value)) return current; if (string.IsNullOrWhiteSpace(current)) return value; return current.TrimEnd() + Environment.NewLine + value; } private void ImportTextFiles(IEnumerable<string> fileNames) { if (!ConfirmSaveDirtyEditorBeforeLeaving()) return; var imported = 0; string? lastId = null; var skipped = new List<string>(); foreach (var fileName in fileNames) { if (!IsTextDocumentFile(fileName)) { skipped.Add(Path.GetFileName(fileName)); continue; } try { var text = ReadTextFile(fileName); var record = new VaultRecord { Id = Guid.NewGuid().ToString("N"), RecordType = "document", Category = CurrentCategoryForNewRecord(), Title = Path.GetFileNameWithoutExtension(fileName), SourceFileName = Path.GetFileName(fileName), DocumentFormat = NormalizeDocumentFormat(Path.GetExtension(fileName).TrimStart('.')), DocumentText = text, CreatedUtc = DateTime.UtcNow, UpdatedUtc = DateTime.UtcNow }; _session.Data.Records.Insert(0, record); lastId = record.Id; imported++; } catch (Exception ex) { skipped.Add($"{Path.GetFileName(fileName)} ({ex.Message})"); } } if (imported == 0) { MessageBox.Show(this, "TXT/MD файлы не импортированы." + FormatSkipped(skipped), "Импорт", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } _dirty = true; try { VaultStore.Save(_session.VaultPath, _session.MasterPassword, _session.Data); _dirty = false; RefreshList(lastId, loadSelected: true); SetStatus($"Импортировано TXT/MD: {imported}"); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка сохранения импорта", MessageBoxButtons.OK, MessageBoxIcon.Error); } if (skipped.Count > 0) MessageBox.Show(this, "Импортировано: " + imported + FormatSkipped(skipped), "Импорт", MessageBoxButtons.OK, MessageBoxIcon.Information); } private static string FormatSkipped(List<string> skipped) { if (skipped.Count == 0) return ""; return Environment.NewLine + Environment.NewLine + "Пропущено:" + Environment.NewLine + string.Join(Environment.NewLine, skipped); } private static string ReadTextFile(string fileName) { var bytes = File.ReadAllBytes(fileName); if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return NormalizeLineEndings(new UTF8Encoding(false, true).GetString(bytes, 3, bytes.Length - 3)); if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return NormalizeLineEndings(Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2)); if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return NormalizeLineEndings(Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2)); try { return NormalizeLineEndings(new UTF8Encoding(false, true).GetString(bytes)); } catch (DecoderFallbackException) { return NormalizeLineEndings(DecodeWindows1251(bytes)); } } private static string NormalizeLineEndings(string? text) { if (string.IsNullOrEmpty(text)) return ""; var normalized = text.Replace("\r\n", "\n").Replace("\r", "\n"); // Иногда текст приходит не с настоящими переводами строк, а с буквальными \n / \r\n. // Чтобы импорт из кривых txt/md не превращался в колбасу в одну строку, // разворачиваем такие последовательности только если настоящих переводов строк нет. if (!normalized.Contains('\n') && (normalized.Contains("\\n", StringComparison.Ordinal) || normalized.Contains("\\r\\n", StringComparison.Ordinal))) { normalized = normalized .Replace("\\r\\n", "\n", StringComparison.Ordinal) .Replace("\\n", "\n", StringComparison.Ordinal); } return normalized.Replace("\n", Environment.NewLine); } private static string DecodeWindows1251(byte[] bytes) { var map80Bf = new[] { '\u0402', '\u0403', '\u201A', '\u0453', '\u201E', '\u2026', '\u2020', '\u2021', '\u20AC', '\u2030', '\u0409', '\u2039', '\u040A', '\u040C', '\u040B', '\u040F', '\u0452', '\u2018', '\u2019', '\u201C', '\u201D', '\u2022', '\u2013', '\u2014', '\u0098', '\u2122', '\u0459', '\u203A', '\u045A', '\u045C', '\u045B', '\u045F', '\u00A0', '\u040E', '\u045E', '\u0408', '\u00A4', '\u0490', '\u00A6', '\u00A7', '\u0401', '\u00A9', '\u0404', '\u00AB', '\u00AC', '\u00AD', '\u00AE', '\u0407', '\u00B0', '\u00B1', '\u0406', '\u0456', '\u0491', '\u00B5', '\u00B6', '\u00B7', '\u0451', '\u2116', '\u0454', '\u00BB', '\u0458', '\u0405', '\u0455', '\u0457' }; var sb = new StringBuilder(bytes.Length); foreach (var b in bytes) { if (b < 0x80) { sb.Append((char)b); } else if (b >= 0xC0) { sb.Append((char)('\u0410' + b - 0xC0)); } else { sb.Append(map80Bf[b - 0x80]); } } return sb.ToString(); } private void MainDragEnter(object? sender, DragEventArgs e) { if (e.Data != null && e.Data.GetDataPresent(DataFormats.FileDrop)) { var files = e.Data.GetData(DataFormats.FileDrop) as string[]; if (files != null && files.Any(IsTextDocumentFile)) { e.Effect = DragDropEffects.Copy; return; } } e.Effect = DragDropEffects.None; } private void MainDragDrop(object? sender, DragEventArgs e) { if (e.Data == null || !e.Data.GetDataPresent(DataFormats.FileDrop)) return; var files = e.Data.GetData(DataFormats.FileDrop) as string[]; if (files == null || files.Length == 0) return; ImportTextFiles(files); } private void DeleteCurrentRecord() { var record = CurrentRecord(); if (record == null) return; var title = CurrentEditorDisplayTitle(); var deleteWithoutQuestion = (ModifierKeys & Keys.Shift) == Keys.Shift; if (!deleteWithoutQuestion) { var answer = MessageBox.Show( this, $"Удалить запись «{title}»?" + Environment.NewLine + Environment.NewLine + "Подсказка: Shift + кнопка «Удалить» удаляет без этого предупреждения.", "Удаление", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (answer != DialogResult.Yes) return; } var deletedIndex = _recordList.SelectedIndex < 0 ? 0 : _recordList.SelectedIndex; var previousTopIndex = SafeListTopIndex(); _session.Data.Records.Remove(record); _currentRecordId = null; _dirty = true; SaveVault(deleteWithoutQuestion ? "Запись удалена без предупреждения" : "Запись удалена", refreshList: false); RefreshList(selectId: null, loadSelected: false); RestoreListTopIndex(previousTopIndex); if (_recordList.Items.Count > 0) { SelectRecordAtListIndex(deletedIndex, loadSelected: true); RestoreListTopIndex(previousTopIndex); } else { ClearEditor(); } SetStatus(deleteWithoutQuestion ? "Запись удалена без предупреждения" : "Запись удалена"); } private void SaveVault(string message, bool refreshList = true) { try { SaveCurrentEditorToRecord(); VaultStore.Save(_session.VaultPath, _session.MasterPassword, _session.Data); _dirty = false; if (refreshList) RefreshList(_currentRecordId, loadSelected: false); UpdateVaultTitle(); SetStatus(message); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void CreateBackup() { SaveVault("Сохранено перед backup", refreshList: false); try { var backupPath = VaultStore.CreateBackup(_session.VaultPath, AppPaths.BackupsDir); SetStatus("Backup создан: " + backupPath); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка backup", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void ExportVault() { SaveCurrentEditorToRecord(); var answer = MessageBox.Show( this, "Экспорт будет обычным незашифрованным текстом. Делать?", "Экспорт", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (answer != DialogResult.Yes) return; Directory.CreateDirectory(AppPaths.ExportsDir); using var dialog = new SaveFileDialog { Title = "Экспорт Zapominalka", InitialDirectory = AppPaths.ExportsDir, FileName = $"zapominalka_export_{DateTime.Now:yyyyMMdd_HHmmss}.md", Filter = "Markdown (*.md)|*.md|Text (*.txt)|*.txt", AddExtension = true, OverwritePrompt = true }; if (dialog.ShowDialog(this) != DialogResult.OK) return; try { var ext = Path.GetExtension(dialog.FileName).ToLowerInvariant(); var text = ext == ".txt" ? Exporter.ToText(_session.Data) : Exporter.ToMarkdown(_session.Data); File.WriteAllText(dialog.FileName, text, new UTF8Encoding(false)); SetStatus("Экспорт создан: " + dialog.FileName); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка экспорта", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OpenAppFolder() { try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = AppPaths.DataDir, UseShellExecute = true }); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка открытия папки", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OpenThemeEditor() { using var dialog = new ThemeEditorForm(); if (dialog.ShowDialog(this) != DialogResult.OK) return; ThemeSettingsStore.Set(dialog.Settings); ApplyThemeAndFonts(); SetStatus("Цвета и шрифты темы сохранены и применены: " + AppPaths.ThemeFile); } private void OpenThemeJson() { try { ThemeSettingsStore.Save(); var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "notepad.exe", Arguments = QuoteArgument(AppPaths.ThemeFile), UseShellExecute = false }); SetStatus("JSON темы открыт в Блокноте. Закрой Блокнот — тема перечитается автоматически."); if (process != null) { _ = Task.Run(() => { try { process.WaitForExit(); process.Dispose(); if (!IsDisposed && IsHandleCreated) BeginInvoke(new Action(ReloadThemeFromJson)); } catch { // Открытие темы не должно валить основную программу. } }); } } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка открытия JSON темы", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void ReloadThemeFromJson() { ThemeSettingsStore.Load(); ApplyThemeAndFonts(); SetStatus("JSON темы перечитан и применён: " + AppPaths.ThemeFile); } private void ApplyThemeAndFonts() { ApplyTheme(); ApplyTextFieldFonts(); _recordList.Font = MakeFont(ThemeSettingsStore.TextFontFamily, Math.Max(13F, AppSettingsStore.Current.CardTitleFontSize), FontStyle.Regular); _recordList.ItemHeight = Math.Max(20, (int)Math.Ceiling(_recordList.Font.GetHeight()) + 4); } private static string QuoteArgument(string value) { return "\"" + value.Replace("\"", "\\\"") + "\""; } private void ChooseDataFolder() { SaveVault("Сохранено перед сменой папки базы", refreshList: false); using var dialog = new FolderBrowserDialog { Description = "Выбери папку, где будут лежать vault.lzdb, settings.json, backups и exports. Можно выбрать сетевую папку.", UseDescriptionForTitle = true, ShowNewFolderButton = true, SelectedPath = Directory.Exists(AppPaths.DataDir) ? AppPaths.DataDir : Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; if (dialog.ShowDialog(this) != DialogResult.OK) return; try { var newDataDir = AppPaths.ResolveUserPath(dialog.SelectedPath); if (string.Equals(Path.GetFullPath(AppPaths.DataDir), newDataDir, StringComparison.OrdinalIgnoreCase)) { SetStatus("Эта папка базы уже используется."); return; } Directory.CreateDirectory(newDataDir); Directory.CreateDirectory(Path.Combine(newDataDir, "backups")); Directory.CreateDirectory(Path.Combine(newDataDir, "exports")); var newVaultPath = Path.Combine(newDataDir, "vault.lzdb"); if (File.Exists(newVaultPath)) { var answer = MessageBox.Show( this, "В выбранной папке уже есть vault.lzdb." + Environment.NewLine + "Переключиться на эту папку после перезапуска? Текущая база копироваться не будет.", "Путь базы", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (answer != DialogResult.Yes) return; } else { File.Copy(_session.VaultPath, newVaultPath, overwrite: false); } var newSettingsPath = Path.Combine(newDataDir, "settings.json"); if (!File.Exists(newSettingsPath) && File.Exists(AppPaths.SettingsFile)) File.Copy(AppPaths.SettingsFile, newSettingsPath, overwrite: false); var newThemePath = Path.Combine(newDataDir, "Zapominalka.theme.json"); if (!File.Exists(newThemePath) && File.Exists(AppPaths.ThemeFile)) File.Copy(AppPaths.ThemeFile, newThemePath, overwrite: false); AppPathConfigStore.Save(new AppPathConfig { Version = 1, Mode = "custom", CustomDataDir = newDataDir }); MessageBox.Show( this, "Путь базы сохранён в Zapominalka.paths.json." + Environment.NewLine + "Файл путей:" + Environment.NewLine + AppPathConfigStore.ActiveConfigFile + Environment.NewLine + Environment.NewLine + "Новая папка данных:" + Environment.NewLine + newDataDir + Environment.NewLine + Environment.NewLine + "Перезапусти программу, чтобы открыть базу из новой папки.", "Путь базы", MessageBoxButtons.OK, MessageBoxIcon.Information); SetStatus("Путь базы изменён. Нужен перезапуск программы."); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка смены папки базы", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void LockVault() { SaveVault("Vault заблокирован", refreshList: false); Hide(); using var login = new LoginForm(_session.VaultPath); if (login.ShowDialog(this) == DialogResult.OK && login.Session != null) { _session = login.Session; UpdateVaultTitle(); _currentRecordId = null; _dirty = false; RefreshList(null, loadSelected: true); Show(); Activate(); SetStatus("Vault разблокирован"); } else { Close(); } } private void ChangeMasterPassword() { SaveVault("Сохранено перед сменой мастер-пароля", refreshList: false); using var dialog = new ChangeMasterPasswordForm(); if (dialog.ShowDialog(this) != DialogResult.OK) return; if (string.IsNullOrWhiteSpace(dialog.OldPassword)) { MessageBox.Show(this, "Старый мастер-пароль пустой.", "Смена мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (string.IsNullOrWhiteSpace(dialog.NewPassword)) { MessageBox.Show(this, "Новый мастер-пароль пустой. Сейф без двери не делаем.", "Смена мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (dialog.NewPassword != dialog.ConfirmPassword) { MessageBox.Show(this, "Новый пароль и повтор не совпадают.", "Смена мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } try { _ = VaultStore.Load(_session.VaultPath, dialog.OldPassword); } catch (CryptographicException) { MessageBox.Show(this, "Старый мастер-пароль неверный.", "Смена мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка проверки старого пароля", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } try { var backupPath = VaultStore.CreateBackup(_session.VaultPath, AppPaths.BackupsDir); VaultStore.Save(_session.VaultPath, dialog.NewPassword, _session.Data); _session.ChangeMasterPassword(dialog.NewPassword); _dirty = false; SetStatus("Мастер-пароль изменён. Backup: " + backupPath); MessageBox.Show(this, "Мастер-пароль изменён. Старый пароль больше не открывает сейф.", "Смена мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка смены мастер-пароля", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OpenPasswordGenerator() { if (_passwordGeneratorForm != null && !_passwordGeneratorForm.IsDisposed) { _passwordGeneratorForm.Show(); _passwordGeneratorForm.Activate(); SetStatus("Генератор уже открыт. Главное окно доступно для вставки."); return; } _passwordGeneratorForm = new PasswordGeneratorForm((text, label) => CopyToClipboard(text, label)); _passwordGeneratorForm.FormClosed += (_, _) => { _passwordGeneratorForm = null; SetStatus("Генератор закрыт. Поля записи не изменены."); }; _passwordGeneratorForm.Show(this); SetStatus("Генератор открыт. Скопируй пароль и вставляй в нужное поле — окно больше не блокирует запись."); } private void SaveCurrentEditorToRecord() { if (_loadingEditor || _currentRecordId == null) return; var record = FindRecord(_currentRecordId); if (record == null) return; if (IsDocumentRecord(record)) { if (_documentTitleBox == null || _documentTextBox == null) return; record.Category = CurrentEditorCategory(); record.Title = _documentTitleBox.Text; record.SourceFileName = _documentSourceBox?.Text ?? ""; record.DocumentFormat = NormalizeDocumentFormat(_documentFormatBox?.SelectedItem?.ToString()); record.DocumentText = NormalizeLineEndings(_documentTextBox.Text); record.UpdatedUtc = DateTime.UtcNow; return; } if (_fixedBoxes.Count == 0) return; SaveFixedFieldLabelsFromEditor(); record.Category = CurrentEditorCategory(); record.Title = GetBox("title"); record.Url = GetBox("url"); record.Login = GetBox("login"); record.Pass = GetBox("pass"); record.Secret1 = GetBox("secret1"); record.Telefon = GetBox("telefon"); record.Email = GetBox("email"); record.Secret2 = GetBox("secret2"); record.Secret3 = GetBox("secret3"); record.Notes1 = GetBox("notes1"); record.Notes2 = GetBox("notes2"); record.CustomFields = _customRows .Select(row => new CustomField { Name = row.NameBox.Text.Trim(), Type = NormalizeFieldType(row.Type), Value = row.ValueBox.Text }) .Where(f => !string.IsNullOrWhiteSpace(f.Name) || !string.IsNullOrWhiteSpace(f.Value)) .ToList(); record.UpdatedUtc = DateTime.UtcNow; } private string GetBox(string key) { return _fixedBoxes.TryGetValue(key, out var box) ? box.Text : ""; } private VaultRecord? CurrentRecord() { if (_currentRecordId == null) return null; return FindRecord(_currentRecordId); } private VaultRecord? FindRecord(string id) { return _session.Data.Records.FirstOrDefault(r => r.Id == id); } private void MarkDirty() { if (_loadingEditor) return; _dirty = true; } private void CopyToClipboard(string value, string label) { if (string.IsNullOrEmpty(value)) { SetStatus($"{label}: пусто"); return; } try { Clipboard.SetText(value); _lastCopiedText = value; _clipboardTimer.Stop(); _clipboardTimer.Start(); SetStatus($"{label} скопировано. Буфер будет очищен через 30 секунд."); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка буфера обмена", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void PasteFromClipboard(TextBox target, string label) { try { if (!Clipboard.ContainsText()) { SetStatus("Буфер пустой — вставлять нечего."); return; } var text = Clipboard.GetText(); if (!target.Multiline) text = NormalizeForSingleLine(text); target.Focus(); target.Text = text; target.SelectionStart = target.TextLength; target.SelectionLength = 0; MarkDirty(); SetStatus($"{label}: поле перезаписано из буфера"); } catch (Exception ex) { MessageBox.Show(this, ex.Message, "Ошибка вставки из буфера", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private static string NormalizeForSingleLine(string text) { if (string.IsNullOrEmpty(text)) return ""; return text .Replace("\r\n", " ", StringComparison.Ordinal) .Replace("\n", " ", StringComparison.Ordinal) .Replace("\r", " ", StringComparison.Ordinal) .Replace(Environment.NewLine, " ", StringComparison.Ordinal); } private void ClearClipboardIfStillOurs() { _clipboardTimer.Stop(); try { if (!string.IsNullOrEmpty(_lastCopiedText) && Clipboard.ContainsText() && Clipboard.GetText() == _lastCopiedText) Clipboard.Clear(); } catch { // Буфер обмена иногда занят другим процессом. Для локальной запоминалки это не катастрофа. } finally { _lastCopiedText = null; SetStatus("Буфер очищен"); } } private void SetStatus(string text) { UpdateVaultTitle(); _statusLabel.Text = BuildStatusText(text); _statusToolTip.SetToolTip(_statusLabel, BuildStatusTooltip(text)); } private string BuildStatusText(string text) { return $"{text}{Environment.NewLine}Сейф: {VaultNames.Display(_session.Data)} | База: {AppPathConfigStore.Current.Mode} | записей: {_session.Data.Records.Count} | vault: {FormatFileSize(AppPaths.VaultFile)} | файл: {ShortenMiddle(AppPaths.VaultFile, 110)}"; } private string BuildStatusTooltip(string text) { return text + Environment.NewLine + "Название сейфа: " + VaultNames.Display(_session.Data) + Environment.NewLine + "Режим базы: " + AppPathConfigStore.Current.Mode + Environment.NewLine + "Файл базы: " + AppPaths.VaultFile + Environment.NewLine + "Папка данных: " + AppPaths.DataDir + Environment.NewLine + "Файл путей: " + AppPathConfigStore.ActiveConfigFile + Environment.NewLine + "Записей: " + _session.Data.Records.Count + Environment.NewLine + "Размер vault: " + FormatFileSize(AppPaths.VaultFile) + Environment.NewLine + "Двойной клик по строке состояния открывает папку данных."; } private static string ShortenMiddle(string value, int maxLength) { if (string.IsNullOrWhiteSpace(value) || value.Length <= maxLength) return value; if (maxLength < 16) return value[..Math.Min(value.Length, maxLength)]; var startLength = Math.Max(8, maxLength / 3); var endLength = Math.Max(8, maxLength - startLength - 3); if (startLength + endLength + 3 >= value.Length) return value; return value[..startLength] + "..." + value[^endLength..]; } private static string FormatFileSize(string path) { try { if (!File.Exists(path)) return "нет файла"; var bytes = new FileInfo(path).Length; if (bytes < 1024) return bytes + " Б"; var kb = bytes / 1024D; if (kb < 1024) return kb.ToString("0.#") + " КБ"; var mb = kb / 1024D; return mb.ToString("0.##") + " МБ"; } catch { return "неизвестно"; } } private static bool IsSecretType(string? type) { return string.Equals(type, "secret", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "password", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "pass", StringComparison.OrdinalIgnoreCase); } private static bool IsUrlType(string? type) { return string.Equals(type, "url", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "uri", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "website", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "web", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "сайт", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "адрес сайта", StringComparison.OrdinalIgnoreCase); } private static bool IsUrlField(string? type, string? name) { return IsUrlType(type) || IsUrlType(name); } private static bool IsDocumentRecord(VaultRecord record) { return string.Equals(record.RecordType, "document", StringComparison.OrdinalIgnoreCase); } private static string NormalizeDocumentFormat(string? format) { if (string.Equals(format, "txt", StringComparison.OrdinalIgnoreCase)) return "txt"; return "md"; } private static bool IsTextDocumentFile(string fileName) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); return ext == ".txt" || ext == ".md" || ext == ".markdown"; } private static string MakeSafeFileName(string value) { var invalid = Path.GetInvalidFileNameChars(); var cleaned = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); return string.IsNullOrWhiteSpace(cleaned) ? "document" : cleaned; } private static string NormalizeFieldType(string? type) { if (string.IsNullOrWhiteSpace(type)) return "text"; return CustomFieldTypes.Contains(type, StringComparer.OrdinalIgnoreCase) ? type.ToLowerInvariant() : "text"; } private sealed class RecordListItem { public RecordListItem(VaultRecord record) { Record = record; } public VaultRecord Record { get; } public override string ToString() { var prefix = MainForm.IsDocumentRecord(Record) ? "T: " : ""; var category = RecordCategories.Normalize(Record.Category); var categoryPrefix = string.IsNullOrWhiteSpace(category) ? "" : "[" + category + "] "; if (!string.IsNullOrWhiteSpace(Record.Title)) return categoryPrefix + prefix + Record.Title; if (MainForm.IsDocumentRecord(Record) && !string.IsNullOrWhiteSpace(Record.SourceFileName)) return categoryPrefix + prefix + Record.SourceFileName; if (!string.IsNullOrWhiteSpace(Record.Url)) return categoryPrefix + prefix + Record.Url; if (!string.IsNullOrWhiteSpace(Record.Login)) return categoryPrefix + prefix + Record.Login; return categoryPrefix + prefix + "Без названия"; } } private sealed class CustomFieldControls { public CustomFieldControls(TextBox nameBox, ComboBox typeBox, TextBox valueBox, Button showButton) { NameBox = nameBox; TypeBox = typeBox; ValueBox = valueBox; ShowButton = showButton; } public TextBox NameBox { get; } public ComboBox TypeBox { get; } public TextBox ValueBox { get; } public Button ShowButton { get; } public bool SecretVisible { get; set; } public string Type => TypeBox.SelectedItem?.ToString() ?? "text"; } }