diff --git a/AiteBar.Tests/QuickNoteContractsTests.cs b/AiteBar.Tests/QuickNoteContractsTests.cs new file mode 100644 index 0000000..33d58b7 --- /dev/null +++ b/AiteBar.Tests/QuickNoteContractsTests.cs @@ -0,0 +1,65 @@ +namespace AiteBar.Tests; + +public sealed class QuickNoteContractsTests +{ + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(6)] + public void HeadingTag_RoundTripsSupportedLevels(int level) + { + Assert.True(QuickNoteTags.TryGetHeadingLevel(QuickNoteTags.Heading(level), out int parsed)); + Assert.Equal(level, parsed); + } + + [Theory] + [InlineData("heading:0")] + [InlineData("heading:7")] + [InlineData("heading:one")] + [InlineData("Heading:1")] + [InlineData(null)] + public void HeadingTag_RejectsInvalidValues(string? tag) + { + Assert.False(QuickNoteTags.TryGetHeadingLevel(tag, out _)); + } + + [Fact] + public void LinkAndIndentTags_RoundTripWithoutParsingPayloadContents() + { + const string url = "https://example.com/?value=link:test"; + Assert.Equal(url, QuickNoteTags.GetLink(QuickNoteTags.Link(url))); + Assert.Equal(" ", QuickNoteTags.GetIndent(QuickNoteTags.Indent(" "))); + } + + [Theory] + [InlineData("Left", 0)] + [InlineData("Right", 1)] + [InlineData("Top", 2)] + [InlineData("TopLeft", 3)] + [InlineData("TopRight", 4)] + [InlineData("Bottom", 5)] + [InlineData("BottomLeft", 6)] + [InlineData("BottomRight", 7)] + public void ResizeEdge_ParsesKnownXamlTags(string value, int expected) + { + Assert.True(QuickNoteResizeEdges.TryParse(value, out QuickNoteResizeEdge parsed)); + Assert.Equal((QuickNoteResizeEdge)expected, parsed); + } + + [Theory] + [InlineData("")] + [InlineData("left")] + [InlineData("Unknown")] + [InlineData(null)] + public void ResizeEdge_RejectsUnknownTags(string? value) + { + Assert.False(QuickNoteResizeEdges.TryParse(value, out _)); + } + + [Fact] + public void FontContract_UsesExpectedFamilies() + { + Assert.Equal(QuickNoteFonts.DefaultFamilyName, QuickNoteFonts.Default.Source); + Assert.Equal(QuickNoteFonts.CodeFamilyName, QuickNoteFonts.Code.Source); + } +} diff --git a/AiteBar.Tests/QuickNoteDocumentHelperTests.cs b/AiteBar.Tests/QuickNoteDocumentHelperTests.cs index 22c3c11..07cf71d 100644 --- a/AiteBar.Tests/QuickNoteDocumentHelperTests.cs +++ b/AiteBar.Tests/QuickNoteDocumentHelperTests.cs @@ -90,6 +90,57 @@ public void GetTextPointerAtOffset_ClampsNegativeAndPastEndOffsets() }); } + [Fact] + public void GetTextPointerAtOffset_ReturnsStartOfTextAfterParagraphMovesOutOfList() + { + RunSta(() => + { + var paragraph = new Paragraph(new Run("note")); + var item = new ListItem(paragraph); + var list = new System.Windows.Documents.List(item); + var document = new FlowDocument(list); + item.Blocks.Remove(paragraph); + document.Blocks.InsertBefore(list, paragraph); + document.Blocks.Remove(list); + + TextPointer start = QuickNoteDocumentHelper.GetTextPointerAtOffset(document, 0)!; + + Assert.Equal("note", new TextRange(start, paragraph.ContentEnd).Text); + }); + } + + [Fact] + public void RemapSelection_PreservesSelectedTextWhenListStructureRemovesHiddenLineBreaks() + { + var selection = QuickNoteDocumentHelper.RemapSelection( + "\n\nformatted item\n\n", + "formatted item\n", + 2, + 16); + + Assert.Equal((0, 14), selection); + } + + [Fact] + public void RemapSelection_UsesNearestMatchingTextWhenContentRepeats() + { + var selection = QuickNoteDocumentHelper.RemapSelection( + "\n\nsame\nsame\n\n", + "same\nsame\n", + 7, + 11); + + Assert.Equal((5, 9), selection); + } + + [Theory] + [InlineData("•\tone", "one")] + [InlineData("1.\tone\n2.\ttwo", "one\ntwo")] + public void RemoveVisualListMarkers_RemovesWpfMarkersFromSelectedText(string text, string expected) + { + Assert.Equal(expected, QuickNoteDocumentHelper.RemoveVisualListMarkers(text)); + } + private static void RunSta(Action action) { Exception? exception = null; diff --git a/AiteBar.Tests/QuickNoteFormattingControlsTests.cs b/AiteBar.Tests/QuickNoteFormattingControlsTests.cs new file mode 100644 index 0000000..24d9578 --- /dev/null +++ b/AiteBar.Tests/QuickNoteFormattingControlsTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Xml.Linq; + +namespace AiteBar.Tests; + +public sealed class QuickNoteFormattingControlsTests +{ + private static readonly XNamespace PresentationNamespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + private static readonly XNamespace XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + + [Fact] + public void HeadingCombo_ContainsBodyAndAllSixHeadingLevels() + { + XElement combo = FindCombo("CmbHeading"); + + Assert.Equal( + ["0", "1", "2", "3", "4", "5", "6"], + combo.Elements(PresentationNamespace + "ComboBoxItem") + .Select(item => item.Attribute("Tag")?.Value ?? string.Empty) + .ToArray()); + } + + [Fact] + public void ListCombo_ContainsBulletAndNumberedChoices() + { + XElement combo = FindCombo("CmbList"); + + Assert.Equal( + ["bullet", "numbered"], + combo.Elements(PresentationNamespace + "ComboBoxItem") + .Select(item => item.Attribute("Tag")?.Value ?? string.Empty) + .ToArray()); + } + + [Fact] + public void FormattingCombos_ResetAfterEachCommandSoTheSameChoiceCanBeUsedAgain() + { + Assert.Equal("-1", FindCombo("CmbHeading").Attribute("SelectedIndex")?.Value); + Assert.Equal("-1", FindCombo("CmbList").Attribute("SelectedIndex")?.Value); + + string code = File.ReadAllText(Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", + "AiteBar", + "QuickNoteWindow.xaml.cs")); + Assert.Equal(2, code.Split("ResetFormatCombo(comboBox, -1);").Length - 1); + } + + [Fact] + public void Toolbar_KeepsCompactPrimaryCommandsAndOverflowMenu() + { + XDocument document = LoadDocument(); + XElement toolbar = document + .Descendants(PresentationNamespace + "StackPanel") + .Single(element => element.Elements(PresentationNamespace + "ComboBox") + .Any(combo => (string?)combo.Attribute(XamlNamespace + "Name") == "CmbHeading")); + + Assert.Equal(5, toolbar.Elements(PresentationNamespace + "Button").Count()); + XElement overflow = toolbar.Elements(PresentationNamespace + "Button") + .Single(button => button.Element(PresentationNamespace + "Button.ContextMenu") != null); + string[] handlers = overflow + .Descendants(PresentationNamespace + "MenuItem") + .Select(item => (string?)item.Attribute("Click")) + .Where(click => click != null) + .Cast() + .ToArray(); + + Assert.Equal( + ["BtnUndo_Click", "BtnRedo_Click", "BtnUnderline_Click", "BtnCode_Click", "BtnClearFormatting_Click"], + handlers); + } + + private static XElement FindCombo(string name) + { + XDocument document = LoadDocument(); + return document + .Descendants(PresentationNamespace + "ComboBox") + .Single(element => (string?)element.Attribute(XamlNamespace + "Name") == name); + } + + private static XDocument LoadDocument() + { + string path = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "AiteBar", "QuickNoteWindow.xaml"); + return XDocument.Load(path); + } +} diff --git a/AiteBar.Tests/QuickNoteLayoutHelperTests.cs b/AiteBar.Tests/QuickNoteLayoutHelperTests.cs index 553cca6..4541d05 100644 --- a/AiteBar.Tests/QuickNoteLayoutHelperTests.cs +++ b/AiteBar.Tests/QuickNoteLayoutHelperTests.cs @@ -101,4 +101,52 @@ public void ClampBoundsToWorkArea_EnforcesMinimumSize() Assert.Equal(QuickNoteLayoutHelper.MinWidth, bounds.Width); Assert.Equal(QuickNoteLayoutHelper.MinHeight, bounds.Height); } + + [Fact] + public void SelectWorkArea_UsesSecondaryMonitorContainingSavedBounds() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle secondary = new(1920, 0, 2560, 1440); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, secondary], + left: 2300, + top: 200, + width: 580, + height: 430); + + Assert.Equal(secondary, selected); + } + + [Fact] + public void SelectWorkArea_SupportsNegativeMonitorCoordinates() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle leftMonitor = new(-1920, 0, 1920, 1080); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, leftMonitor], + left: -1500, + top: 100, + width: 580, + height: 430); + + Assert.Equal(leftMonitor, selected); + } + + [Fact] + public void SelectWorkArea_UsesNearestMonitorWhenSavedMonitorWasRemoved() + { + Rectangle primary = new(0, 0, 1920, 1080); + Rectangle rightMonitor = new(1920, 0, 1920, 1080); + + Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + [primary, rightMonitor], + left: 5000, + top: 100, + width: 580, + height: 430); + + Assert.Equal(rightMonitor, selected); + } } diff --git a/AiteBar.Tests/QuickNoteMarkdownTests.cs b/AiteBar.Tests/QuickNoteMarkdownTests.cs index 417859a..9a75aff 100644 --- a/AiteBar.Tests/QuickNoteMarkdownTests.cs +++ b/AiteBar.Tests/QuickNoteMarkdownTests.cs @@ -117,7 +117,20 @@ public void GetClearLineMarkerRangeEdit_ReplacesSelectedLinesAsSingleSegment() Assert.Equal(5, edit.StartOffset); Assert.Equal("- one\n- two".Length, edit.RemoveLength); Assert.Equal("one\ntwo", edit.InsertText); - Assert.True(edit.CaretOffset >= edit.StartOffset); + Assert.Equal(5, edit.CaretOffset); + Assert.Equal("one\ntwo".Length, edit.SelectionLength); + } + + [Fact] + public void GetClearLineMarkerRangeEdit_PreservesPartialSelectionAfterRemovingMarkers() + { + QuickNoteRangeEdit edit = QuickNoteMarkdown.GetClearLineMarkerRangeEdit("head\n- one two\ntail", 7, 10); + + Assert.Equal(5, edit.StartOffset); + Assert.Equal("- one two".Length, edit.RemoveLength); + Assert.Equal("one two", edit.InsertText); + Assert.Equal(5, edit.CaretOffset); + Assert.Equal(3, edit.SelectionLength); } [Fact] @@ -170,6 +183,27 @@ public void LoadMarkdown_RendersSupportedInlineFormatting() Assert.Equal("plain **bold** *italic* `code`", markdown); } + [Theory] + [InlineData("***both***")] + [InlineData("~~under-strike~~")] + [InlineData("**bold `code`**")] + [InlineData("[**bold link**](https://example.com)")] + [InlineData("***bold italic `code`***")] + [InlineData("~~`code strike`~~")] + [InlineData("~~`code strike underline`~~")] + [InlineData("[~~`formatted code link`~~](https://example.com)")] + public void LoadMarkdown_PreservesNestedFormattingRoundTrip(string source) + { + string markdown = RunSta(() => + { + var document = new FlowDocument(); + QuickNoteMarkdown.LoadMarkdown(document, source); + return QuickNoteMarkdown.ToMarkdown(document); + }); + + Assert.Equal(source, markdown); + } + [Fact] public void LoadMarkdown_RendersAndSavesHeadings() { @@ -418,6 +452,24 @@ public void LoadMarkdown_DoesNotTreatEscapedMarkersAsFormatting() Assert.Equal("literal **not bold**", visibleText); } + [Theory] + [InlineData("https://example.com", 0, true)] + [InlineData("file:///C:/Windows/System32/calc.exe", 0, false)] + [InlineData("javascript:alert(1)", 0, false)] + [InlineData("mailto:user@example.com", 1, true)] + [InlineData("mailto:user@example.com\"&calc.exe", 1, false)] + [InlineData("tel:+380 (67) 123-45-67", 2, true)] + [InlineData("tel:../../calc.exe|cmd", 2, false)] + public void IsSafeLinkForOpen_AllowsOnlyExpectedSchemePayloads( + string link, + int type, + bool expected) + { + Assert.Equal( + expected, + QuickNoteMarkdown.IsSafeLinkForOpen(link, (QuickNoteMarkdown.LinkType)type)); + } + [Fact] public void ToMarkdown_PreservesMultipleLines() { diff --git a/AiteBar.Tests/QuickNoteServiceTests.cs b/AiteBar.Tests/QuickNoteServiceTests.cs index fde1613..df0d6c6 100644 --- a/AiteBar.Tests/QuickNoteServiceTests.cs +++ b/AiteBar.Tests/QuickNoteServiceTests.cs @@ -54,6 +54,15 @@ public async Task ReadMarkdownAsync_WhenNoFile_ReturnsEmpty() Assert.Equal(string.Empty, content); } + [Fact] + public async Task HasExternalChanges_WhenFileIsCreatedAfterMissingBaseline_ReturnsTrue() + { + await _service.ReadMarkdownAsync(); + await File.WriteAllTextAsync(_service.NotePath, "external"); + + Assert.True(_service.HasExternalChanges()); + } + [Fact] public async Task HasExternalChanges_AfterLoad_ReturnsFalse() { @@ -79,6 +88,19 @@ public async Task ReadMarkdownAsync_LoadsExistingFileAndTracksExternalChanges() Assert.True(_service.HasExternalChanges()); } + [Fact] + public async Task HasExternalChanges_DetectsSameLengthContentWithRestoredTimestamp() + { + await File.WriteAllTextAsync(_service.NotePath, "first"); + await _service.ReadMarkdownAsync(); + DateTime baselineTimestamp = File.GetLastWriteTimeUtc(_service.NotePath); + + await File.WriteAllTextAsync(_service.NotePath, "other"); + File.SetLastWriteTimeUtc(_service.NotePath, baselineTimestamp); + + Assert.True(_service.HasExternalChanges()); + } + [Fact] public async Task SaveAsync_WritesMarkdownAndClearsExternalChangeState() { @@ -130,6 +152,54 @@ public async Task SaveConflictCopyAsync_WritesConflictFileNextToNoteWithoutChang Assert.Equal(conflictPath, _service.LastConflictCopyPath); } + [Fact] + public async Task SaveConflictCopyAsync_CreatesUniqueFilesForImmediateConflicts() + { + string[] paths = await RunStaAsync(async () => + { + var first = new FlowDocument(new Paragraph(new Run("first"))); + var second = new FlowDocument(new Paragraph(new Run("second"))); + return new[] + { + await _service.SaveConflictCopyAsync(first), + await _service.SaveConflictCopyAsync(second) + }; + }); + + Assert.NotEqual(paths[0], paths[1]); + Assert.Equal("first", await File.ReadAllTextAsync(paths[0])); + Assert.Equal("second", await File.ReadAllTextAsync(paths[1])); + } + + [Fact] + public async Task ReadMarkdownAsync_RestoresLatestConflictCopyAfterServiceRestart() + { + string older = Path.Combine(_tempDir, "QuickNote.conflict-older.md"); + string latest = Path.Combine(_tempDir, "QuickNote.conflict-latest.md"); + await File.WriteAllTextAsync(older, "older"); + await File.WriteAllTextAsync(latest, "latest"); + File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddMinutes(-1)); + File.SetLastWriteTimeUtc(latest, DateTime.UtcNow); + var restarted = new QuickNoteService(_service.NotePath); + + await restarted.ReadMarkdownAsync(); + + Assert.Equal(latest, restarted.LastConflictCopyPath); + } + + [Fact] + public async Task SaveAsync_DoesNotLeaveTemporaryFiles() + { + await RunStaAsync(async () => + { + var document = new FlowDocument(new Paragraph(new Run("atomic"))); + await _service.SaveAsync(document); + }); + + Assert.Equal("atomic", await File.ReadAllTextAsync(_service.NotePath)); + Assert.Empty(Directory.GetFiles(_tempDir, "*.tmp")); + } + [Fact] public async Task OpenConflictCopy_OpensLastConflictCopy() { diff --git a/AiteBar.Tests/QuickNoteWindowCloseTests.cs b/AiteBar.Tests/QuickNoteWindowCloseTests.cs index dea0f01..128929b 100644 --- a/AiteBar.Tests/QuickNoteWindowCloseTests.cs +++ b/AiteBar.Tests/QuickNoteWindowCloseTests.cs @@ -13,7 +13,16 @@ namespace AiteBar.Tests; public sealed class QuickNoteWindowCloseTests { [Fact] - public async Task Close_WaitsForActiveAndForcedSavesBeforeDisposingWindow() + public void ForcedSaveWaitTimeout_IsBounded() + { + Assert.InRange( + QuickNoteWindow.ForcedSaveWaitTimeout, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(15)); + } + + [Fact] + public async Task Close_WaitsForActiveSaveWithoutWritingUnchangedDocumentAgain() { await RunStaAsync(async () => { @@ -45,15 +54,10 @@ await RunStaAsync(async () => persistence.CompleteSave(0); await firstSave; - await persistence.WaitForSaveCountAsync(2); - - Assert.True(window.IsVisible); - Assert.False(closed.Task.IsCompleted); - - persistence.CompleteSave(1); await closed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(window.IsVisible); + Assert.Equal(1, persistence.SaveCount); } finally { @@ -156,6 +160,7 @@ private sealed class DelayedQuickNotePersistence : IQuickNotePersistence private readonly List _saves = []; private readonly List<(int Count, TaskCompletionSource Completion)> _saveCountWaiters = []; + public int SaveCount => _saves.Count; public string? LastConflictCopyPath => null; public bool HasExternalChanges() => false; public void Load(FlowDocument document) => document.Blocks.Clear(); diff --git a/AiteBar.Tests/QuickNoteWindowFormattingTests.cs b/AiteBar.Tests/QuickNoteWindowFormattingTests.cs new file mode 100644 index 0000000..75270ce --- /dev/null +++ b/AiteBar.Tests/QuickNoteWindowFormattingTests.cs @@ -0,0 +1,250 @@ +using System.IO; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Documents; + +namespace AiteBar.Tests; + +[Collection("WpfTestCollection")] +public sealed class QuickNoteWindowFormattingTests +{ + [Fact] + public void ClearSelectedFormatting_UnwrapsListAndLinkInOneDocumentChange() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var formattedRun = new Run("formatted item"); + var hyperlink = new Hyperlink(new Bold(formattedRun)) + { + NavigateUri = new Uri("https://example.com") + }; + var list = new System.Windows.Documents.List(); + list.ListItems.Add(new ListItem(new Paragraph(hyperlink))); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(list); + window.TxtNote.Selection.Select( + formattedRun.ContentStart, + formattedRun.ContentEnd); + Assert.Equal("formatted item", QuickNoteDocumentHelper.RemoveVisualListMarkers(window.TxtNote.Selection.Text)); + + int textChangedCount = 0; + window.TxtNote.TextChanged += (_, _) => textChangedCount++; + + window.ClearSelectedFormatting(); + + var paragraph = Assert.IsType(window.TxtNote.Document.Blocks.FirstBlock); + Assert.Empty(paragraph.Inlines.OfType()); + Assert.Equal("formatted item", window.TxtNote.Selection.Text.Trim()); + Assert.Equal("formatted item", new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + Assert.Equal(FontWeights.Normal, window.TxtNote.Selection.GetPropertyValue(TextElement.FontWeightProperty)); + Assert.Equal("formatted item", QuickNoteMarkdown.ToMarkdown(window.TxtNote.Document)); + Assert.Equal(1, textChangedCount); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesSelectionAcrossMultipleListItems() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var first = new Run("first"); + var second = new Run("second"); + var list = new System.Windows.Documents.List(); + list.ListItems.Add(new ListItem(new Paragraph(first))); + list.ListItems.Add(new ListItem(new Paragraph(second))); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(list); + window.TxtNote.Selection.Select(first.ContentStart, second.ContentEnd); + Assert.Equal( + "first\nsecond", + QuickNoteDocumentHelper.RemoveVisualListMarkers(window.TxtNote.Selection.Text).Trim()); + + window.ClearSelectedFormatting(); + + Assert.Equal(2, window.TxtNote.Document.Blocks.OfType().Count()); + Assert.Equal( + "first\nsecond", + QuickNoteDocumentHelper.NormalizeLineEndings(window.TxtNote.Selection.Text).Trim()); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesUnselectedHyperlinkFragments() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var run = new Run("prefix selected suffix"); + var hyperlink = new Hyperlink(run) + { + NavigateUri = new Uri("https://example.com"), + Tag = "link:https://example.com" + }; + var paragraph = new Paragraph(hyperlink); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(paragraph); + window.TxtNote.Selection.Select( + run.ContentStart.GetPositionAtOffset("prefix ".Length)!, + run.ContentStart.GetPositionAtOffset("prefix selected".Length)!); + + window.ClearSelectedFormatting(); + + Hyperlink[] links = paragraph.Inlines.OfType().ToArray(); + Assert.Equal(2, links.Length); + Assert.Equal("prefix ", new TextRange(links[0].ContentStart, links[0].ContentEnd).Text); + Assert.Equal(" suffix", new TextRange(links[1].ContentStart, links[1].ContentEnd).Text); + Assert.Equal("selected", window.TxtNote.Selection.Text); + Assert.Equal( + "prefix selected suffix", + new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void ClearSelectedFormatting_PreservesNestedFormattingInLinkedFragments() + { + RunSta(() => + { + string tempRoot = Path.Combine(Path.GetTempPath(), "AiteBarTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + try + { + var settingsService = new AppSettingsService( + Path.Combine(tempRoot, "buttons.json"), + Path.Combine(tempRoot, "settings.json")); + using var window = new QuickNoteWindow(new NoOpQuickNotePersistence(), settingsService); + var prefix = new Run("prefix "); + var selected = new Run("selected"); + var suffix = new Run(" suffix"); + var strike = new Span(suffix) { TextDecorations = TextDecorations.Strikethrough }; + var hyperlink = new Hyperlink + { + NavigateUri = new Uri("https://example.com"), + Tag = "link:https://example.com" + }; + hyperlink.Inlines.Add(new Bold(prefix)); + hyperlink.Inlines.Add(new Italic(selected)); + hyperlink.Inlines.Add(strike); + var paragraph = new Paragraph(hyperlink); + window.TxtNote.Document.Blocks.Clear(); + window.TxtNote.Document.Blocks.Add(paragraph); + window.TxtNote.Selection.Select(selected.ContentStart, selected.ContentEnd); + + window.ClearSelectedFormatting(); + + Hyperlink[] links = paragraph.Inlines.OfType().ToArray(); + Assert.Equal(2, links.Length); + Assert.IsType(links[0].Inlines.FirstInline); + var suffixSpan = Assert.IsType(links[1].Inlines.FirstInline); + Assert.Contains( + suffixSpan.TextDecorations, + decoration => decoration.Location == TextDecorationLocation.Strikethrough); + Assert.Equal(FontStyles.Normal, window.TxtNote.Selection.GetPropertyValue(TextElement.FontStyleProperty)); + Assert.Equal("prefix selected suffix", new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + Assert.Equal( + "[**prefix **](https://example.com)selected[~~ suffix~~](https://example.com)", + QuickNoteMarkdown.ToMarkdown(window.TxtNote.Document)); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + }); + } + + [Fact] + public void LinkDialog_PreservesSelectedWhitespaceInLinkText() + { + Assert.Equal( + " selected text ", + QuickNoteLinkDialog.PreserveLinkText(" selected text ")); + } + + private static void RunSta(Action action) + { + Exception? exception = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + exception = ex; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception != null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } + + private sealed class NoOpQuickNotePersistence : IQuickNotePersistence + { + public string? LastConflictCopyPath => null; + public bool HasExternalChanges() => false; + public void Load(FlowDocument document) + { + } + + public Task SaveAsync(FlowDocument document) => Task.CompletedTask; + public Task SaveConflictCopyAsync(FlowDocument document) => Task.FromResult(string.Empty); + public void OpenInEditor() + { + } + + public void OpenConflictCopy() + { + } + } +} diff --git a/AiteBar/QuickNoteContracts.cs b/AiteBar/QuickNoteContracts.cs new file mode 100644 index 0000000..2e365db --- /dev/null +++ b/AiteBar/QuickNoteContracts.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.Windows.Media; + +namespace AiteBar; + +internal static class QuickNoteFonts +{ + public const string DefaultFamilyName = "Segoe UI"; + public const string CodeFamilyName = "Consolas"; + + public static FontFamily Default { get; } = new(DefaultFamilyName); + public static FontFamily Code { get; } = new(CodeFamilyName); +} + +internal static class QuickNoteTags +{ + private const string HeadingPrefix = "heading:"; + private const string IndentPrefix = "indent:"; + private const string LinkPrefix = "link:"; + + public const string Code = "code"; + + public static string Heading(int level) => + HeadingPrefix + level.ToString(CultureInfo.InvariantCulture); + + public static bool TryGetHeadingLevel(object? tag, out int level) + { + level = 0; + return TryGetValue(tag, HeadingPrefix, out string value) && + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out level) && + level is >= 1 and <= 6; + } + + public static string? Indent(string value) => + string.IsNullOrEmpty(value) ? null : IndentPrefix + value; + + public static string GetIndent(object? tag, string fallback = "") => + TryGetValue(tag, IndentPrefix, out string value) ? value : fallback; + + public static string Link(string url) => LinkPrefix + url; + + public static string? GetLink(object? tag) => + TryGetValue(tag, LinkPrefix, out string value) ? value : null; + + private static bool TryGetValue(object? tag, string prefix, out string value) + { + value = string.Empty; + if (tag is not string text || !text.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + value = text[prefix.Length..]; + return true; + } +} + +internal enum QuickNoteResizeEdge +{ + Left, + Right, + Top, + TopLeft, + TopRight, + Bottom, + BottomLeft, + BottomRight +} + +internal static class QuickNoteResizeEdges +{ + public static bool TryParse(string? value, out QuickNoteResizeEdge edge) => + Enum.TryParse(value, ignoreCase: false, out edge) && + Enum.IsDefined(edge); +} diff --git a/AiteBar/QuickNoteDocumentHelper.cs b/AiteBar/QuickNoteDocumentHelper.cs index ef33343..027d438 100644 --- a/AiteBar/QuickNoteDocumentHelper.cs +++ b/AiteBar/QuickNoteDocumentHelper.cs @@ -1,10 +1,14 @@ using System; using System.Windows.Documents; +using System.Text.RegularExpressions; namespace AiteBar; internal static class QuickNoteDocumentHelper { + private static readonly Regex VisualListMarkerRegex = + new(@"^(?:[•◦▪]|\d+[.)])\t", RegexOptions.Compiled | RegexOptions.Multiline); + public static int GetTextOffset(FlowDocument document, TextPointer pointer) { return NormalizeLineEndings(new TextRange(document.ContentStart, pointer).Text).Length; @@ -14,7 +18,7 @@ public static int GetTextOffset(FlowDocument document, TextPointer pointer) { if (offset <= 0) { - return GetStartInsertionPosition(document); + return GetTextStartPointer(document); } int documentLength = GetTextOffset(document, document.ContentEnd); @@ -41,21 +45,113 @@ public static int GetTextOffset(FlowDocument document, TextPointer pointer) return best ?? document.ContentEnd; } - private static TextPointer GetStartInsertionPosition(FlowDocument document) + private static TextPointer GetTextStartPointer(FlowDocument document) { - TextPointer? pointer = document.ContentStart.GetInsertionPosition(LogicalDirection.Forward); - while (pointer != null && pointer.CompareTo(document.ContentEnd) <= 0) + TextPointer current = document.ContentStart; + TextPointer best = current; + while (current.CompareTo(document.ContentEnd) < 0) { - if (GetTextOffset(document, pointer) >= 0) + TextPointer? next = current.GetNextContextPosition(LogicalDirection.Forward); + if (next == null || GetTextOffset(document, next) > 0) { - return pointer; + break; } - pointer = pointer.GetNextInsertionPosition(LogicalDirection.Forward); + best = next; + current = next; + } + + return best; + } + + public static (int Start, int End) RemapSelection( + string oldText, + string newText, + int selectionStart, + int selectionEnd, + string? selectedText = null) + { + oldText = NormalizeLineEndings(oldText); + newText = NormalizeLineEndings(newText); + int start = Math.Clamp(Math.Min(selectionStart, selectionEnd), 0, oldText.Length); + int end = Math.Clamp(Math.Max(selectionStart, selectionEnd), 0, oldText.Length); + selectedText = selectedText == null + ? oldText[start..end] + : NormalizeLineEndings(selectedText); + + if (selectedText.Length > 0) + { + int closestMatch = FindClosestMatch(newText, selectedText, start); + if (closestMatch >= 0) + { + return (closestMatch, closestMatch + selectedText.Length); + } + } + else + { + const int contextLength = 32; + string leftContext = oldText[Math.Max(0, start - contextLength)..start]; + string rightContext = oldText[start..Math.Min(oldText.Length, start + contextLength)]; + string combinedContext = leftContext + rightContext; + int closestContext = FindClosestMatch(newText, combinedContext, Math.Max(0, start - leftContext.Length)); + if (closestContext >= 0) + { + int caret = closestContext + leftContext.Length; + return (caret, caret); + } + + int closestLeft = FindClosestMatch(newText, leftContext, Math.Max(0, start - leftContext.Length)); + if (leftContext.Length > 0 && closestLeft >= 0) + { + int caret = closestLeft + leftContext.Length; + return (caret, caret); + } + + int closestRight = FindClosestMatch(newText, rightContext, start); + if (rightContext.Length > 0 && closestRight >= 0) + { + return (closestRight, closestRight); + } + } + + int clampedStart = Math.Clamp(start, 0, newText.Length); + int clampedEnd = Math.Clamp(end, clampedStart, newText.Length); + return (clampedStart, clampedEnd); + } + + private static int FindClosestMatch(string text, string value, int expectedOffset) + { + if (value.Length == 0) + { + return -1; } - return document.ContentStart; + int closest = -1; + int closestDistance = int.MaxValue; + int searchStart = 0; + while (searchStart <= text.Length - value.Length) + { + int match = text.IndexOf(value, searchStart, StringComparison.Ordinal); + if (match < 0) + { + break; + } + + int distance = Math.Abs(match - expectedOffset); + if (distance < closestDistance) + { + closest = match; + closestDistance = distance; + } + + searchStart = match + 1; + } + + return closest; } public static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n'); + + public static string RemoveVisualListMarkers(string text) => + VisualListMarkerRegex.Replace(NormalizeLineEndings(text), string.Empty); } diff --git a/AiteBar/QuickNoteLayoutHelper.cs b/AiteBar/QuickNoteLayoutHelper.cs index 784daeb..ff67d37 100644 --- a/AiteBar/QuickNoteLayoutHelper.cs +++ b/AiteBar/QuickNoteLayoutHelper.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Drawing; +using System.Linq; namespace AiteBar; @@ -55,6 +57,57 @@ public static (double Left, double Top, double Width, double Height) ClampBounds return (safeLeft, safeTop, safeWidth, safeHeight); } + public static Rectangle SelectWorkArea( + IReadOnlyList workAreas, + double? left, + double? top, + double? width, + double? height) + { + ArgumentNullException.ThrowIfNull(workAreas); + if (workAreas.Count == 0) + { + return Rectangle.Empty; + } + + if (!IsUsableCoordinate(left) || !IsUsableCoordinate(top)) + { + return workAreas[0]; + } + + double safeWidth = IsUsableSize(width) ? width!.Value : DefaultWidth; + double safeHeight = IsUsableSize(height) ? height!.Value : DefaultHeight; + var savedBounds = new Rectangle( + (int)Math.Round(left!.Value), + (int)Math.Round(top!.Value), + Math.Max(1, (int)Math.Round(safeWidth)), + Math.Max(1, (int)Math.Round(safeHeight))); + + Rectangle intersecting = workAreas + .Select(area => (Area: area, Intersection: Rectangle.Intersect(area, savedBounds))) + .OrderByDescending(candidate => (long)candidate.Intersection.Width * candidate.Intersection.Height) + .First() + .Area; + + if (workAreas.Any(area => Rectangle.Intersect(area, savedBounds) is { Width: > 0, Height: > 0 })) + { + return intersecting; + } + + double centerX = left.Value + safeWidth / 2; + double centerY = top.Value + safeHeight / 2; + return workAreas + .OrderBy(area => DistanceSquaredToRectangle(centerX, centerY, area)) + .First(); + } + + private static double DistanceSquaredToRectangle(double x, double y, Rectangle rectangle) + { + double dx = x < rectangle.Left ? rectangle.Left - x : x > rectangle.Right ? x - rectangle.Right : 0; + double dy = y < rectangle.Top ? rectangle.Top - y : y > rectangle.Bottom ? y - rectangle.Bottom : 0; + return dx * dx + dy * dy; + } + private static bool IsUsableSize(double? value) => value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value) && value.Value > 0; diff --git a/AiteBar/QuickNoteLinkDialog.xaml.cs b/AiteBar/QuickNoteLinkDialog.xaml.cs index 79f9716..2da6a89 100644 --- a/AiteBar/QuickNoteLinkDialog.xaml.cs +++ b/AiteBar/QuickNoteLinkDialog.xaml.cs @@ -5,7 +5,7 @@ namespace AiteBar { public partial class QuickNoteLinkDialog : DarkWindow { - public string LinkText => TxtLinkText.Text.Trim(); + public string LinkText => PreserveLinkText(TxtLinkText.Text); public string Url => TxtUrl.Text.Trim(); @@ -57,5 +57,7 @@ private static bool IsValidHttpUrl(string value) return Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); } + + internal static string PreserveLinkText(string? value) => value ?? string.Empty; } } diff --git a/AiteBar/QuickNoteMarkdown.cs b/AiteBar/QuickNoteMarkdown.cs index 79a066c..bbdb250 100644 --- a/AiteBar/QuickNoteMarkdown.cs +++ b/AiteBar/QuickNoteMarkdown.cs @@ -16,16 +16,18 @@ namespace AiteBar internal static class QuickNoteMarkdown { - private static readonly MediaFontFamily DefaultFont = new("Segoe UI"); - private static readonly MediaFontFamily CodeFont = new("Consolas"); - private static readonly Regex UrlRegex = new(@"(?i)\b(?:https?://|www\.)[^\s<>()""']+", RegexOptions.Compiled); - private static readonly Regex EmailRegex = new(@"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", RegexOptions.Compiled); - private static readonly Regex PhoneRegex = new(@"(?i)\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b", RegexOptions.Compiled); + private const RegexOptions LinkRegexOptions = + RegexOptions.Compiled | + RegexOptions.IgnoreCase | + RegexOptions.CultureInvariant | + RegexOptions.NonBacktracking; + private static readonly Regex UrlRegex = new(@"\b(?:https?://|www\.)[^\s<>()""']+", LinkRegexOptions); + private static readonly Regex EmailRegex = new(@"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", LinkRegexOptions); + private static readonly Regex PhoneRegex = new(@"\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b", LinkRegexOptions); private static readonly Regex BulletMarkerRegex = new(@"^(?\s*)[-*]\s+", RegexOptions.Compiled); private static readonly Regex NumberMarkerRegex = new(@"^(?\s*)\d+\.\s+", RegexOptions.Compiled); private static readonly Regex AnyListMarkerRegex = new(@"^(?\s*)(?:[-*]\s+|\d+\.\s+)", RegexOptions.Compiled); private static readonly Regex HeadingMarkerRegex = new(@"^(?\s*)(?#{1,6})\s+", RegexOptions.Compiled); - private const string HeadingTagPrefix = "heading:"; public enum LinkType { @@ -74,10 +76,56 @@ public static string NormalizeUrlForOpen(string matchedUrl) return NormalizeLinkForOpen(matchedUrl, LinkType.Url); } + public static bool IsSafeLinkForOpen(string link, LinkType type) + { + if (string.IsNullOrWhiteSpace(link)) + { + return false; + } + + return type switch + { + LinkType.Url => + Uri.TryCreate(link, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps), + LinkType.Email => IsSafeEmailLink(link), + LinkType.Phone => IsSafePhoneLink(link), + _ => false + }; + } + + private static bool IsSafeEmailLink(string link) + { + const string prefix = "mailto:"; + if (!link.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string address = link[prefix.Length..]; + Match match = EmailRegex.Match(address); + return match.Success && match.Index == 0 && match.Length == address.Length; + } + + private static bool IsSafePhoneLink(string link) + { + const string prefix = "tel:"; + if (!link.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string number = link[prefix.Length..]; + return number.Count(char.IsDigit) >= 4 && + number.All(character => + char.IsDigit(character) || + character is '+' or '-' or '.' or '(' or ')' or ' '); + } + public static void LoadMarkdown(FlowDocument document, string markdown) { document.Blocks.Clear(); - string[] lines = NormalizeLineEndings(markdown).Split('\n'); + string[] lines = QuickNoteDocumentHelper.NormalizeLineEndings(markdown).Split('\n'); int index = 0; var listStack = new Stack<(FlowList List, int IndentLevel)>(); @@ -198,7 +246,7 @@ public static string ToMarkdown(FlowDocument document) public static QuickNoteTextEdit ToggleListMarkers(string text, int selectionStart, int selectionEnd, bool numbered) { - text = NormalizeLineEndings(text); + text = QuickNoteDocumentHelper.NormalizeLineEndings(text); QuickNoteTextOperation[] operations = GetListMarkerOperations(text, selectionStart, selectionEnd, numbered); string updatedText = ApplyOperations(text, operations); int caret = MapOffsetThroughOperations(Math.Max(selectionStart, selectionEnd), operations); @@ -207,7 +255,7 @@ public static QuickNoteTextEdit ToggleListMarkers(string text, int selectionStar public static QuickNoteRangeEdit GetToggleListMarkerRangeEdit(string text, int selectionStart, int selectionEnd, bool numbered) { - text = NormalizeLineEndings(text); + text = QuickNoteDocumentHelper.NormalizeLineEndings(text); var (lineStart, lineEnd) = GetSelectedLineBounds(text, selectionStart, selectionEnd); string selectedText = text[lineStart..lineEnd]; int relativeSelectionStart = Math.Clamp(selectionStart - lineStart, 0, selectedText.Length); @@ -221,7 +269,7 @@ public static QuickNoteRangeEdit GetToggleListMarkerRangeEdit(string text, int s public static QuickNoteTextEdit ClearLineMarkers(string text, int selectionStart, int selectionEnd) { - text = NormalizeLineEndings(text); + text = QuickNoteDocumentHelper.NormalizeLineEndings(text); QuickNoteTextOperation[] operations = GetClearMarkerOperations(text, selectionStart, selectionEnd); string updatedText = ApplyOperations(text, operations); int caret = MapOffsetThroughOperations(Math.Max(selectionStart, selectionEnd), operations); @@ -230,13 +278,16 @@ public static QuickNoteTextEdit ClearLineMarkers(string text, int selectionStart public static QuickNoteRangeEdit GetClearLineMarkerRangeEdit(string text, int selectionStart, int selectionEnd) { - text = NormalizeLineEndings(text); + text = QuickNoteDocumentHelper.NormalizeLineEndings(text); var (lineStart, lineEnd) = GetSelectedLineBounds(text, selectionStart, selectionEnd); string selectedText = text[lineStart..lineEnd]; - QuickNoteTextOperation[] operations = GetClearMarkerOperations(selectedText, selectionStart - lineStart, selectionEnd - lineStart); + int relativeSelectionStart = Math.Clamp(selectionStart - lineStart, 0, selectedText.Length); + int relativeSelectionEnd = Math.Clamp(selectionEnd - lineStart, 0, selectedText.Length); + QuickNoteTextOperation[] operations = GetClearMarkerOperations(selectedText, relativeSelectionStart, relativeSelectionEnd); string updatedText = ApplyOperations(selectedText, operations); - int caret = lineStart + MapOffsetThroughOperations(Math.Max(selectionStart, selectionEnd) - lineStart, operations); - return new QuickNoteRangeEdit(lineStart, lineEnd - lineStart, updatedText, caret, 0); + int mappedStart = lineStart + MapOffsetThroughOperations(relativeSelectionStart, operations); + int mappedEnd = lineStart + MapOffsetThroughOperations(relativeSelectionEnd, operations); + return new QuickNoteRangeEdit(lineStart, lineEnd - lineStart, updatedText, mappedStart, Math.Max(0, mappedEnd - mappedStart)); } public static QuickNoteRangeEdit GetHeadingRangeEdit(string text, int selectionStart, int selectionEnd, int headingLevel) @@ -246,7 +297,7 @@ public static QuickNoteRangeEdit GetHeadingRangeEdit(string text, int selectionS throw new ArgumentOutOfRangeException(nameof(headingLevel), "Heading level must be 0 for body text or 1 through 6."); } - text = NormalizeLineEndings(text); + text = QuickNoteDocumentHelper.NormalizeLineEndings(text); var (lineStart, lineEnd) = GetSelectedLineBounds(text, selectionStart, selectionEnd); string selectedText = text[lineStart..lineEnd]; QuickNoteTextOperation[] operations = GetHeadingOperations(selectedText, selectionStart - lineStart, selectionEnd - lineStart, headingLevel); @@ -334,7 +385,7 @@ public static QuickNoteTextOperation[] GetHeadingOperations(string text, int sel return operations.ToArray(); } - private static void AddMarkdownInlines(InlineCollection inlines, string line) + private static void AddMarkdownInlines(InlineCollection inlines, string line, bool allowLinks = true) { var plain = new StringBuilder(); int index = 0; @@ -347,10 +398,24 @@ private static void AddMarkdownInlines(InlineCollection inlines, string line) continue; } + if (TryReadDelimited(line, index, "***", out string boldItalicText, out int boldItalicEnd)) + { + FlushPlain(inlines, plain); + var bold = new Bold(); + var italic = new Italic(); + AddMarkdownInlines(italic.Inlines, boldItalicText, allowLinks); + bold.Inlines.Add(italic); + inlines.Add(bold); + index = boldItalicEnd; + continue; + } + if (TryReadDelimited(line, index, "**", out string boldText, out int boldEnd)) { FlushPlain(inlines, plain); - inlines.Add(new Bold(CreateRun(UnescapeMarkdownText(boldText)))); + var bold = new Bold(); + AddMarkdownInlines(bold.Inlines, boldText, allowLinks); + inlines.Add(bold); index = boldEnd; continue; } @@ -358,10 +423,12 @@ private static void AddMarkdownInlines(InlineCollection inlines, string line) if (TryReadHtmlUnderline(line, index, out string underlineText, out int underlineEnd)) { FlushPlain(inlines, plain); - inlines.Add(new Span(CreateRun(UnescapeMarkdownText(underlineText))) + var underline = new Span { TextDecorations = TextDecorations.Underline - }); + }; + AddMarkdownInlines(underline.Inlines, underlineText, allowLinks); + inlines.Add(underline); index = underlineEnd; continue; } @@ -369,18 +436,23 @@ private static void AddMarkdownInlines(InlineCollection inlines, string line) if (TryReadDelimited(line, index, "~~", out string strikeText, out int strikeEnd)) { FlushPlain(inlines, plain); - inlines.Add(new Span(CreateRun(UnescapeMarkdownText(strikeText))) + var strike = new Span { TextDecorations = TextDecorations.Strikethrough - }); + }; + AddMarkdownInlines(strike.Inlines, strikeText, allowLinks); + inlines.Add(strike); index = strikeEnd; continue; } - if (TryReadMarkdownLink(line, index, out string linkText, out string url, out int linkEnd)) + if (allowLinks && TryReadMarkdownLink(line, index, out string linkText, out string url, out int linkEnd)) { FlushPlain(inlines, plain); - inlines.Add(CreateHyperlink(UnescapeMarkdownText(linkText), UnescapeMarkdownText(url))); + Hyperlink hyperlink = CreateHyperlink(string.Empty, UnescapeMarkdownText(url)); + hyperlink.Inlines.Clear(); + AddMarkdownInlines(hyperlink.Inlines, linkText, allowLinks: false); + inlines.Add(hyperlink); index = linkEnd; continue; } @@ -388,9 +460,9 @@ private static void AddMarkdownInlines(InlineCollection inlines, string line) if (TryReadDelimited(line, index, "`", out string codeText, out int codeEnd)) { FlushPlain(inlines, plain); - var codeSpan = new Span(CreateRun(UnescapeMarkdownText(codeText), CodeFont)) + var codeSpan = new Span(CreateRun(UnescapeMarkdownText(codeText), QuickNoteFonts.Code)) { - Tag = "code" + Tag = QuickNoteTags.Code }; inlines.Add(codeSpan); index = codeEnd; @@ -401,7 +473,9 @@ private static void AddMarkdownInlines(InlineCollection inlines, string line) TryReadDelimited(line, index, "*", out string italicText, out int italicEnd)) { FlushPlain(inlines, plain); - inlines.Add(new Italic(CreateRun(UnescapeMarkdownText(italicText)))); + var italic = new Italic(); + AddMarkdownInlines(italic.Inlines, italicText, allowLinks); + inlines.Add(italic); index = italicEnd; continue; } @@ -564,7 +638,7 @@ private static Paragraph CreateParagraph(params Inline[] inlines) { var paragraph = new Paragraph { - FontFamily = DefaultFont, + FontFamily = QuickNoteFonts.Default, FontSize = 14, FontWeight = FontWeights.Normal, FontStyle = FontStyles.Normal, @@ -603,7 +677,7 @@ private static FlowList CreateList(bool numbered, string indent = "") => { MarkerStyle = numbered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc, Margin = new Thickness(0), - Tag = string.IsNullOrEmpty(indent) ? null : "indent:" + indent + Tag = QuickNoteTags.Indent(indent) }; private static ListItem CreateListItem(string markdownText) @@ -619,7 +693,7 @@ private static ListItem CreateListItem(string markdownText) private static Run CreateRun(string text, MediaFontFamily? fontFamily = null) => new(text) { - FontFamily = fontFamily ?? DefaultFont, + FontFamily = fontFamily ?? QuickNoteFonts.Default, FontWeight = FontWeights.Normal, FontStyle = FontStyles.Normal, TextDecorations = null @@ -630,7 +704,7 @@ public static Hyperlink CreateHyperlink(string text, string url) var hyperlink = new Hyperlink(CreateRun(text)) { NavigateUri = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null, - Tag = "link:" + url, + Tag = QuickNoteTags.Link(url), Foreground = Brushes.DodgerBlue, TextDecorations = TextDecorations.Underline }; @@ -641,7 +715,7 @@ private static Span CreateHeadingSpan(int headingLevel) { return new Span { - Tag = HeadingTagPrefix + headingLevel.ToString(System.Globalization.CultureInfo.InvariantCulture), + Tag = QuickNoteTags.Heading(headingLevel), FontSize = GetHeadingFontSize(headingLevel), FontWeight = FontWeights.SemiBold }; @@ -845,8 +919,14 @@ private static void AppendInlineMarkdown(StringBuilder builder, Inline inline, b bool isBold = bold || inline is Bold || IsLocalValue(inline, TextElement.FontWeightProperty, FontWeights.Bold); bool isItalic = italic || inline is Italic || IsLocalValue(inline, TextElement.FontStyleProperty, FontStyles.Italic); bool isCode = code || IsCodeInline(inline); - bool isUnderline = underline || HasTextDecoration(inline, TextDecorationLocation.Underline); + // Hyperlink applies underline as its default visual style; Markdown link syntax already preserves it. + bool isUnderline = underline || (inline is not Hyperlink && HasTextDecoration(inline, TextDecorationLocation.Underline)); bool isStrikethrough = strikethrough || HasTextDecoration(inline, TextDecorationLocation.Strikethrough); + bool opensBold = isBold && !bold; + bool opensItalic = isItalic && !italic; + bool opensCode = isCode && !code; + bool opensUnderline = isUnderline && !underline; + bool opensStrikethrough = isStrikethrough && !strikethrough; if (inline is Hyperlink hyperlink) { @@ -856,6 +936,7 @@ private static void AppendInlineMarkdown(StringBuilder builder, Inline inline, b AppendInlineMarkdown(linkBuilder, child, isBold, isItalic, isCode, false, isStrikethrough); } + AppendStyleOpening(builder, opensBold, opensItalic, opensCode, opensUnderline, opensStrikethrough); string linkUrl = GetHyperlinkUrl(hyperlink); if (string.IsNullOrWhiteSpace(linkUrl)) { @@ -870,12 +951,13 @@ private static void AppendInlineMarkdown(StringBuilder builder, Inline inline, b builder.Append(')'); } + AppendStyleClosing(builder, opensBold, opensItalic, opensCode, opensUnderline, opensStrikethrough); return; } if (inline is Run run) { - AppendStyledText(builder, run.Text, isBold, isItalic, isCode, isUnderline, isStrikethrough); + AppendStyledText(builder, run.Text, opensBold, opensItalic, opensCode, opensUnderline, opensStrikethrough); return; } @@ -887,43 +969,31 @@ private static void AppendInlineMarkdown(StringBuilder builder, Inline inline, b if (inline is Span span) { + AppendStyleOpening(builder, opensBold, opensItalic, opensCode, opensUnderline, opensStrikethrough); foreach (Inline child in span.Inlines) { AppendInlineMarkdown(builder, child, isBold, isItalic, isCode, isUnderline, isStrikethrough); } + + AppendStyleClosing(builder, opensBold, opensItalic, opensCode, opensUnderline, opensStrikethrough); } } - private static string GetHyperlinkUrl(Hyperlink hyperlink) + internal static string GetHyperlinkUrl(Hyperlink hyperlink) { - if (hyperlink.Tag is string tag && tag.StartsWith("link:", StringComparison.Ordinal)) - { - return tag["link:".Length..]; - } - - return hyperlink.NavigateUri?.ToString() ?? string.Empty; + return QuickNoteTags.GetLink(hyperlink.Tag) ?? + hyperlink.NavigateUri?.ToString() ?? + string.Empty; } private static bool TryGetHeadingLevel(Span span, out int headingLevel) { - headingLevel = 0; - if (span.Tag is not string tag || !tag.StartsWith(HeadingTagPrefix, StringComparison.Ordinal)) - { - return false; - } - - return int.TryParse(tag[HeadingTagPrefix.Length..], out headingLevel) && - headingLevel is >= 1 and <= 6; + return QuickNoteTags.TryGetHeadingLevel(span.Tag, out headingLevel); } private static string GetListIndent(FlowList list, string? fallbackIndent = null) { - if (list.Tag is string tag && tag.StartsWith("indent:", StringComparison.Ordinal)) - { - return tag["indent:".Length..]; - } - - return fallbackIndent ?? string.Empty; + return QuickNoteTags.GetIndent(list.Tag, fallbackIndent ?? string.Empty); } private static void AppendStyledText(StringBuilder builder, string text, bool bold, bool italic, bool code, bool underline, bool strikethrough) @@ -934,6 +1004,13 @@ private static void AppendStyledText(StringBuilder builder, string text, bool bo } string escaped = EscapeMarkdownText(text); + AppendStyleOpening(builder, bold, italic, code, underline, strikethrough); + builder.Append(escaped); + AppendStyleClosing(builder, bold, italic, code, underline, strikethrough); + } + + private static void AppendStyleOpening(StringBuilder builder, bool bold, bool italic, bool code, bool underline, bool strikethrough) + { if (underline) { builder.Append(""); @@ -949,28 +1026,29 @@ private static void AppendStyledText(StringBuilder builder, string text, bool bo builder.Append('*'); } - if (code) - { - builder.Append('`'); - } - if (strikethrough) { builder.Append("~~"); } - builder.Append(escaped); - - if (strikethrough) + if (code) { - builder.Append("~~"); + builder.Append('`'); } + } + private static void AppendStyleClosing(StringBuilder builder, bool bold, bool italic, bool code, bool underline, bool strikethrough) + { if (code) { builder.Append('`'); } + if (strikethrough) + { + builder.Append("~~"); + } + if (italic) { builder.Append('*'); @@ -1026,8 +1104,8 @@ private static string UnescapeMarkdownText(string text) } private static bool IsCodeInline(Inline inline) => - inline.FontFamily?.Source.Equals("Consolas", StringComparison.OrdinalIgnoreCase) == true || - (inline is Span span && span.Tag?.ToString() == "code"); + inline.FontFamily?.Source.Equals(QuickNoteFonts.CodeFamilyName, StringComparison.OrdinalIgnoreCase) == true || + (inline is Span span && Equals(span.Tag, QuickNoteTags.Code)); private static bool HasTextDecoration(Inline inline, TextDecorationLocation location) { @@ -1131,8 +1209,6 @@ private static (int IndentLength, int MarkerLength) GetHeadingMarker(string line return lines; } - private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n'); - private static string ApplyOperations(string text, IReadOnlyCollection operations) { var sortedOperations = operations.OrderByDescending(op => op.Offset).ToList(); diff --git a/AiteBar/QuickNoteService.cs b/AiteBar/QuickNoteService.cs index 3f086a9..ab7e83b 100644 --- a/AiteBar/QuickNoteService.cs +++ b/AiteBar/QuickNoteService.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; using System.Windows.Documents; @@ -25,7 +26,11 @@ public sealed class QuickNoteService { private readonly string _notePath; private readonly IQuickNoteProcessStartDispatcher _processStartDispatcher; + private bool _baselineEstablished; + private bool _lastKnownExists; private DateTime _lastKnownWriteTimeUtc = DateTime.MinValue; + private long _lastKnownLength; + private string? _lastKnownContentHash; public QuickNoteService(string? notePath = null) : this(notePath, new QuickNoteProcessStartDispatcher()) @@ -45,13 +50,21 @@ internal QuickNoteService(string? notePath, IQuickNoteProcessStartDispatcher pro public bool HasExternalChanges() { - if (!File.Exists(NotePath)) + if (!_baselineEstablished) { - return _lastKnownWriteTimeUtc != DateTime.MinValue; + return false; } - DateTime currentWriteTimeUtc = File.GetLastWriteTimeUtc(NotePath); - return _lastKnownWriteTimeUtc != DateTime.MinValue && currentWriteTimeUtc != _lastKnownWriteTimeUtc; + var file = new FileInfo(NotePath); + if (file.Exists != _lastKnownExists) + { + return true; + } + + return file.Exists && + (file.LastWriteTimeUtc != _lastKnownWriteTimeUtc || + file.Length != _lastKnownLength || + !string.Equals(ComputeContentHash(NotePath), _lastKnownContentHash, StringComparison.Ordinal)); } public async Task LoadAsync(FlowDocument document) @@ -71,12 +84,14 @@ public string ReadMarkdown() EnsureNoteDirectory(); if (!File.Exists(NotePath)) { - _lastKnownWriteTimeUtc = DateTime.MinValue; + RecordBaseline(); + RefreshLastConflictCopy(); return string.Empty; } string markdown = File.ReadAllText(NotePath); - _lastKnownWriteTimeUtc = File.GetLastWriteTimeUtc(NotePath); + RecordBaseline(); + RefreshLastConflictCopy(); return markdown; } @@ -85,12 +100,14 @@ public async Task ReadMarkdownAsync() EnsureNoteDirectory(); if (!File.Exists(NotePath)) { - _lastKnownWriteTimeUtc = DateTime.MinValue; + RecordBaseline(); + RefreshLastConflictCopy(); return string.Empty; } string markdown = await File.ReadAllTextAsync(NotePath); - _lastKnownWriteTimeUtc = File.GetLastWriteTimeUtc(NotePath); + RecordBaseline(); + RefreshLastConflictCopy(); return markdown; } @@ -104,8 +121,8 @@ public async Task SaveAsync(FlowDocument document) { EnsureNoteDirectory(); string markdown = QuickNoteMarkdown.ToMarkdown(document); - await File.WriteAllTextAsync(NotePath, markdown); - _lastKnownWriteTimeUtc = File.GetLastWriteTimeUtc(NotePath); + await WriteAtomicallyAsync(NotePath, markdown); + RecordBaseline(); } public async Task SaveConflictCopyAsync(FlowDocument document) @@ -113,9 +130,9 @@ public async Task SaveConflictCopyAsync(FlowDocument document) EnsureNoteDirectory(); string conflictPath = Path.Combine( Path.GetDirectoryName(NotePath) ?? PathHelper.AppDataFolder, - $"QuickNote.conflict-{DateTime.Now:yyyyMMdd-HHmmss}.md"); + $"QuickNote.conflict-{DateTime.Now:yyyyMMdd-HHmmss-fff}-{Guid.NewGuid():N}.md"); string markdown = QuickNoteMarkdown.ToMarkdown(document); - await File.WriteAllTextAsync(conflictPath, markdown); + await WriteNewFileAsync(conflictPath, markdown); LastConflictCopyPath = conflictPath; CleanupOldConflictCopies(); return conflictPath; @@ -161,6 +178,7 @@ public void OpenInEditor() if (!File.Exists(NotePath)) { File.WriteAllText(NotePath, string.Empty); + RecordBaseline(); } _processStartDispatcher.Start(new ProcessStartInfo(NotePath) { UseShellExecute = true }); @@ -187,5 +205,61 @@ private void EnsureNoteDirectory() Directory.CreateDirectory(directory); } + + private void RecordBaseline() + { + var file = new FileInfo(NotePath); + _baselineEstablished = true; + _lastKnownExists = file.Exists; + _lastKnownWriteTimeUtc = file.Exists ? file.LastWriteTimeUtc : DateTime.MinValue; + _lastKnownLength = file.Exists ? file.Length : 0; + _lastKnownContentHash = file.Exists ? ComputeContentHash(NotePath) : null; + } + + private void RefreshLastConflictCopy() + { + try + { + string directory = Path.GetDirectoryName(NotePath) ?? PathHelper.AppDataFolder; + LastConflictCopyPath = Directory.GetFiles(directory, "QuickNote.conflict-*.md") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + catch (Exception ex) + { + Logger.Log(ex); + } + } + + private static string ComputeContentHash(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return Convert.ToHexString(SHA256.HashData(stream)); + } + + private static async Task WriteAtomicallyAsync(string path, string content) + { + string directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory(); + string tempPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + await File.WriteAllTextAsync(tempPath, content); + File.Move(tempPath, path, overwrite: true); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private static async Task WriteNewFileAsync(string path, string content) + { + await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(content); + } } } diff --git a/AiteBar/QuickNoteWindow.xaml b/AiteBar/QuickNoteWindow.xaml index 78aff97..b756805 100644 --- a/AiteBar/QuickNoteWindow.xaml +++ b/AiteBar/QuickNoteWindow.xaml @@ -276,21 +276,36 @@ - diff --git a/AiteBar/QuickNoteWindow.xaml.cs b/AiteBar/QuickNoteWindow.xaml.cs index c9b0842..847fed3 100644 --- a/AiteBar/QuickNoteWindow.xaml.cs +++ b/AiteBar/QuickNoteWindow.xaml.cs @@ -31,6 +31,7 @@ public partial class QuickNoteWindow : DarkWindow, IDisposable private const int WMSZ_BOTTOM = 6; private const int WMSZ_BOTTOMLEFT = 7; private const int WMSZ_BOTTOMRIGHT = 8; + internal static readonly TimeSpan ForcedSaveWaitTimeout = TimeSpan.FromSeconds(10); private readonly IQuickNotePersistence _noteService; private readonly AppSettingsService _settingsService; private readonly DispatcherTimer _saveTimer; @@ -273,7 +274,11 @@ internal async Task SaveNowAsync(bool force = false) if (force) { - await _saveSemaphore.WaitAsync(); + if (!await _saveSemaphore.WaitAsync(ForcedSaveWaitTimeout)) + { + SetStatus(QuickNoteStatusKind.SaveFailed); + return false; + } } // If a timer save can't acquire the semaphore immediately, coalesce it with the current save. else if (!await _saveSemaphore.WaitAsync(0)) @@ -285,6 +290,12 @@ internal async Task SaveNowAsync(bool force = false) SetStatus(QuickNoteStatusKind.Saving); try { + if (!_hasPendingChanges) + { + UpdateStatusSaved(); + return true; + } + do { if (!_hasPendingChanges && !force) @@ -403,7 +414,7 @@ private async void BtnPin_Checked(object sender, RoutedEventArgs e) { s.QuickNotePinned = sender is System.Windows.Controls.Primitives.ToggleButton { IsChecked: true }; }); - await _settingsService.SaveAsync(); + await SaveSettingsSafelyAsync(); TxtNote.Focus(); } @@ -422,7 +433,18 @@ private void BtnMenu_Click(object sender, RoutedEventArgs e) private void BtnClear_Click(object sender, RoutedEventArgs e) { var dialog = new DarkDialog(LocalizationService.Get("QuickNote_ClearConfirm"), isConfirm: true) { Owner = this }; - if (dialog.ShowDialog() == true) + bool? result; + _isModalDialogOpen = true; + try + { + result = dialog.ShowDialog(); + } + finally + { + _isModalDialogOpen = false; + } + + if (result == true) { TxtNote.Document.Blocks.Clear(); TxtNote.Document.Blocks.Add(new Paragraph(new Run(string.Empty))); @@ -464,7 +486,7 @@ private void BtnOpenConflictCopy_Click(object sender, RoutedEventArgs e) private void BtnStrikethrough_Click(object sender, RoutedEventArgs e) => ToggleTextDecoration(TextDecorationLocation.Strikethrough); - private void BtnCode_Click(object sender, RoutedEventArgs e) => ToggleFormatting(TextElement.FontFamilyProperty, new System.Windows.Media.FontFamily("Consolas"), new System.Windows.Media.FontFamily("Segoe UI")); + private void BtnCode_Click(object sender, RoutedEventArgs e) => ToggleFormatting(TextElement.FontFamilyProperty, QuickNoteFonts.Code, QuickNoteFonts.Default); private void BtnBullet_Click(object sender, RoutedEventArgs e) => ApplyListFormatting(numbered: false); @@ -503,7 +525,7 @@ private void CmbHeading_SelectionChanged(object sender, SelectionChangedEventArg ApplyHeadingToSelectedLines(headingLevel, selection.Start, selection.End); } - ResetFormatCombo(comboBox, 0); + ResetFormatCombo(comboBox, -1); _preservedFormatSelection = null; } @@ -730,7 +752,7 @@ private void ApplyHeadingFormattingToRange(int startOffset, int endOffset, int h } var range = new TextRange(start, end); - range.ApplyPropertyValue(TextElement.FontFamilyProperty, new System.Windows.Media.FontFamily("Segoe UI")); + range.ApplyPropertyValue(TextElement.FontFamilyProperty, QuickNoteFonts.Default); range.ApplyPropertyValue(TextElement.FontSizeProperty, QuickNoteMarkdown.GetHeadingFontSizeForLevel(headingLevel)); range.ApplyPropertyValue(TextElement.FontWeightProperty, headingLevel == 0 ? FontWeights.Normal : FontWeights.SemiBold); range.ApplyPropertyValue(TextElement.FontStyleProperty, FontStyles.Normal); @@ -894,7 +916,7 @@ private void SetEditorPlainText(string text) var paragraph = new Paragraph { Margin = new Thickness(0), - FontFamily = new System.Windows.Media.FontFamily("Segoe UI"), + FontFamily = QuickNoteFonts.Default, FontSize = 14, FontWeight = FontWeights.Normal, FontStyle = FontStyles.Normal @@ -910,7 +932,7 @@ private void SetEditorPlainText(string text) paragraph.Inlines.Add(new Run(lines[i]) { - FontFamily = new System.Windows.Media.FontFamily("Segoe UI"), + FontFamily = QuickNoteFonts.Default, FontWeight = FontWeights.Normal, FontStyle = FontStyles.Normal }); @@ -939,19 +961,42 @@ private void ApplyRangeEdit(QuickNoteRangeEdit edit) } } - private void ClearSelectedFormatting() + internal void ClearSelectedFormatting() { - // Preserve original selection - var originalSelectionStart = TxtNote.Selection.Start; - var originalSelectionEnd = TxtNote.Selection.End; - - ClearSelectedTextMarkers(); - RemoveSelectedListFormatting(new TextRange(originalSelectionStart, originalSelectionEnd)); - - // Restore original selection before resetting formatting - TxtNote.Selection.Select(originalSelectionStart, originalSelectionEnd); - UnwrapHyperlinksInSelection(); - ResetSelectionFormatting(); + var (selectionStart, selectionEnd) = GetSelectionOffsets(); + + TxtNote.BeginChange(); + try + { + var markerEdit = ClearSelectedTextMarkers(selectionStart, selectionEnd); + selectionStart = markerEdit.Start; + selectionEnd = markerEdit.End; + if (markerEdit.Changed) + { + SelectEditorRange(selectionStart, selectionEnd); + } + + string textBeforeListUnwrap = GetEditorText(); + string selectedTextBeforeListUnwrap = QuickNoteDocumentHelper.RemoveVisualListMarkers(TxtNote.Selection.Text); + RemoveSelectedListFormatting(TxtNote.Selection); + string textAfterListUnwrap = GetEditorText(); + (selectionStart, selectionEnd) = QuickNoteDocumentHelper.RemapSelection( + textBeforeListUnwrap, + textAfterListUnwrap, + selectionStart, + selectionEnd, + selectedTextBeforeListUnwrap); + SelectEditorRange(selectionStart, selectionEnd); + UnwrapHyperlinksInSelection(); + SelectEditorRange(selectionStart, selectionEnd); + ResetSelectionFormatting(); + } + finally + { + TxtNote.EndChange(); + } + + SelectEditorRange(selectionStart, selectionEnd); MarkChangedAndScheduleSave(); ScheduleFooterStatsUpdate(); TxtNote.Focus(); @@ -961,50 +1006,53 @@ private void UnwrapHyperlinksInSelection() { TextPointer start = TxtNote.Selection.Start; TextPointer end = TxtNote.Selection.End; - + var hyperlinks = GetAllHyperlinks(TxtNote.Document.Blocks) + .Where(hyperlink => TextRangesIntersect(start, end, hyperlink.ContentStart, hyperlink.ContentEnd)) + .Select(hyperlink => + { + string text = QuickNoteDocumentHelper.NormalizeLineEndings( + new TextRange(hyperlink.ContentStart, hyperlink.ContentEnd).Text); + int selectionStart = start.CompareTo(hyperlink.ContentStart) <= 0 + ? 0 + : QuickNoteDocumentHelper.NormalizeLineEndings( + new TextRange(hyperlink.ContentStart, start).Text).Length; + int selectionEnd = end.CompareTo(hyperlink.ContentEnd) >= 0 + ? text.Length + : QuickNoteDocumentHelper.NormalizeLineEndings( + new TextRange(hyperlink.ContentStart, end).Text).Length; + return ( + Hyperlink: hyperlink, + Text: text, + Start: Math.Clamp(selectionStart, 0, text.Length), + End: Math.Clamp(selectionEnd, 0, text.Length)); + }) + .ToList(); + TxtNote.BeginChange(); try { - // Traverse backwards to avoid issues with modified collection - TextPointer? current = end; - while (current != null && current.CompareTo(start) > 0) + foreach (var item in hyperlinks.AsEnumerable().Reverse()) { - if (current.Parent is Hyperlink hyperlink) + Hyperlink hyperlink = item.Hyperlink; + InlineCollection? parentInlines = GetInlineSiblings(hyperlink); + if (parentInlines == null) { - // Check if hyperlink overlaps with selection - if (hyperlink.ContentStart.CompareTo(end) < 0 && hyperlink.ContentEnd.CompareTo(start) > 0) - { - // Unwrap hyperlink: move children to parent, remove hyperlink - InlineCollection? parentInlines = GetInlineSiblings(hyperlink); - if (parentInlines != null) - { - // Get all children first - var children = new List(); - Inline? child = hyperlink.Inlines.FirstInline; - while (child != null) - { - children.Add(child); - child = child.NextInline; - } - - // Insert children before hyperlink - foreach (var childInline in children) - { - hyperlink.Inlines.Remove(childInline); - parentInlines.InsertBefore(hyperlink, childInline); - } - - // Remove hyperlink - parentInlines.Remove(hyperlink); - } - } - // Move past hyperlink - current = hyperlink.ContentStart; + continue; } - else + + if (item.Start > 0 || item.End < item.Text.Length) { - current = current.GetNextContextPosition(LogicalDirection.Backward); + ReplaceHyperlinkWithFragments(parentInlines, hyperlink, item.Text, item.Start, item.End); + continue; } + + foreach (Inline child in hyperlink.Inlines.ToList()) + { + hyperlink.Inlines.Remove(child); + parentInlines.InsertBefore(hyperlink, child); + } + + parentInlines.Remove(hyperlink); } } finally @@ -1013,17 +1061,204 @@ private void UnwrapHyperlinksInSelection() } } - private void ClearSelectedTextMarkers() + private static void ReplaceHyperlinkWithFragments( + InlineCollection parentInlines, + Hyperlink source, + string text, + int selectionStart, + int selectionEnd) + { + if (selectionStart > 0) + { + parentInlines.InsertBefore(source, CreateHyperlinkFragment( + source, + CloneInlineRange(source.Inlines, 0, selectionStart))); + } + + if (selectionEnd > selectionStart) + { + foreach (Inline inline in CloneInlineRange(source.Inlines, selectionStart, selectionEnd)) + { + parentInlines.InsertBefore(source, inline); + } + } + + if (selectionEnd < text.Length) + { + parentInlines.InsertBefore(source, CreateHyperlinkFragment( + source, + CloneInlineRange(source.Inlines, selectionEnd, text.Length))); + } + + parentInlines.Remove(source); + } + + private static Hyperlink CreateHyperlinkFragment(Hyperlink source, IEnumerable inlines) + { + var fragment = new Hyperlink + { + NavigateUri = source.NavigateUri, + Tag = source.Tag, + Foreground = source.Foreground, + TextDecorations = source.TextDecorations?.Clone() + }; + + foreach (Inline inline in inlines) + { + fragment.Inlines.Add(inline); + } + + return fragment; + } + + private static IReadOnlyList CloneInlineRange(InlineCollection inlines, int start, int end) + { + var result = new List(); + int offset = 0; + foreach (Inline inline in inlines) + { + int length = GetInlineTextLength(inline); + int localStart = Math.Clamp(start - offset, 0, length); + int localEnd = Math.Clamp(end - offset, 0, length); + if (localEnd > localStart && CloneInlineRange(inline, localStart, localEnd) is { } clone) + { + result.Add(clone); + } + + offset += length; + if (offset >= end) + { + break; + } + } + + return result; + } + + private static Inline? CloneInlineRange(Inline inline, int start, int end) + { + if (inline is Run run) + { + return CloneRunWithText(run, run.Text[start..end]); + } + + if (inline is LineBreak) + { + return start == 0 && end > 0 ? new LineBreak() : null; + } + + if (inline is Span span) + { + Span clone = CloneSpanShell(span); + foreach (Inline child in CloneInlineRange(span.Inlines, start, end)) + { + clone.Inlines.Add(child); + } + + return clone.Inlines.Count > 0 ? clone : null; + } + + return null; + } + + private static Span CloneSpanShell(Span source) + { + Span clone = source switch + { + Bold => new Bold(), + Italic => new Italic(), + _ => new Span() + }; + clone.Tag = source.Tag; + clone.FontFamily = source.FontFamily; + clone.FontSize = source.FontSize; + clone.FontStretch = source.FontStretch; + clone.FontStyle = source.FontStyle; + clone.FontWeight = source.FontWeight; + clone.Foreground = source.Foreground; + clone.Background = source.Background; + clone.TextDecorations = source.TextDecorations?.Clone(); + return clone; + } + + private static int GetInlineTextLength(Inline inline) + { + if (inline is Run run) + { + return QuickNoteDocumentHelper.NormalizeLineEndings(run.Text).Length; + } + + if (inline is LineBreak) + { + return 1; + } + + return inline is Span span + ? span.Inlines.Sum(GetInlineTextLength) + : 0; + } + + private static IEnumerable GetAllHyperlinks(BlockCollection blocks) + { + foreach (Block block in blocks) + { + if (block is Paragraph paragraph) + { + foreach (Hyperlink hyperlink in GetAllHyperlinks(paragraph.Inlines)) + { + yield return hyperlink; + } + } + else if (block is FlowList list) + { + foreach (ListItem item in list.ListItems) + { + foreach (Hyperlink hyperlink in GetAllHyperlinks(item.Blocks)) + { + yield return hyperlink; + } + } + } + else if (block is Section section) + { + foreach (Hyperlink hyperlink in GetAllHyperlinks(section.Blocks)) + { + yield return hyperlink; + } + } + } + } + + private static IEnumerable GetAllHyperlinks(InlineCollection inlines) + { + foreach (Inline inline in inlines) + { + if (inline is Hyperlink hyperlink) + { + yield return hyperlink; + } + else if (inline is Span span) + { + foreach (Hyperlink child in GetAllHyperlinks(span.Inlines)) + { + yield return child; + } + } + } + } + + private (int Start, int End, bool Changed) ClearSelectedTextMarkers(int selectionStart, int selectionEnd) { - var (selectionStart, selectionEnd) = GetSelectionOffsets(); string text = GetEditorText(); QuickNoteRangeEdit edit = QuickNoteMarkdown.GetClearLineMarkerRangeEdit(text, selectionStart, selectionEnd); if (!(edit.RemoveLength == edit.InsertText.Length && string.Equals(text.Substring(edit.StartOffset, edit.RemoveLength), edit.InsertText, StringComparison.Ordinal))) { ApplyRangeEdit(edit); - // Don't set caret yet - we'll restore original selection later + return (edit.CaretOffset, edit.CaretOffset + edit.SelectionLength, true); } + + return (selectionStart, selectionEnd, false); } private void ResetSelectionFormatting() @@ -1032,7 +1267,7 @@ private void ResetSelectionFormatting() TxtNote.Selection.ApplyPropertyValue(TextElement.FontStyleProperty, FontStyles.Normal); TxtNote.Selection.ApplyPropertyValue(TextElement.FontSizeProperty, QuickNoteMarkdown.GetHeadingFontSizeForLevel(0)); TxtNote.Selection.ApplyPropertyValue(Inline.TextDecorationsProperty, null); - TxtNote.Selection.ApplyPropertyValue(TextElement.FontFamilyProperty, new System.Windows.Media.FontFamily("Segoe UI")); + TxtNote.Selection.ApplyPropertyValue(TextElement.FontFamilyProperty, QuickNoteFonts.Default); TxtNote.Selection.ApplyPropertyValue(TextElement.ForegroundProperty, Brush(_theme.Text)); } @@ -1222,7 +1457,7 @@ private void ResetCaretFormatting() TxtNote.Selection.ApplyPropertyValue(TextElement.FontStyleProperty, FontStyles.Normal); TxtNote.Selection.ApplyPropertyValue(TextElement.FontSizeProperty, QuickNoteMarkdown.GetHeadingFontSizeForLevel(0)); TxtNote.Selection.ApplyPropertyValue(Inline.TextDecorationsProperty, null); - TxtNote.Selection.ApplyPropertyValue(TextElement.FontFamilyProperty, new System.Windows.Media.FontFamily("Segoe UI")); + TxtNote.Selection.ApplyPropertyValue(TextElement.FontFamilyProperty, QuickNoteFonts.Default); TxtNote.Selection.ApplyPropertyValue(TextElement.ForegroundProperty, Brush(_theme.Text)); } @@ -1242,7 +1477,7 @@ public void ShowSimple(AppSettings settings) { EnsureDocumentLoadedForFirstPaint(); - var work = GetWorkArea(); + var work = GetWorkArea(settings); var bounds = QuickNoteLayoutHelper.ClampBoundsToWorkArea( work, settings.QuickNoteLeft, @@ -1314,21 +1549,24 @@ private bool IsTransientUiOpen() private void ResizeGrip_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { - if (sender is not FrameworkElement { Tag: string edge } || e.ButtonState != MouseButtonState.Pressed) + if (sender is not FrameworkElement { Tag: string edge } || + e.ButtonState != MouseButtonState.Pressed || + !QuickNoteResizeEdges.TryParse(edge, out QuickNoteResizeEdge resizeEdge)) { return; } - int direction = edge switch + int direction = resizeEdge switch { - "Left" => WMSZ_LEFT, - "Right" => WMSZ_RIGHT, - "Top" => WMSZ_TOP, - "TopLeft" => WMSZ_TOPLEFT, - "TopRight" => WMSZ_TOPRIGHT, - "Bottom" => WMSZ_BOTTOM, - "BottomLeft" => WMSZ_BOTTOMLEFT, - _ => WMSZ_BOTTOMRIGHT + QuickNoteResizeEdge.Left => WMSZ_LEFT, + QuickNoteResizeEdge.Right => WMSZ_RIGHT, + QuickNoteResizeEdge.Top => WMSZ_TOP, + QuickNoteResizeEdge.TopLeft => WMSZ_TOPLEFT, + QuickNoteResizeEdge.TopRight => WMSZ_TOPRIGHT, + QuickNoteResizeEdge.Bottom => WMSZ_BOTTOM, + QuickNoteResizeEdge.BottomLeft => WMSZ_BOTTOMLEFT, + QuickNoteResizeEdge.BottomRight => WMSZ_BOTTOMRIGHT, + _ => throw new ArgumentOutOfRangeException(nameof(resizeEdge)) }; var handle = new System.Windows.Interop.WindowInteropHelper(this).Handle; @@ -1364,7 +1602,7 @@ private void BuildThemePalette() ApplyTheme(theme); BuildThemePalette(); ThemePopup.IsOpen = false; - await _settingsService.SaveAsync(); + await SaveSettingsSafelyAsync(); }; ThemePalette.Children.Add(button); } @@ -1412,9 +1650,7 @@ private void ApplyTheme(QuickNoteTheme theme) Resources["QuickNoteHoverBrush"] = Brush(theme.IsDark ? "#303238" : "#14000000"); Resources["QuickNoteHoverForegroundBrush"] = text; FormatSeparator1.Fill = muted; - FormatSeparator2.Fill = muted; FormatSeparator1.Opacity = theme.IsDark ? 0.35 : 0.45; - FormatSeparator2.Opacity = theme.IsDark ? 0.35 : 0.45; _cachedTextBlocks ??= FindVisualChildren(this).ToList(); foreach (var textBlock in _cachedTextBlocks) @@ -1499,11 +1735,11 @@ private void ApplyInlineStyles(InlineCollection inlines, System.Windows.Media.Br } else if (inline is Span span) { - if (span.Tag?.ToString() == "code") + if (Equals(span.Tag, QuickNoteTags.Code)) { span.Background = codeBackground; span.Foreground = codeText; - span.FontFamily = new System.Windows.Media.FontFamily("Consolas"); + span.FontFamily = QuickNoteFonts.Code; } ApplyInlineStyles(span.Inlines, codeBackground, codeText, linkBrush); } @@ -1515,7 +1751,7 @@ private void ApplyInlineStyles(InlineCollection inlines, System.Windows.Media.Br { ApplyInlineStyles(italic.Inlines, codeBackground, codeText, linkBrush); } - else if (inline is Run run && run.FontFamily?.Source == "Consolas") + else if (inline is Run run && run.FontFamily?.Source == QuickNoteFonts.CodeFamilyName) { run.Background = codeBackground; run.Foreground = codeText; @@ -1549,9 +1785,27 @@ private static IEnumerable FindVisualChildren(DependencyObject parent) whe - private System.Drawing.Rectangle GetWorkArea() + private System.Drawing.Rectangle GetWorkArea(AppSettings? settings = null) { - // Находим экран, где сейчас находится окно или используем основной + if (settings != null && HasSavedBounds(settings)) + { + Forms.Screen? primary = Forms.Screen.PrimaryScreen; + var workAreas = Forms.Screen.AllScreens + .OrderByDescending(screen => ReferenceEquals(screen, primary)) + .Select(screen => screen.WorkingArea) + .ToList(); + System.Drawing.Rectangle selected = QuickNoteLayoutHelper.SelectWorkArea( + workAreas, + settings.QuickNoteLeft, + settings.QuickNoteTop, + settings.QuickNoteWidth, + settings.QuickNoteHeight); + if (!selected.IsEmpty) + { + return selected; + } + } + var currentScreen = Forms.Screen.FromHandle(new System.Windows.Interop.WindowInteropHelper(this).Handle); return currentScreen?.WorkingArea ?? Forms.Screen.PrimaryScreen?.WorkingArea ?? GetVirtualScreenFallback(); } @@ -1604,7 +1858,19 @@ private async Task SaveGeometryNowAsync() s.QuickNoteWidth = bounds.Width; s.QuickNoteHeight = bounds.Height; }); - await _settingsService.SaveAsync(); + await SaveSettingsSafelyAsync(); + } + + private async Task SaveSettingsSafelyAsync() + { + try + { + await _settingsService.SaveAsync(); + } + catch (Exception ex) + { + Logger.Log(ex); + } } private bool TryOpenUrlAtMouse(MouseButtonEventArgs e) @@ -1618,7 +1884,7 @@ private bool TryOpenUrlAtMouse(MouseButtonEventArgs e) try { string normalized = QuickNoteMarkdown.NormalizeLinkForOpen(link.Value.Link, link.Value.Type); - if (!IsValidLink(normalized, link.Value.Type)) + if (!QuickNoteMarkdown.IsSafeLinkForOpen(normalized, link.Value.Type)) { SetStatus(QuickNoteStatusKind.OpenFailed); return false; @@ -1635,22 +1901,6 @@ private bool TryOpenUrlAtMouse(MouseButtonEventArgs e) return true; } - private static bool IsValidLink(string link, QuickNoteMarkdown.LinkType type) - { - if (string.IsNullOrWhiteSpace(link)) - { - return false; - } - - return type switch - { - QuickNoteMarkdown.LinkType.Url => Uri.TryCreate(link, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps), - QuickNoteMarkdown.LinkType.Email => link.Contains('@') && link.Length > 3, - QuickNoteMarkdown.LinkType.Phone => link.Length > 3, - _ => false - }; - } - private (string Link, QuickNoteMarkdown.LinkType Type)? FindLinkAtMouse(System.Windows.Point position) { TextPointer? pointer = TxtNote.GetPositionFromPoint(position, true); @@ -1661,7 +1911,7 @@ private static bool IsValidLink(string link, QuickNoteMarkdown.LinkType type) if (FindHyperlink(pointer) is { } hyperlink) { - string url = GetHyperlinkUrl(hyperlink); + string url = QuickNoteMarkdown.GetHyperlinkUrl(hyperlink); if (!string.IsNullOrWhiteSpace(url)) { return (url, QuickNoteMarkdown.LinkType.Url); @@ -1763,16 +2013,6 @@ private static bool TryGetParagraphTextPosition(TextPointer pointer, out string return null; } - private static string GetHyperlinkUrl(Hyperlink hyperlink) - { - if (hyperlink.Tag is string tag && tag.StartsWith("link:", StringComparison.Ordinal)) - { - return tag["link:".Length..]; - } - - return hyperlink.NavigateUri?.ToString() ?? string.Empty; - } - private string GetEditorText() { string text = new TextRange(TxtNote.Document.ContentStart, TxtNote.Document.ContentEnd).Text; @@ -1806,7 +2046,7 @@ private void UpdateConflictMenuState() if (_cachedConflictCopyMenuItem is { } menuItem) { menuItem.IsEnabled = hasConflict; - menuItem.ToolTip = hasConflict ? _noteService.LastConflictCopyPath : null; + menuItem.ToolTip = hasConflict ? System.IO.Path.GetFileName(_noteService.LastConflictCopyPath) : null; } else { @@ -1814,7 +2054,7 @@ private void UpdateConflictMenuState() if (_cachedConflictCopyMenuItem is { } cachedItem) { cachedItem.IsEnabled = hasConflict; - cachedItem.ToolTip = hasConflict ? _noteService.LastConflictCopyPath : null; + cachedItem.ToolTip = hasConflict ? System.IO.Path.GetFileName(_noteService.LastConflictCopyPath) : null; } } } diff --git a/AiteBar/Resources/Strings.de.resx b/AiteBar/Resources/Strings.de.resx index 409464d..8462b52 100644 --- a/AiteBar/Resources/Strings.de.resx +++ b/AiteBar/Resources/Strings.de.resx @@ -130,6 +130,7 @@ Wählen Sie Kombinationen, die nicht von Windows oder einer anderen App verwende Nummerierte Liste Formatierung löschen Überschrift + Weitere Formatierung Fließtext Titel Untertitel diff --git a/AiteBar/Resources/Strings.resx b/AiteBar/Resources/Strings.resx index 8bee21b..51c9e37 100644 --- a/AiteBar/Resources/Strings.resx +++ b/AiteBar/Resources/Strings.resx @@ -219,6 +219,7 @@ Choose combinations that are not used by Windows or another app. Numbered list Clear formatting Heading + More formatting Body Text Title Subtitle diff --git a/AiteBar/Resources/Strings.ru.resx b/AiteBar/Resources/Strings.ru.resx index 1080473..62ba81e 100644 --- a/AiteBar/Resources/Strings.ru.resx +++ b/AiteBar/Resources/Strings.ru.resx @@ -499,6 +499,9 @@ Заголовок + + Дополнительное форматирование + Обычный текст diff --git a/AiteBar/Resources/Strings.uk.resx b/AiteBar/Resources/Strings.uk.resx index 6f28e38..824b6d4 100644 --- a/AiteBar/Resources/Strings.uk.resx +++ b/AiteBar/Resources/Strings.uk.resx @@ -67,6 +67,7 @@ Нумерований список Очистити форматування Заголовок + Більше форматування Звичайний текст Заголовок 1 Заголовок 2 diff --git a/QUICK_NOTE_RELIABILITY_EXECPLAN.md b/QUICK_NOTE_RELIABILITY_EXECPLAN.md new file mode 100644 index 0000000..fe53d0c --- /dev/null +++ b/QUICK_NOTE_RELIABILITY_EXECPLAN.md @@ -0,0 +1,236 @@ +# Make Quick Note formatting, persistence, and window behavior reliable + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +This plan is maintained in accordance with `PLANS.md` at the repository root. + +## Purpose / Big Picture + +After this work, Quick Note must preserve every combination of formatting exposed by its toolbar across save and reload, must not overwrite an unchanged note merely because the window closes, and must protect local and external edits with atomic writes and unique conflict copies. The unpinned window must remain open while its confirmation dialog is active, saved positions must reopen on the correct monitor, the toolbar must fit at every supported window width, and clearing formatting from part of a hyperlink must not remove the link from unselected text. + +The result is observable by using combinations such as bold plus italic or underline plus strikethrough, closing and reopening Quick Note, and seeing the same formatting without literal Markdown markers. It is also demonstrable through focused unit and WPF tests, a successful Release build, the full test suite, and a rebuilt installer. + +## Progress + +- [x] (2026-07-28) Audited the complete Quick Note implementation and reproduced nested-format loss and conflict-copy filename collisions. +- [x] (2026-07-28) Created this self-contained ExecPlan with acceptance criteria for all confirmed findings. +- [x] (2026-07-28) Implemented recursive inline Markdown parsing and round-trip coverage for nested toolbar formats. +- [x] (2026-07-28) Prevented unchanged close from rewriting the note and changed normal note writes to temporary-file replacement. +- [x] (2026-07-28) Added a missing-file baseline and collision-proof conflict-copy creation. +- [x] (2026-07-28) Protected modal clear confirmation and settings writes from unhandled asynchronous failures. +- [x] (2026-07-28) Preserved unselected hyperlink fragments during partial clear formatting. +- [x] (2026-07-28) Reopened saved geometry on the monitor containing the saved bounds, including negative coordinates. +- [x] (2026-07-28) Moved secondary formatting commands into a compact overflow menu so the toolbar fits supported widths. +- [x] (2026-07-28) Completed focused tests (91/91), Release build, full suite (927/927), and installer build. +- [x] (2026-07-28) Verified `AiteBar-Setup.exe` (77,593,769 bytes) and matching SHA-256 manifest. +- [x] (2026-07-28) Performed a final code review of the complete Quick Note boundary and fixed five additional persistence/round-trip/command-state issues. +- [x] (2026-07-28) Expanded focused coverage to 99 passing tests and completed a zero-warning Release build plus 935/935 full-suite tests. +- [x] (2026-07-28) Rebuilt the final installer after review and verified its SHA-256 manifest. +- [x] (2026-07-28) Triaged the attached external review against the actual click/save paths and applied the confirmed security and lifecycle hardening. +- [x] (2026-07-28) Bounded forced-save semaphore waiting, tightened `mailto:`/`tel:` validation, switched link regexes to the non-backtracking engine, hid absolute conflict paths, and removed duplicate hyperlink URL parsing. +- [x] (2026-07-28) Re-ran focused/full validation and rebuilt the clean-branch installer for the hardening follow-up. +- [x] (2026-07-28) Applied the safe cleanup from the second factual review: centralized tag/font contracts, reused one newline normalizer, and replaced resize string fallback with validated enum parsing. +- [x] (2026-07-28) Re-ran clean-branch validation and installer generation for the contract cleanup. + +## Surprises & Discoveries + +- Observation: The serializer can emit nested Markdown that the parser cannot reconstruct. + Evidence: Reflection against the built assembly produced `***both*** -> **\*both**\*`, `~~under-strike~~ -> \~\~under-strike\~\~`, and `[**bold link**](https://example.com) -> [\*\*bold link\*\*](https://example.com)`. + +- Observation: Conflict-copy names have one-second precision and are overwritten. + Evidence: Two immediate calls returned the same `QuickNote.conflict-20260727-103942.md` path; only the second content remained and the directory contained one conflict file. + +- Observation: The left toolbar requests about 471 pixels, while the default window provides about 420 pixels after chrome and right-side commands, and the minimum window provides about 300 pixels. + Evidence: The fixed widths and margins in `AiteBar/QuickNoteWindow.xaml` exceed the Grid column budget at both `Width="580"` and `MinWidth="460"`. + +- Observation: The first focused run exposed a second nested-format bug in the serializer: inherited styles were reopened around every child run. + Evidence: `**bold `code`**` initially serialized as `**bold ****`code`**`; moving Markdown delimiters to the owning inline container fixed both this case and triple-emphasis with code. + +- Observation: A failed test compilation left `testhost` PID 31852 holding the Release test assemblies. + Evidence: The process path pointed into this repository's `AiteBar.Tests\bin\Release` directory. Terminating only that PID released the files; subsequent focused and full runs completed normally. + +- Observation: Resetting the heading combo to its body-text item prevented choosing body text again after applying a heading. + Evidence: WPF does not raise `SelectionChanged` when the already-selected body item is chosen. Both formatting combos now return to command state (`SelectedIndex="-1"`) after every action. + +- Observation: Inline code nested with strikethrough was serialized with code as the outer delimiter, causing the strike markers to reload as literal code. + Evidence: The canonical order is now `~~`code`~~`; focused round-trip tests cover code+strike, underline+code+strike, and a formatted link. + +- Observation: Partial clear-formatting rebuilt linked prefix/suffix text from only the first `Run`, flattening nested bold, italic, or strike structure. + Evidence: Range cloning now preserves the complete inline tree for unselected linked fragments, while the selected fragment serializes without formatting or link syntax. + +- Observation: External edits could evade timestamp/length comparison if content length and timestamp were unchanged, and the latest conflict-copy path was forgotten after an application restart. + Evidence: The service now includes SHA-256 content identity in its baseline and discovers the newest conflict copy during note load; focused tests reproduce both cases. + +- Observation: Link insertion trimmed selected leading and trailing whitespace. + Evidence: Link display text is now preserved exactly while URL normalization continues to trim the URL field. + +- Observation: The attached review correctly identified unbounded forced-save waiting and weak defense-in-depth validation for raw email/phone links, but its command-injection path for Markdown `file:`/`javascript:` links was not reachable. + Evidence: Explicit Markdown hyperlinks are classified as URL links and already pass through the HTTP/HTTPS-only click validator before `Process.Start`. The shared validator is now stricter for every link type and has regression cases for rejected `file:`, `javascript:`, shell metacharacters, and traversal-like phone payloads. + +- Observation: `ConfigureAwait(false)` is not appropriate in `QuickNoteWindow.SaveNowAsync`. + Evidence: The continuation updates WPF controls and reads the UI-owned `FlowDocument`; retaining the dispatcher context is intentional. Reliability is instead provided by a bounded ten-second forced wait that leaves the window open on timeout. + +- Observation: The resize handler treated every unknown XAML `Tag` as `BottomRight`. + Evidence: The default switch arm returned `WMSZ_BOTTOMRIGHT`; the handler now accepts only the eight values in `QuickNoteResizeEdge` and ignores invalid tags. Tests cover all eight valid names plus empty, case-mismatched, unknown, and null values. + +- Observation: The second review's remaining God-object, synchronous-first-load, and persistence-adapter findings are architectural tradeoffs rather than demonstrated user defects. + Evidence: The adapter is the test seam used by WPF close/formatting tests, and loading before the first paint avoids presenting an empty note. Both changes would require separate UX/performance acceptance work. + +## Decision Log + +- Decision: Treat all eight confirmed audit findings plus direct non-atomic note writes as one reliability change. + Rationale: They interact through the same save/reload and window lifecycle. Fixing them together allows end-to-end tests that prove user data and selection behavior survive the complete workflow. + Date/Author: 2026-07-28 / Codex + +- Decision: Preserve the existing lightweight single-note architecture and Markdown file format. + Rationale: The request is corrective. It does not authorize a new editor engine, database, or persistent history model. + Date/Author: 2026-07-28 / Codex + +- Decision: Prefer small pure helpers for parsing, file naming, geometry selection, and toolbar layout contracts. + Rationale: These behaviors can be tested without relying exclusively on fragile interactive WPF automation. + Date/Author: 2026-07-28 / Codex + +- Decision: Keep the four most common inline commands visible and move undo, redo, underline, code, and clear formatting into a localized overflow menu. + Rationale: This preserves access to every command while fitting both the default and minimum window widths without increasing the lightweight window size. + Date/Author: 2026-07-28 / Codex + +- Decision: Compare external note content by hash in addition to existence, size, and timestamp. + Rationale: Avoiding a rare false negative is worth one sequential read before a pending save because Quick Note is a single local text file and data preservation is the priority. + Date/Author: 2026-07-28 / Codex + +- Decision: Apply targeted hardening from the external review without undertaking the proposed God-object rewrite. + Rationale: The safety and lifecycle improvements are independently testable. Splitting the WPF window into several services would be a separate high-risk refactor and is not required to correct the reported behavior. + Date/Author: 2026-07-28 / Codex + +## Outcomes & Retrospective + +Implementation is complete and the final review validation is green. Quick Note now retains nested formatting, avoids redundant close writes, writes atomically, detects file creation and same-metadata content edits after its baseline, creates and rediscovers unique conflict copies, safely handles settings failures and the clear dialog, preserves unselected rich hyperlink fragments, restores the correct monitor, and keeps every formatting command repeatable and reachable in the compact toolbar. + +Focused Quick Note tests pass 129/129. On the isolated branch created directly from `origin/master`, the Release solution build completes with zero warnings and zero errors, and the complete test suite passes 751/751. `installer/Build-Installer.ps1` rebuilt publish output and `artifacts/installer/AiteBar-Setup.exe`; code signing was skipped because no signing certificate was supplied. The contract-cleanup installer is 77,479,183 bytes and its SHA-256 is `B0BEA5C6D060714DC0749899729AE470E94F284B7315E90AACC7A8F304E07242`, matching `SHA256SUMS.txt`. + +A separate launch smoke test was not performed because an installed AiteBar instance (PID 27144) was already running and the application uses a single-instance workflow. That user process was deliberately left untouched; compilation, publish, installer generation, focused WPF tests, and the full suite provide the final automated evidence. + +## Context and Orientation + +`AiteBar/QuickNoteWindow.xaml` defines the lightweight always-on-top note window, its formatting toolbar, editor, theme popup, footer, and resize grips. `AiteBar/QuickNoteWindow.xaml.cs` owns lifecycle, focus behavior, autosave, formatting commands, link handling, theme application, geometry persistence, and status updates. + +`AiteBar/QuickNoteMarkdown.cs` converts between the WPF `FlowDocument` used by `RichTextBox` and the persisted `QuickNote.md` text. A round-trip means converting Markdown to a `FlowDocument` and then back to Markdown without losing the user-visible formatting or content. + +`AiteBar/QuickNoteService.cs` reads and writes the Markdown file, detects edits made by another process, creates conflict copies, and opens note files through the Windows shell. `AiteBar/QuickNotePersistence.cs` is the small interface used to isolate that service in window tests. + +`AiteBar/QuickNoteDocumentHelper.cs` contains text-offset and selection helpers. `AiteBar/QuickNoteLayoutHelper.cs` contains monitor-independent geometry math. `AiteBar/QuickNoteTheme.cs` defines themes, while `AiteBar/QuickNoteLinkDialog.xaml(.cs)` provides the modal link editor. + +Focused tests live in `AiteBar.Tests/QuickNoteMarkdownTests.cs`, `QuickNoteServiceTests.cs`, `QuickNoteDocumentHelperTests.cs`, `QuickNoteLayoutHelperTests.cs`, `QuickNoteWindowCloseTests.cs`, `QuickNoteWindowFormattingTests.cs`, and `QuickNoteFormattingControlsTests.cs`. + +The working tree already contains unrelated Text Processing changes and two unrelated ExecPlans. They belong to the user and must remain untouched. The Quick Note changes made immediately before this plan restore empty formatting combobox entries and make clear-formatting document edits atomic; this plan builds on those changes. + +## Plan of Work + +First, update `QuickNoteMarkdown` so inline containers recursively parse their content rather than creating a plain `Run`. The parser must preserve combinations of bold, italic, code, underline, strikethrough, and hyperlink content that the serializer can emit. Add explicit round-trip tests for nested combinations and formatted links. + +Second, update `QuickNoteService` with a real baseline state that distinguishes “not loaded yet” from “loaded and missing.” Use a temporary file in the note directory followed by an atomic replace or move so an interrupted save never truncates the existing note. Generate conflict-copy names with sub-second uniqueness and non-overwriting creation. Tests must cover external creation after a missing baseline, two immediate conflicts, and preservation of the original file when a write fails where practical. + +Third, change window close semantics so a forced close waits for pending/in-flight saves but does not serialize an unchanged document. Wrap settings saves used by pin, theme, and geometry handlers so exceptions are logged rather than escaping `async void` dispatcher callbacks. Set the modal-dialog guard around the clear confirmation just as the link dialog already does. + +Fourth, make partial hyperlink clearing split the hyperlink at the selected boundaries: text outside the selection retains its URL, while only selected text becomes plain and has formatting reset. Keep whole-link clearing behavior when the entire link is selected. Add WPF tests for a middle substring and full-link selection. + +Fifth, select the work area from saved coordinates before showing the window. Add a layout helper accepting available monitor rectangles and a saved point or bounds, with deterministic tests representing primary, secondary, removed, and negative-coordinate monitors. + +Sixth, make the header responsive without increasing the minimum or default window size. Keep primary formatting commands visible and place overflow commands in a compact menu or use adaptive visibility according to existing visual conventions. Add a XAML contract test that calculates or asserts the responsive structure at the minimum width. + +Finally, run the focused Quick Note tests, `dotnet build .\AiteBar.sln -c Release`, and `dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release`. If the documented WPF host issue occurs, run `dotnet vstest .\AiteBar.Tests\bin\Release\net10.0-windows\AiteBar.Tests.dll`. Rebuild the installer with `.\installer\Build-Installer.ps1` and verify `artifacts\installer\AiteBar-Setup.exe` plus its SHA-256 file. + +## Concrete Steps + +Run all commands from `D:\01_Codebdbd\01_projects\aitebar`. + +Inspect the scoped diff before editing: + + git diff -- AiteBar/QuickNote* AiteBar.Tests/QuickNote* + +Run focused tests during implementation: + + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release --filter "FullyQualifiedName~QuickNote" + +Run final validation: + + dotnet build .\AiteBar.sln -c Release + dotnet test .\AiteBar.Tests\AiteBar.Tests.csproj -c Release + +Use the documented fallback if required: + + dotnet vstest .\AiteBar.Tests\bin\Release\net10.0-windows\AiteBar.Tests.dll + +Build and inspect the installer: + + .\installer\Build-Installer.ps1 + Get-Item .\artifacts\installer\AiteBar-Setup.exe + Get-FileHash .\artifacts\installer\AiteBar-Setup.exe -Algorithm SHA256 + +## Validation and Acceptance + +The change is accepted when all of the following observable behaviors are true. + +Applying any combination of toolbar formats, saving, closing, and reopening yields the same visible combination without literal Markdown syntax. A Markdown-formatted link retains both its URL and nested bold/italic/code/strike styling. + +Closing an unchanged note performs no content write. Creating `QuickNote.md` externally after the window opened with no file produces a conflict copy instead of overwriting the external content. Two conflict copies created immediately have different paths and both contents survive. Normal note saves use a temporary file and leave either the old complete file or the new complete file. + +With Quick Note unpinned, opening the clear confirmation leaves the note and dialog open until the user confirms or cancels. A settings write failure is logged and does not terminate the UI dispatcher. + +Clearing formatting from the middle of a hyperlink keeps the prefix and suffix linked. Clearing the entire hyperlink removes the link. Selected text and caret remain stable. + +Saved coordinates on a secondary or negative-coordinate monitor reopen on that monitor. If that monitor is gone, bounds clamp to a remaining work area. + +At widths 580 and 460, toolbar controls do not overlap pin, menu, or close buttons and all commands remain reachable. + +Focused tests, Release build, the full suite or approved fallback, and installer build all complete successfully. + +## Idempotence and Recovery + +All edits are source changes and tests; rerunning builds and tests is safe. Atomic note writes must create uniquely named temporary files in the destination directory and delete abandoned temporary files in a `finally` block. Conflict-copy creation must never overwrite an existing copy. + +Do not reset or discard unrelated working-tree changes. If a WPF test leaves a process running, terminate only the test process identified by that run. Installer generation may overwrite files only under the repository’s documented `artifacts/publish/win-x64` and `artifacts/installer` directories. + +## Artifacts and Notes + +Initial audit evidence: + + Nested formatting: + INPUT=***both*** + ROUNDTRIP=**\*both**\* + + Conflict collision: + SAME_PATH=True + FINAL_CONTENT=second local version + CONFLICT_FILE_COUNT=1 + + Toolbar budget: + LeftToolbarDesired=471 + DefaultLeftColumnBudget~=420 + MinWindowLeftColumnBudget~=300 + +All 47 Quick Note localization keys exist and are non-empty in `Strings.resx`, `Strings.de.resx`, `Strings.uk.resx`, and `Strings.ru.resx`. + +## Interfaces and Dependencies + +Keep `IQuickNotePersistence` as the window boundary. It may gain a method only if a window-level behavior cannot be expressed safely through the current methods. + +`QuickNoteMarkdown.LoadMarkdown(FlowDocument, string)` and `QuickNoteMarkdown.ToMarkdown(FlowDocument)` remain the public conversion entry points. New parser helpers should stay internal or private and use existing WPF inline types. + +`QuickNoteService.NotePath`, `ReadMarkdown`, `ReadMarkdownAsync`, `Load`, `LoadAsync`, `SaveAsync`, `SaveConflictCopyAsync`, `HasExternalChanges`, `OpenInEditor`, and `OpenConflictCopy` must remain source-compatible. + +Use only .NET and existing WPF APIs. Do not add a Markdown package or a persistence dependency for this corrective change. + +Revision note (2026-07-28): Initial plan created from the completed Quick Note audit so implementation can proceed without relying on chat history. + +Revision note (2026-07-28): Updated after implementation and automated verification; documented the serializer finding, targeted stale-testhost recovery, toolbar decision, and passing test counts. + +Revision note (2026-07-28): Closed the plan after successful installer generation and checksum verification. + +Revision note (2026-07-28): Reopened for the requested final code review; recorded five additional findings, their fixes, and expanded passing test evidence. + +Revision note (2026-07-28): Closed the final review after rebuilding the installer and verifying its manifest; documented why the already-running installed instance was not disturbed for a second smoke launch. + +Revision note (2026-07-28): Reopened to triage the attached third-party review; recorded which findings were reachable, applied targeted hardening, and added security/lifecycle regression tests. + +Revision note (2026-07-28): Applied the low-risk contract cleanup from the factual re-review and documented the architectural items intentionally left outside this corrective release.