From 4320149a1b78643d86a394ecc8daef81af7d2b6b Mon Sep 17 00:00:00 2001 From: "ADA (OpenClaw Agent)" Date: Wed, 2 Sep 2026 13:15:50 +0200 Subject: [PATCH] feat: add safe HTML export for version 1.3 --- .github/workflows/build-and-release.yml | 5 +- AboutWindow.xaml | 2 +- CHANGELOG.md | 7 + CLAUDE.md | 7 +- MainWindow.xaml | 3 + Models/Models.cs | 1 + OneNoteExporter.csproj | 6 +- OneNoteExporter.sln | 6 + Properties/AssemblyInfo.cs | 3 + README.md | 21 +- Services/OneNoteComWorker.cs | 152 +++- Services/OneNoteService.cs | 762 +++++++++++++++++- innosetup.iss | 2 +- release.sh | 2 +- .../OneNoteExporter.Tests/HtmlExportTests.cs | 145 ++++ .../OneNoteExporter.Tests.csproj | 24 + 16 files changed, 1091 insertions(+), 57 deletions(-) create mode 100644 Properties/AssemblyInfo.cs create mode 100644 tests/OneNoteExporter.Tests/HtmlExportTests.cs create mode 100644 tests/OneNoteExporter.Tests/OneNoteExporter.Tests.csproj diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 3611921..90c865f 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -58,7 +58,10 @@ jobs: "number=$version" >> $env:GITHUB_OUTPUT Write-Host "Building version $version" - - name: Restore dependencies + - name: Run tests + run: dotnet test tests\OneNoteExporter.Tests\OneNoteExporter.Tests.csproj --configuration Release + + - name: Restore win-x64 dependencies run: dotnet restore OneNoteExporter.csproj --runtime win-x64 - name: Publish self-contained application diff --git a/AboutWindow.xaml b/AboutWindow.xaml index 04ae14a..656ea95 100644 --- a/AboutWindow.xaml +++ b/AboutWindow.xaml @@ -65,7 +65,7 @@ - ___html` folder, because OneNote emits `.htm` pages plus + companion asset folders. ### Export Formats | UI value | PublishFormat | Extension | @@ -101,6 +105,7 @@ in the project, installer metadata, Git tag, GitHub release title, and About dia | `"onepkg"` | `pfOneNotePackage` | `.onepkg` | | `"xps"` | `pfXPS` | `.xps` | | `"pdf"` | `pfPDF` | `.pdf` | +| `"html"` | `pfHTML` page jobs | `___html/index.html` | | `"localbackup"` | n/a (file copy) | n/a | ### Local Backup Mode diff --git a/MainWindow.xaml b/MainWindow.xaml index 0210c67..8b48ac7 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -328,6 +328,9 @@ PDF Document (.pdf) – Universal, lower quality + + HTML Web Pages – Browser-readable folder with index + Local Backup Copy – Fast, direct file copy diff --git a/Models/Models.cs b/Models/Models.cs index 8bd727a..a32bdf2 100644 --- a/Models/Models.cs +++ b/Models/Models.cs @@ -21,6 +21,7 @@ public class SectionInfo public string NotebookName { get; set; } = ""; public string NotebookPath { get; set; } = ""; public string GroupName { get; set; } = ""; // empty when section is directly in notebook + public string GroupExportPath { get; set; } = ""; // collision-safe HTML group path public string ExportFileStem { get; set; } = ""; // collision-safe; UI continues to show Name public bool IsCloud { get; set; } } diff --git a/OneNoteExporter.csproj b/OneNoteExporter.csproj index 79102cb..48f0286 100644 --- a/OneNoteExporter.csproj +++ b/OneNoteExporter.csproj @@ -12,7 +12,7 @@ OneNoteExporter OneNoteExporter - 1.2.2 + 1.3.0 icon.ico @@ -25,4 +25,8 @@ + + + + diff --git a/OneNoteExporter.sln b/OneNoteExporter.sln index 3e971c2..0e034ef 100644 --- a/OneNoteExporter.sln +++ b/OneNoteExporter.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.8.34316.72 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneNoteExporter", "OneNoteExporter.csproj", "{3F2504E0-4F89-41D3-9A0C-0305E82C3301}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneNoteExporter.Tests", "tests\OneNoteExporter.Tests\OneNoteExporter.Tests.csproj", "{C149C964-3A10-4B2C-B41F-A0A53D77D6FD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x86 = Debug|x86 @@ -15,6 +17,10 @@ Global {3F2504E0-4F89-41D3-9A0C-0305E82C3301}.Debug|x86.Build.0 = Debug|x86 {3F2504E0-4F89-41D3-9A0C-0305E82C3301}.Release|x86.ActiveCfg = Release|x86 {3F2504E0-4F89-41D3-9A0C-0305E82C3301}.Release|x86.Build.0 = Release|x86 + {C149C964-3A10-4B2C-B41F-A0A53D77D6FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {C149C964-3A10-4B2C-B41F-A0A53D77D6FD}.Debug|x86.Build.0 = Debug|Any CPU + {C149C964-3A10-4B2C-B41F-A0A53D77D6FD}.Release|x86.ActiveCfg = Release|Any CPU + {C149C964-3A10-4B2C-B41F-A0A53D77D6FD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..36b895a --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OneNoteExporter.Tests")] diff --git a/README.md b/README.md index d050040..f82e793 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A Windows desktop application to export OneNote notebooks to portable formats. Talks directly to OneNote Desktop via the COM API. -![version](https://img.shields.io/badge/version-1.2.2-blueviolet?style=flat-square) +![version](https://img.shields.io/badge/version-1.3.0-blueviolet?style=flat-square) ![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-0078D7?style=flat-square&logo=windows&logoColor=white) ![license](https://img.shields.io/badge/license-MIT-brightgreen?style=flat-square) ![built with](https://img.shields.io/badge/built%20with-.NET%2010%20%2F%20WPF-512BD4?style=flat-square&logo=dotnet&logoColor=white) @@ -33,14 +33,14 @@ About dialog. ## Automated builds and releases -GitHub Actions builds a self-contained Windows x64 application and compiles the Inno Setup -installer for every pull request and every push to `main`. The installer and its SHA-256 -checksum are available as workflow artifacts for 14 days. +GitHub Actions runs the automated tests, builds a self-contained Windows x64 application, +and compiles the Inno Setup installer for every pull request and every push to `main`. The +installer and its SHA-256 checksum are available as workflow artifacts for 14 days. To publish a release: 1. Update `` in `OneNoteExporter.csproj` and commit the change. -2. Create and push a matching tag, for example `v1.2.2`. +2. Create and push a matching tag, for example `v1.3.0`. 3. GitHub Actions builds the installer, creates the GitHub release with generated notes, and uploads the installer plus checksum automatically. @@ -55,11 +55,10 @@ The Microsoft Store version of OneNote is not supported. ## Export Formats -![Available export formats](FormatDemoBild.png) - | Format | Notes | |---|---| | OneNote Package (.onepkg) | Recommended. Full-fidelity, re-importable into OneNote. | +| HTML Web Pages | Browser-readable page folders plus generated notebook/section indexes. OneNote writes each page as `.htm` with any companion assets beside it. | | XPS Document (.xps) | Good layout preservation. Large whole notebooks can be split into safer per-section exports. | | PDF Document (.pdf) | Universal, slightly lower quality on complex pages. Large whole notebooks can be split into safer per-section exports. | | Local Backup Copy | Copies the raw backup folder from `%LOCALAPPDATA%\Microsoft\OneNote\16.0\Sicherung`. Fastest option, all notebooks at once. | @@ -68,6 +67,14 @@ The Microsoft Store version of OneNote is not supported. - Password-protected sections cannot be exported. Unlock them in OneNote first. - Cloud notebooks (OneDrive, SharePoint) must be synced before export. Large notebooks can take several minutes. +- HTML exports create a `___html` folder with an `index.html` start + page. The stable suffix prevents same-named notebooks from overwriting each other. Complete + notebook exports link directly to every page; selected-section exports create or refresh + the notebook start page with links to the available section indexes. Each OneNote page is + published separately so notebook and section selections work even though OneNote's COM + API only provides reliable HTML publishing at page level. Section groups remain folders; + OneNote subpage levels are currently listed as normal pages. HTML is intended for offline + browser access and is not a re-importable replacement for `.onepkg`. - OneNote does not expose notebook size through its COM interface. Before a whole-notebook PDF/XPS export, the app therefore offers to export each section separately (recommended), create a complete `.onepkg` backup, or consciously try the whole rendered export. Split diff --git a/Services/OneNoteComWorker.cs b/Services/OneNoteComWorker.cs index 23c911d..7e91443 100644 --- a/Services/OneNoteComWorker.cs +++ b/Services/OneNoteComWorker.cs @@ -70,14 +70,22 @@ public Task ExportNotebookAsync( string exportFormat = "onepkg", IProgress? progress = null, CancellationToken ct = default) - => RunSerializedAsync( - () => ExportAsync( - service => service.BeginNotebookExport( - notebookId, destinationPath, exportFormat, progress), - exportFormat, - progress, - ct), - ct); + => exportFormat.Equals("html", StringComparison.OrdinalIgnoreCase) + ? RunSerializedAsync( + () => ExportHtmlAsync( + service => service.BeginNotebookHtmlExport( + notebookId, destinationPath, progress), + progress, + ct), + ct) + : RunSerializedAsync( + () => ExportAsync( + service => service.BeginNotebookExport( + notebookId, destinationPath, exportFormat, progress), + exportFormat, + progress, + ct), + ct); public Task ExportSectionAsync( SectionInfo section, @@ -85,14 +93,126 @@ public Task ExportSectionAsync( string exportFormat = "onepkg", IProgress? progress = null, CancellationToken ct = default) - => RunSerializedAsync( - () => ExportAsync( - service => service.BeginSectionExport( - section, destinationPath, exportFormat, progress), - exportFormat, - progress, - ct), - ct); + => exportFormat.Equals("html", StringComparison.OrdinalIgnoreCase) + ? RunSerializedAsync( + () => ExportHtmlAsync( + service => service.BeginSectionHtmlExport( + section, destinationPath, progress), + progress, + ct), + ct) + : RunSerializedAsync( + () => ExportAsync( + service => service.BeginSectionExport( + section, destinationPath, exportFormat, progress), + exportFormat, + progress, + ct), + ct); + + private async Task ExportHtmlAsync( + Func beginExport, + IProgress? progress, + CancellationToken ct) + { + bool connectionRecoveryAttempted = false; + + for (int attempt = 1; attempt <= MaxExportAttempts; attempt++) + { + try + { + HtmlExportPlan plan = await InvokeAsync(beginExport, ct).ConfigureAwait(false); + + for (int pageIndex = 0; pageIndex < plan.Pages.Count; pageIndex++) + { + ct.ThrowIfCancellationRequested(); + HtmlPageExport page = plan.Pages[pageIndex]; + progress?.Report( + $"HTML page {pageIndex + 1}/{plan.Pages.Count}: {page.Name}"); + + await InvokeAsync( + service => service.BeginHtmlPageExport(plan, page, progress), + ct) + .ConfigureAwait(false); + ExportResult pageResult = await OneNoteService.WaitForHtmlPageAsync( + page, plan.IsCloud, progress, ct) + .ConfigureAwait(false); + + if (!pageResult.Success) + { + pageResult.Message += + " The existing HTML export was not changed. A temporary " + + "staging directory may be retained until automatic cleanup."; + return pageResult; + } + } + + progress?.Report("Building the HTML index and finalizing the export..."); + ct.ThrowIfCancellationRequested(); + var result = new ExportResult + { + Success = true, + Message = $"Exported {plan.Pages.Count} HTML page(s) successfully." + }; + ExportResult finalResult = OneNoteService.FinalizeHtmlExport(plan, result); + finalResult.AttemptCount = attempt; + finalResult.RecoveredAfterRetry = attempt > 1; + + if (finalResult.RecoveredAfterRetry) + { + finalResult.Message += + $" Export succeeded after OneNote communication recovery " + + $"(attempt {attempt}/{MaxExportAttempts})."; + } + + return finalResult; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when ( + attempt < MaxExportAttempts && + OneNoteService.IsRecoverableComFailure(ex)) + { + string failure = OneNoteService.DescribeComFailure(ex); + bool resetConnection = OneNoteService.RequiresComConnectionReset(ex); + + progress?.Report( + $"OneNote communication failed: {failure}. " + + $"Automatic retry {attempt + 1}/{MaxExportAttempts} in " + + $"{ExportRetryDelay.TotalSeconds:0} seconds..."); + await Task.Delay(ExportRetryDelay, ct).ConfigureAwait(false); + + if (resetConnection) + { + progress?.Report("Reconnecting to OneNote Desktop..."); + + try + { + await RecreateServiceAsync(ct).ConfigureAwait(false); + connectionRecoveryAttempted = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception recoveryException) + { + return OneNoteService.CreateConnectionRecoveryFailure( + ex, recoveryException); + } + } + } + catch (Exception ex) + { + return OneNoteService.CreateExportFailure( + ex, attempt, "html", connectionRecoveryAttempted); + } + } + + throw new InvalidOperationException("The HTML export retry loop ended unexpectedly."); + } private async Task ExportAsync( Func beginExport, diff --git a/Services/OneNoteService.cs b/Services/OneNoteService.cs index 25fa893..dadf761 100644 --- a/Services/OneNoteService.cs +++ b/Services/OneNoteService.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -14,6 +15,26 @@ internal sealed record PendingExport( string FinalPath, bool IsCloud); +internal sealed record HtmlPageExport( + string Id, + string Name, + string SectionPath, + string RelativeDirectory, + string TargetPath); + +internal sealed record HtmlExportPlan( + string Title, + string StagingDirectory, + string FinalDirectory, + string CollectionDirectory, + string CollectionTitle, + bool IsCloud, + IReadOnlyList Pages); + +internal sealed record ExportFormatInfo( + string Extension, + PublishFormat PublishFormat); + /// /// Synchronous OneNote COM API implementation owned by . /// Construction, all calls and disposal must stay on that worker's STA thread. @@ -119,19 +140,9 @@ public PendingExport BeginNotebookExport( progress?.Report($"Exporting: {notebookName} (format: {exportFormat})"); - string fileExtension = exportFormat.ToLowerInvariant() switch - { - "xps" => ".xps", - "pdf" => ".pdf", - _ => ".onepkg" - }; - - PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch - { - "xps" => PublishFormat.pfXPS, - "pdf" => PublishFormat.pfPDF, - _ => PublishFormat.pfOneNotePackage - }; + ExportFormatInfo formatInfo = ResolveSingleFileFormat(exportFormat); + string fileExtension = formatInfo.Extension; + PublishFormat publishFormat = formatInfo.PublishFormat; var sanitizedName = string.Join("_", notebookName.Split(Path.GetInvalidFileNameChars())); var finalPath = Path.Combine(destinationPath, sanitizedName + fileExtension); @@ -221,6 +232,98 @@ private static void CleanupStaleStagingFiles( } } + private static void RecoverInterruptedHtmlSwap( + string parentDirectory, + string finalName, + string finalDirectory) + { + string pattern = $".{finalName}.*.previous"; + + try + { + var previousDirectories = Directory + .EnumerateDirectories(parentDirectory, pattern) + .OrderByDescending(Directory.GetLastWriteTimeUtc) + .ToList(); + + if (!Directory.Exists(finalDirectory) && previousDirectories.Count > 0) + { + Directory.Move(previousDirectories[0], finalDirectory); + previousDirectories.RemoveAt(0); + } + + if (Directory.Exists(finalDirectory)) + { + foreach (string previousDirectory in previousDirectories) + TryDeleteDirectory(previousDirectory); + } + } + catch (IOException) + { + // Recovery is best-effort; finalization will report any real collision. + } + catch (UnauthorizedAccessException) + { + // The export itself will provide the actionable permission error. + } + } + + private static void CleanupStaleHtmlDirectories( + string parentDirectory, + string finalName, + DateTime olderThanUtc) + { + foreach (string suffix in new[] { "partial", "previous" }) + { + string pattern = $".{finalName}.*.{suffix}"; + + try + { + foreach (string directory in Directory.EnumerateDirectories(parentDirectory, pattern)) + { + try + { + if (Directory.GetLastWriteTimeUtc(directory) < olderThanUtc) + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // Active or locked output must never be disturbed. + } + catch (UnauthorizedAccessException) + { + // Cleanup is best-effort and must not prevent an export. + } + } + } + catch (IOException) + { + // Directory enumeration is best-effort. + } + catch (UnauthorizedAccessException) + { + // The export itself will provide the actionable permission error. + } + } + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // A later export can clean up the retained transaction directory. + } + catch (UnauthorizedAccessException) + { + // A later export can clean up the retained transaction directory. + } + } + /// /// Returns all exportable sections in a notebook (including those inside section groups). /// Skips encrypted and recycle-bin entries. Called by OneNoteComWorker on its STA thread. @@ -246,7 +349,8 @@ public List GetSections(string notebookId) var ns = xdoc.Root?.Name.Namespace; if (ns == null) return sections; - CollectSections(xdoc.Root!, ns, notebookId, nbName, nbPath, isCloud, "", sections); + CollectSections( + xdoc.Root!, ns, notebookId, nbName, nbPath, isCloud, "", "", sections); AssignCollisionSafeExportFileStems(sections); return sections; } @@ -266,19 +370,9 @@ public PendingExport BeginSectionExport( progress?.Report($"Exporting section: {section.Name} (format: {exportFormat})"); - string fileExtension = exportFormat.ToLowerInvariant() switch - { - "xps" => ".xps", - "pdf" => ".pdf", - _ => ".onepkg" - }; - - PublishFormat publishFormat = exportFormat.ToLowerInvariant() switch - { - "xps" => PublishFormat.pfXPS, - "pdf" => PublishFormat.pfPDF, - _ => PublishFormat.pfOneNotePackage - }; + ExportFormatInfo formatInfo = ResolveSingleFileFormat(exportFormat); + string fileExtension = formatInfo.Extension; + PublishFormat publishFormat = formatInfo.PublishFormat; // Preserve section-group folders and use the collision-safe stem assigned // while loading the full hierarchy, so split exports never overwrite peers. @@ -313,6 +407,234 @@ public PendingExport BeginSectionExport( return new PendingExport(stagingPath, finalPath, section.IsCloud); } + public HtmlExportPlan BeginNotebookHtmlExport( + string notebookId, + string destinationPath, + IProgress? progress = null) + { + if (_oneNote == null) + throw new InvalidOperationException("OneNote is not initialized."); + + _oneNote.GetHierarchy(notebookId, HierarchyScope.hsPages, out string xml); + var document = XDocument.Parse(xml); + var ns = document.Root?.Name.Namespace + ?? throw new InvalidOperationException("OneNote returned an empty hierarchy."); + var notebook = document.Root! + .DescendantsAndSelf(ns + "Notebook") + .FirstOrDefault(element => element.Attribute("ID")?.Value == notebookId) + ?? document.Root!.DescendantsAndSelf(ns + "Notebook").FirstOrDefault() + ?? throw new InvalidOperationException("The selected notebook could not be found."); + + string notebookName = notebook.Attribute("name")?.Value ?? "Notebook"; + string notebookPath = notebook.Attribute("path")?.Value ?? ""; + bool isCloud = IsCloudPath(notebookPath); + string notebookStem = + $"{SanitizeHtmlPathSegment(notebookName, "Notebook")}" + + $"__{CreateStableSuffix(notebookId)}"; + string finalDirectory = Path.Combine( + destinationPath, $"{notebookStem}_html"); + string stagingDirectory = PrepareHtmlStagingDirectory(finalDirectory); + + progress?.Report($"Preparing browser-readable HTML pages for {notebookName}..."); + var pages = CollectNotebookHtmlPages(notebook, ns, stagingDirectory); + if (pages.Count == 0) + throw new InvalidOperationException("The notebook does not contain exportable pages."); + + return new HtmlExportPlan( + notebookName, + stagingDirectory, + finalDirectory, + finalDirectory, + notebookName, + isCloud, + pages); + } + + public HtmlExportPlan BeginSectionHtmlExport( + SectionInfo section, + string destinationPath, + IProgress? progress = null) + { + if (_oneNote == null) + throw new InvalidOperationException("OneNote is not initialized."); + + _oneNote.GetHierarchy(section.Id, HierarchyScope.hsPages, out string xml); + var document = XDocument.Parse(xml); + var ns = document.Root?.Name.Namespace + ?? throw new InvalidOperationException("OneNote returned an empty hierarchy."); + var sectionElement = document.Root! + .DescendantsAndSelf(ns + "Section") + .FirstOrDefault(element => element.Attribute("ID")?.Value == section.Id) + ?? document.Root!.DescendantsAndSelf(ns + "Section").FirstOrDefault() + ?? throw new InvalidOperationException("The selected section could not be found."); + + string notebookStem = + $"{SanitizeHtmlPathSegment(section.NotebookName, "Notebook")}" + + $"__{CreateStableSuffix(section.NotebookId)}"; + string notebookRoot = Path.Combine(destinationPath, $"{notebookStem}_html"); + string sectionParent = BuildHtmlGroupDirectory( + notebookRoot, section.GroupName, section.GroupExportPath); + string sectionStem = $"{SanitizeHtmlPathSegment(section.Name, "Section")}" + + $"__{CreateStableSuffix(section.Id)}"; + string finalDirectory = Path.Combine(sectionParent, sectionStem); + string stagingDirectory = PrepareHtmlStagingDirectory(finalDirectory); + + progress?.Report($"Preparing browser-readable HTML pages for {section.Name}..."); + var pages = CollectSectionHtmlPages( + sectionElement, ns, stagingDirectory, "", section.Name); + if (pages.Count == 0) + throw new InvalidOperationException("The section does not contain exportable pages."); + + return new HtmlExportPlan( + section.Name, + stagingDirectory, + finalDirectory, + notebookRoot, + section.NotebookName, + section.IsCloud, + pages); + } + + public PendingExport BeginHtmlPageExport( + HtmlExportPlan plan, + HtmlPageExport page, + IProgress? progress = null) + { + if (_oneNote == null) + throw new InvalidOperationException("OneNote is not initialized."); + + string pageDirectory = Path.GetDirectoryName(page.TargetPath) + ?? throw new InvalidOperationException("The HTML page path is invalid."); + Directory.CreateDirectory(pageDirectory); + progress?.Report($"Publishing HTML page: {page.Name}"); + _oneNote.Publish(page.Id, page.TargetPath, PublishFormat.pfHTML, ""); + + return new PendingExport(page.TargetPath, page.TargetPath, plan.IsCloud); + } + + private static List CollectNotebookHtmlPages( + XElement notebook, + XNamespace ns, + string stagingDirectory) + { + var pages = new List(); + var usedSectionPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + Walk(notebook, "", ""); + return pages; + + void Walk(XElement parent, string relativeGroupPath, string displayGroupPath) + { + foreach (var child in parent.Elements()) + { + if (child.Name == ns + "SectionGroup") + { + if (child.Attribute("isRecycleBin")?.Value == "true") continue; + + string groupName = child.Attribute("name")?.Value ?? "Group"; + string groupId = child.Attribute("ID")?.Value ?? + $"{displayGroupPath}/{groupName}"; + string sanitizedGroup = + $"{SanitizeHtmlPathSegment(groupName, "Group")}" + + $"__{CreateStableSuffix(groupId)}"; + Walk( + child, + Path.Combine(relativeGroupPath, sanitizedGroup), + string.IsNullOrEmpty(displayGroupPath) + ? groupName + : $"{displayGroupPath} / {groupName}"); + } + else if (child.Name == ns + "Section") + { + if (child.Attribute("encrypted")?.Value == "true") continue; + + string sectionName = child.Attribute("name")?.Value ?? "Section"; + string sectionId = child.Attribute("ID")?.Value ?? sectionName; + string sectionStem = + $"{SanitizeHtmlPathSegment(sectionName, "Section")}" + + $"__{CreateStableSuffix(sectionId)}"; + string sectionPath = Path.Combine( + relativeGroupPath, + sectionStem); + sectionPath = MakeCollisionSafePath( + sectionPath, sectionId, usedSectionPaths); + string displayPath = string.IsNullOrEmpty(displayGroupPath) + ? sectionName + : $"{displayGroupPath} / {sectionName}"; + pages.AddRange(CollectSectionHtmlPages( + child, ns, stagingDirectory, sectionPath, displayPath)); + } + } + } + } + + private static List CollectSectionHtmlPages( + XElement section, + XNamespace ns, + string stagingDirectory, + string sectionRelativePath, + string sectionDisplayPath) + { + var pages = new List(); + var usedPagePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var page in section.Elements(ns + "Page")) + { + string pageId = page.Attribute("ID")?.Value ?? ""; + if (string.IsNullOrEmpty(pageId)) continue; + + string pageName = page.Attribute("name")?.Value ?? "Untitled Page"; + string relativeDirectory = Path.Combine( + sectionRelativePath, + SanitizeHtmlPathSegment(pageName, "Untitled Page")); + relativeDirectory = MakeCollisionSafePath( + relativeDirectory, pageId, usedPagePaths); + string targetPath = Path.Combine( + stagingDirectory, relativeDirectory, "index.htm"); + + pages.Add(new HtmlPageExport( + pageId, pageName, sectionDisplayPath, relativeDirectory, targetPath)); + } + + return pages; + } + + private static string MakeCollisionSafePath( + string preferredPath, + string stableId, + HashSet usedPaths) + { + if (usedPaths.Add(preferredPath)) return preferredPath; + + string candidate = $"{preferredPath}__{CreateStableSuffix(stableId)}"; + int collisionIndex = 2; + while (!usedPaths.Add(candidate)) + candidate = $"{preferredPath}__{CreateStableSuffix(stableId)}_{collisionIndex++}"; + return candidate; + } + + private static string CreateStableSuffix(string value) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash.AsSpan(0, 4)).ToLowerInvariant(); + } + + private static string PrepareHtmlStagingDirectory(string finalDirectory) + { + string parentDirectory = Path.GetDirectoryName(finalDirectory) + ?? throw new InvalidOperationException("The HTML destination path is invalid."); + string finalName = Path.GetFileName(finalDirectory); + Directory.CreateDirectory(parentDirectory); + RecoverInterruptedHtmlSwap(parentDirectory, finalName, finalDirectory); + CleanupStaleHtmlDirectories( + parentDirectory, finalName, DateTime.UtcNow.AddDays(-2)); + + string stagingDirectory = Path.Combine( + parentDirectory, $".{finalName}.{Guid.NewGuid():N}.partial"); + Directory.CreateDirectory(stagingDirectory); + return stagingDirectory; + } + private static string BuildSectionDirectory(string notebookDirectory, string groupName) { string currentDirectory = notebookDirectory; @@ -328,6 +650,27 @@ private static string BuildSectionDirectory(string notebookDirectory, string gro return currentDirectory; } + private static string BuildHtmlGroupDirectory( + string notebookDirectory, + string groupName, + string groupExportPath) + { + string currentDirectory = notebookDirectory; + + if (!string.IsNullOrWhiteSpace(groupExportPath)) + return Path.Combine(currentDirectory, groupExportPath); + + foreach (string groupPart in groupName.Split( + " / ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + currentDirectory = Path.Combine( + currentDirectory, + SanitizeHtmlPathSegment(groupPart, "Group")); + } + + return currentDirectory; + } + /// /// Keeps normal section filenames readable, but adds a stable short ID only when /// two sections would resolve to the same case-insensitive Windows path after @@ -362,6 +705,45 @@ private static string GetSanitizedSectionRelativePath(SectionInfo section) private static string SanitizeFileName(string name) => string.Join("_", name.Split(Path.GetInvalidFileNameChars())); + private static string SanitizeHtmlPathSegment(string name, string fallback) + { + const int maxLength = 80; + string sanitized = SanitizeFileName(name).Trim().TrimEnd('.'); + if (string.IsNullOrWhiteSpace(sanitized)) + sanitized = fallback; + + string deviceName = sanitized.Split('.')[0]; + bool isReservedDeviceName = deviceName.Equals("CON", StringComparison.OrdinalIgnoreCase) || + deviceName.Equals("PRN", StringComparison.OrdinalIgnoreCase) || + deviceName.Equals("AUX", StringComparison.OrdinalIgnoreCase) || + deviceName.Equals("NUL", StringComparison.OrdinalIgnoreCase) || + (deviceName.Length == 4 && + (deviceName.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || + deviceName.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) && + deviceName[3] is >= '1' and <= '9'); + if (isReservedDeviceName) + sanitized = $"_{sanitized}"; + + if (sanitized.Length > maxLength) + sanitized = sanitized[..maxLength].TrimEnd(' ', '.'); + + return string.IsNullOrWhiteSpace(sanitized) ? fallback : sanitized; + } + + private static bool IsCloudPath(string path) + => path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("http://", StringComparison.OrdinalIgnoreCase); + + private static ExportFormatInfo ResolveSingleFileFormat(string exportFormat) + => exportFormat.ToLowerInvariant() switch + { + "onepkg" => new(".onepkg", PublishFormat.pfOneNotePackage), + "xps" => new(".xps", PublishFormat.pfXPS), + "pdf" => new(".pdf", PublishFormat.pfPDF), + _ => throw new ArgumentOutOfRangeException( + nameof(exportFormat), exportFormat, "Unsupported single-file export format.") + }; + // ── Private helpers ────────────────────────────────────────────────────── /// @@ -448,6 +830,321 @@ internal static async Task WaitForFileAsync( }; } + internal static async Task WaitForHtmlPageAsync( + HtmlPageExport page, + bool isCloud, + IProgress? progress, + CancellationToken ct) + { + const int checkIntervalMs = 500; + const int requiredStableSnapshots = 10; // five seconds without bundle changes + int maxAttempts = isCloud ? 1200 : 600; // 10 min : 5 min per page + string? previousSnapshot = null; + int stableCount = 0; + + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + + try + { + if (File.Exists(page.TargetPath) && new FileInfo(page.TargetPath).Length > 0) + { + string pageDirectory = Path.GetDirectoryName(page.TargetPath)!; + string snapshot = CreateDirectorySnapshot(pageDirectory); + + if (snapshot == previousSnapshot) + { + stableCount++; + if (stableCount >= requiredStableSnapshots) + { + long totalBytes = Directory + .EnumerateFiles(pageDirectory, "*", SearchOption.AllDirectories) + .Sum(path => new FileInfo(path).Length); + return new ExportResult + { + Success = true, + Message = $"HTML page exported ({FormatBytes(totalBytes)})", + ExportedPath = page.TargetPath + }; + } + } + else + { + stableCount = 0; + previousSnapshot = snapshot; + } + } + } + catch (IOException) + { + // OneNote may be creating, replacing, or closing a companion asset. + stableCount = 0; + previousSnapshot = null; + } + catch (UnauthorizedAccessException) + { + // Treat a temporary sharing/permission race as an active write. + stableCount = 0; + previousSnapshot = null; + } + + if (attempt > 0 && attempt % 10 == 0) + progress?.Report($"Waiting for HTML assets: {page.Name}..."); + + await Task.Delay(checkIntervalMs, ct).ConfigureAwait(false); + } + + return new ExportResult + { + Success = false, + Message = $"The HTML page '{page.Name}' did not finish writing." + }; + } + + private static string CreateDirectorySnapshot(string directory) + { + var snapshot = new StringBuilder(); + + foreach (string path in Directory + .EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + var info = new FileInfo(path); + snapshot + .Append(Path.GetRelativePath(directory, path)) + .Append('|') + .Append(info.Length) + .Append('|') + .Append(info.LastWriteTimeUtc.Ticks) + .AppendLine(); + } + + return snapshot.ToString(); + } + + internal static ExportResult FinalizeHtmlExport( + HtmlExportPlan plan, + ExportResult result) + { + WriteHtmlIndexes(plan); + + string parentDirectory = Path.GetDirectoryName(plan.FinalDirectory) + ?? throw new InvalidOperationException("The HTML destination path is invalid."); + string finalName = Path.GetFileName(plan.FinalDirectory); + string previousDirectory = Path.Combine( + parentDirectory, $".{finalName}.{Guid.NewGuid():N}.previous"); + bool previousMoved = false; + bool updateCollectionIndex = !Path.GetFullPath(plan.CollectionDirectory).Equals( + Path.GetFullPath(plan.FinalDirectory), StringComparison.OrdinalIgnoreCase); + string collectionIndexPath = Path.Combine( + plan.CollectionDirectory, "index.html"); + byte[]? previousCollectionIndex = updateCollectionIndex && File.Exists(collectionIndexPath) + ? File.ReadAllBytes(collectionIndexPath) + : null; + + try + { + if (Directory.Exists(plan.FinalDirectory)) + { + Directory.Move(plan.FinalDirectory, previousDirectory); + previousMoved = true; + } + + Directory.Move(plan.StagingDirectory, plan.FinalDirectory); + + if (updateCollectionIndex) + WriteHtmlCollectionIndex(plan.CollectionDirectory, plan.CollectionTitle); + + result.ExportedPath = updateCollectionIndex + ? collectionIndexPath + : Path.Combine(plan.FinalDirectory, "index.html"); + + if (previousMoved) + TryDeleteDirectory(previousDirectory); + + return result; + } + catch + { + try + { + if (Directory.Exists(plan.FinalDirectory) && + !Directory.Exists(plan.StagingDirectory)) + { + Directory.Move(plan.FinalDirectory, plan.StagingDirectory); + } + + if (!Directory.Exists(plan.FinalDirectory) && + previousMoved && + Directory.Exists(previousDirectory)) + { + Directory.Move(previousDirectory, plan.FinalDirectory); + } + + if (updateCollectionIndex) + { + if (previousCollectionIndex != null) + File.WriteAllBytes(collectionIndexPath, previousCollectionIndex); + else if (File.Exists(collectionIndexPath)) + File.Delete(collectionIndexPath); + } + } + catch (IOException) + { + // Preserve the original finalization failure. The .previous directory + // remains available for recovery on the next export. + } + catch (UnauthorizedAccessException) + { + // Preserve the original finalization failure and retained recovery data. + } + + throw; + } + } + + private static void WriteHtmlIndexes(HtmlExportPlan plan) + { + var sectionGroups = plan.Pages + .GroupBy(page => Path.GetDirectoryName(page.RelativeDirectory) ?? "") + .OrderBy(group => group.First().SectionPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var sectionGroup in sectionGroups) + { + string sectionDirectory = Path.Combine( + plan.StagingDirectory, sectionGroup.Key); + var sectionLinks = sectionGroup + .OrderBy(page => page.Name, StringComparer.OrdinalIgnoreCase) + .Select(page => ( + page.Name, + ToHtmlHref(Path.GetRelativePath( + sectionDirectory, page.TargetPath)))) + .ToList(); + WriteHtmlIndex( + Path.Combine(sectionDirectory, "index.html"), + sectionGroup.First().SectionPath, + sectionLinks); + } + + var rootLinks = plan.Pages + .OrderBy(page => page.SectionPath, StringComparer.OrdinalIgnoreCase) + .ThenBy(page => page.Name, StringComparer.OrdinalIgnoreCase) + .Select(page => ( + $"{page.SectionPath} / {page.Name}", + ToHtmlHref(Path.GetRelativePath( + plan.StagingDirectory, page.TargetPath)))) + .ToList(); + WriteHtmlIndex( + Path.Combine(plan.StagingDirectory, "index.html"), + plan.Title, + rootLinks); + } + + private static void WriteHtmlCollectionIndex( + string collectionDirectory, + string title) + { + Directory.CreateDirectory(collectionDirectory); + string rootIndexPath = Path.Combine(collectionDirectory, "index.html"); + var sectionLinks = Directory + .EnumerateFiles(collectionDirectory, "index.html", SearchOption.AllDirectories) + .Where(path => !path.Equals(rootIndexPath, StringComparison.OrdinalIgnoreCase)) + .Select(path => new + { + Path = path, + RelativePath = Path.GetRelativePath(collectionDirectory, path) + }) + .Where(item => !item.RelativePath + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.StartsWith('.'))) + .Where(item => HasStableSuffix( + Path.GetFileName(Path.GetDirectoryName(item.RelativePath) ?? ""))) + .OrderBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase) + .Select(item => ( + GetHtmlCollectionLabel(item.RelativePath), + ToHtmlHref(item.RelativePath))) + .ToList(); + + string temporaryIndexPath = Path.Combine( + collectionDirectory, $".index.{Guid.NewGuid():N}.partial.html"); + try + { + WriteHtmlIndex(temporaryIndexPath, title, sectionLinks); + File.Move(temporaryIndexPath, rootIndexPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryIndexPath)) + File.Delete(temporaryIndexPath); + } + } + + private static string GetHtmlCollectionLabel(string relativeIndexPath) + { + string? directory = Path.GetDirectoryName(relativeIndexPath); + if (string.IsNullOrEmpty(directory)) return "Section"; + + return string.Join( + " / ", + directory + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + .Select(RemoveStableSuffix)); + } + + private static string RemoveStableSuffix(string segment) + { + int separatorIndex = segment.LastIndexOf("__", StringComparison.Ordinal); + return HasStableSuffix(segment) + ? segment[..separatorIndex] + : segment; + } + + private static bool HasStableSuffix(string segment) + { + int separatorIndex = segment.LastIndexOf("__", StringComparison.Ordinal); + if (separatorIndex < 0 || segment.Length - separatorIndex != 10) + return false; + + return segment[(separatorIndex + 2)..].All(Uri.IsHexDigit); + } + + private static void WriteHtmlIndex( + string path, + string title, + IReadOnlyList<(string Label, string Href)> links) + { + var html = new StringBuilder() + .AppendLine("") + .AppendLine("") + .Append("") + .Append("").Append(WebUtility.HtmlEncode(title)).AppendLine("") + .AppendLine("") + .AppendLine("") + .Append("

").Append(WebUtility.HtmlEncode(title)).AppendLine("

") + .AppendLine(""); + File.WriteAllText(path, html.ToString(), new UTF8Encoding(false)); + } + + private static string ToHtmlHref(string relativePath) + => string.Join( + "/", + relativePath + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + internal static ExportResult FinalizeExport( PendingExport pending, ExportResult result) @@ -460,6 +1157,7 @@ internal static ExportResult FinalizeExport( internal static bool IsRecoverableComFailure(Exception exception) => exception is COMException ex && ex.HResult is unchecked((int)0x800706BA) or // RPC_S_SERVER_UNAVAILABLE + unchecked((int)0x800706BE) or // RPC_S_CALL_FAILED unchecked((int)0x80010108) or // RPC_E_DISCONNECTED unchecked((int)0x800401FD) or // CO_E_OBJNOTCONNECTED unchecked((int)0x8001010A) or // RPC_E_SERVERCALL_RETRYLATER @@ -468,6 +1166,7 @@ internal static bool IsRecoverableComFailure(Exception exception) internal static bool RequiresComConnectionReset(Exception exception) => exception is COMException ex && ex.HResult is unchecked((int)0x800706BA) or + unchecked((int)0x800706BE) or unchecked((int)0x80010108) or unchecked((int)0x800401FD); @@ -481,6 +1180,7 @@ internal static string DescribeComFailure(Exception exception) return ex.HResult switch { unchecked((int)0x800706BA) => "RPC connection lost (0x800706BA)", + unchecked((int)0x800706BE) => "OneNote RPC call failed (0x800706BE)", unchecked((int)0x80010108) => "COM object disconnected (0x80010108)", unchecked((int)0x800401FD) => "COM object is no longer connected (0x800401FD)", unchecked((int)0x80010007) => "OneNote COM server stopped (0x80010007)", @@ -605,7 +1305,7 @@ internal static ExportResult CreateExportFailure( private static void CollectSections( XElement parent, XNamespace ns, string notebookId, string notebookName, string notebookPath, bool isCloud, - string groupName, List sections) + string groupName, string groupExportPath, List sections) { foreach (var el in parent.Elements()) { @@ -621,6 +1321,7 @@ private static void CollectSections( NotebookName = notebookName, NotebookPath = notebookPath, GroupName = groupName, + GroupExportPath = groupExportPath, IsCloud = isCloud }); } @@ -629,8 +1330,13 @@ private static void CollectSections( if (el.Attribute("isRecycleBin")?.Value == "true") continue; var gName = el.Attribute("name")?.Value ?? "Group"; + string gId = el.Attribute("ID")?.Value ?? $"{groupName}/{gName}"; + string exportSegment = + $"{SanitizeHtmlPathSegment(gName, "Group")}" + + $"__{CreateStableSuffix(gId)}"; CollectSections(el, ns, notebookId, notebookName, notebookPath, isCloud, string.IsNullOrEmpty(groupName) ? gName : $"{groupName} / {gName}", + Path.Combine(groupExportPath, exportSegment), sections); } } diff --git a/innosetup.iss b/innosetup.iss index 4e66c37..dfc223f 100644 --- a/innosetup.iss +++ b/innosetup.iss @@ -1,6 +1,6 @@ #define MyAppName "OneNoteBackupExporter" #ifndef MyAppVersion - #define MyAppVersion "1.2.2" + #define MyAppVersion "1.3.0" #endif #define MyAppPublisher "JLI Software" #define MyAppURL "https://github.com/jli-software/OneNoteBackupExporter" diff --git a/release.sh b/release.sh index 37b3c8d..e9f25c9 100755 --- a/release.sh +++ b/release.sh @@ -5,7 +5,7 @@ set -euo pipefail if [[ $# -ne 1 ]]; then - echo "Usage: $0 (for example: $0 1.2.2)" + echo "Usage: $0 (for example: $0 1.3.0)" exit 1 fi diff --git a/tests/OneNoteExporter.Tests/HtmlExportTests.cs b/tests/OneNoteExporter.Tests/HtmlExportTests.cs new file mode 100644 index 0000000..d8f0071 --- /dev/null +++ b/tests/OneNoteExporter.Tests/HtmlExportTests.cs @@ -0,0 +1,145 @@ +using OneNoteExporter.Models; +using OneNoteExporter.Services; +using System.Diagnostics; +using Xunit; + +namespace OneNoteExporter.Tests; + +public sealed class HtmlExportTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), "OneNoteExporterTests", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task WaitForHtmlPageAsync_WaitsForTheWholePageBundle() + { + string pageDirectory = Path.Combine(_testRoot, "staging", "Page"); + string targetPath = Path.Combine(pageDirectory, "index.htm"); + Directory.CreateDirectory(Path.Combine(pageDirectory, "index_files")); + await File.WriteAllTextAsync(targetPath, "Test"); + await File.WriteAllBytesAsync( + Path.Combine(pageDirectory, "index_files", "image.png"), + [1, 2, 3, 4]); + var page = new HtmlPageExport( + "page-id", "Page", "Section", "Section\\Page", targetPath); + + var stopwatch = Stopwatch.StartNew(); + Task lateAsset = Task.Run(async () => + { + await Task.Delay(1500); + await File.WriteAllTextAsync( + Path.Combine(pageDirectory, "index_files", "late.css"), "body{}"); + }); + + ExportResult result = await OneNoteService.WaitForHtmlPageAsync( + page, isCloud: false, progress: null, CancellationToken.None); + Assert.True(lateAsset.IsCompleted); + await lateAsset; + + Assert.True(result.Success); + Assert.Equal(targetPath, result.ExportedPath); + Assert.True(stopwatch.Elapsed >= TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task WaitForHtmlPageAsync_HonorsCancellation() + { + string targetPath = Path.Combine(_testRoot, "missing", "index.htm"); + var page = new HtmlPageExport( + "page-id", "Page", "Section", "Page", targetPath); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync(() => + OneNoteService.WaitForHtmlPageAsync( + page, isCloud: false, progress: null, cancellation.Token)); + } + + [Fact] + public async Task FinalizeHtmlExport_ReplacesTheCompleteBundleAndBuildsIndexes() + { + string stagingDirectory = Path.Combine(_testRoot, ".Notebook_html.partial"); + string finalDirectory = Path.Combine(_testRoot, "Notebook_html"); + string pageDirectory = Path.Combine(stagingDirectory, "Section", "Page & Notes"); + string targetPath = Path.Combine(pageDirectory, "index.htm"); + Directory.CreateDirectory(Path.Combine(pageDirectory, "index_files")); + await File.WriteAllTextAsync(targetPath, "New page"); + await File.WriteAllTextAsync( + Path.Combine(pageDirectory, "index_files", "asset.txt"), "asset"); + Directory.CreateDirectory(finalDirectory); + await File.WriteAllTextAsync(Path.Combine(finalDirectory, "obsolete.txt"), "old"); + + var page = new HtmlPageExport( + "page-id", "Page & Notes", "Section", "Section\\Page & Notes", targetPath); + var plan = new HtmlExportPlan( + "Notebook", + stagingDirectory, + finalDirectory, + finalDirectory, + "Notebook", + IsCloud: false, + [page]); + var result = new ExportResult { Success = true }; + + ExportResult finalized = OneNoteService.FinalizeHtmlExport(plan, result); + + Assert.False(File.Exists(Path.Combine(finalDirectory, "obsolete.txt"))); + Assert.True(File.Exists(Path.Combine( + finalDirectory, "Section", "Page & Notes", "index.htm"))); + Assert.True(File.Exists(Path.Combine(finalDirectory, "index.html"))); + string rootIndex = await File.ReadAllTextAsync( + Path.Combine(finalDirectory, "index.html")); + Assert.Contains("Page & Notes", rootIndex); + Assert.Contains("Page%20%26%20Notes/index.htm", rootIndex); + Assert.Equal(Path.Combine(finalDirectory, "index.html"), finalized.ExportedPath); + Assert.Empty(Directory.EnumerateDirectories( + _testRoot, ".Notebook_html.*.previous")); + } + + [Fact] + public async Task FinalizeHtmlExport_SectionExportRebuildsNotebookIndex() + { + string collectionDirectory = Path.Combine(_testRoot, "Notebook_html"); + string sectionParent = Path.Combine(collectionDirectory, "Group"); + string stagingDirectory = Path.Combine(sectionParent, ".Section.partial"); + string finalDirectory = Path.Combine(sectionParent, "Section__1234abcd"); + string targetPath = Path.Combine(stagingDirectory, "Page", "index.htm"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + await File.WriteAllTextAsync(targetPath, "Page"); + + string retainedSection = Path.Combine(collectionDirectory, "Retained__deadbeef"); + Directory.CreateDirectory(retainedSection); + await File.WriteAllTextAsync( + Path.Combine(retainedSection, "index.html"), "retained"); + await File.WriteAllTextAsync( + Path.Combine(collectionDirectory, "index.html"), "obsolete link"); + + var page = new HtmlPageExport( + "page-id", "Page", "Section", "Page", targetPath); + var plan = new HtmlExportPlan( + "Section", + stagingDirectory, + finalDirectory, + collectionDirectory, + "Notebook", + IsCloud: false, + [page]); + + ExportResult result = OneNoteService.FinalizeHtmlExport( + plan, new ExportResult { Success = true }); + + string collectionIndex = await File.ReadAllTextAsync( + Path.Combine(collectionDirectory, "index.html")); + Assert.DoesNotContain("obsolete link", collectionIndex); + Assert.Contains("Group / Section", collectionIndex); + Assert.Contains("Retained", collectionIndex); + Assert.Contains("Group/Section__1234abcd/index.html", collectionIndex); + Assert.Equal(Path.Combine(collectionDirectory, "index.html"), result.ExportedPath); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + Directory.Delete(_testRoot, recursive: true); + } +} diff --git a/tests/OneNoteExporter.Tests/OneNoteExporter.Tests.csproj b/tests/OneNoteExporter.Tests/OneNoteExporter.Tests.csproj new file mode 100644 index 0000000..79dad79 --- /dev/null +++ b/tests/OneNoteExporter.Tests/OneNoteExporter.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0-windows + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +