From 492a8fa017de9a394e7d9a3663bdf2e150d779ca Mon Sep 17 00:00:00 2001 From: Christof Lauriers Date: Sun, 22 Mar 2026 21:25:18 +0100 Subject: [PATCH] Fix ThangsPublisher selectors and parallel publishing - ThangsPublisher: use RunAndWaitForFileChooserAsync for React-compatible model file upload; fix audience trigger to real ClickAsync with retry loop; fix save button selector (data-test-id not data-testid); use save-and-publish button; fix close button to ClickAsync - PrintablesPublisher: remove Console.ReadLine pause; add Disclaimer - PublishCommand: run publishers in parallel via Task.WhenAll Co-Authored-By: Claude Sonnet 4.6 --- .../Platforms/PrintablesPublisher.cs | 20 ++-- .../Platforms/ThangsPublisher.cs | 91 +++++++++++++------ src/ModelPublisher.Core/PublishCommand.cs | 38 ++++---- 3 files changed, 93 insertions(+), 56 deletions(-) diff --git a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs index 7bdbf56..e834e06 100644 --- a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs @@ -16,7 +16,7 @@ public class PrintablesPublisher : IPlatformPublisher public bool IsFreeOnly => false; public bool SupportsMarkdown => false; - public string Disclaimer => ""; + public string Disclaimer => GetDisclaimer(); public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default) @@ -115,18 +115,13 @@ await page. GetByRole(AriaRole.Button, new() { Name = "Creative Commons — Attribution — Noncommercial — Share Alike" }) .ClickAsync(); - // Step 11: Human confirmation before publish - AnsiConsole.MarkupLine( - $"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - - await Task.Run(Console.ReadLine, ct); - - // Step 12: Submit + // Step 11: Submit await page .GetByRole(AriaRole.Button, new() { Name = "Save draft" }) .ClickAsync(); - await page.WaitForURLAsync("**/model/**", new() { Timeout = 30_000 }); + // Wait for URL to change away from /model/create to the saved model URL (e.g. /model/12345-slug) + await page.WaitForURLAsync(url => url.Contains("/model/") && !url.Contains("/model/create"), new() { Timeout = 30_000 }); return new PublishResult(PlatformName, true, page.Url, null); } @@ -140,4 +135,11 @@ public Task PublishPremiumAsync(ReleaseManifest manifest, IPage p { throw new NotImplementedException(); } + + private static string GetDisclaimer() + { + return "_ALL The Crafty Maker designs are protected by Copyright Law. By downloading, YOU HAVE NO RIGHT to " + + "sell any digital files or reproductions from those files. If you want a commercial license to LEGALLY " + + "SELL 3D prints, you'll need a The Crafty Seller Patreon subscription._"; + } } \ No newline at end of file diff --git a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs index 5c6f1e9..f7e86f5 100644 --- a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs @@ -41,19 +41,23 @@ await page .GetByTestId("action-upload-models") .ClickAsync(); - // Step 1: Model files - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); - - var modelFileInput = page.Locator("input[multiple]").First; - await modelFileInput.FocusAsync(); - - await FileUploadHelper.UploadSequentialAsync( - page, modelFileInput, manifest.Files.Models.Select(manifest.ResolveFilePath), PlatformName); - - await page.GetByTestId("upload-mode-collection").ClickAsync(); - - await page.Locator("label[for='terms-acceptance']").ClickAsync(); - + // Step 1: Model files — intercept file chooser so React registers the selection + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model files..."); + + var modelChooser = await page + .RunAndWaitForFileChooserAsync(() => page.GetByTestId("full-page-upload-container").ClickAsync()); + + await modelChooser + .SetFilesAsync(manifest.Files.Models.Select(manifest.ResolveFilePath).ToArray()); + + var uploadMode = page.GetByTestId("upload-mode-collection"); + await uploadMode.WaitForAsync(); + await uploadMode.ClickAsync(); + + var terms = page.Locator("label[for='terms-acceptance']"); + await terms.WaitForAsync(); + await terms.ClickAsync(); + await page .GetByTestId("file-selector-buttons-upload-files") .ClickAsync(); @@ -89,31 +93,58 @@ await page await page.WaitForTimeoutAsync(300); } - // Step 6: Upload photos + // Step 6: Upload photos — set all at once AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading photos..."); var photoInput = page.Locator("input[multiple][accept*='.jpg']").First; - await FileUploadHelper.UploadSequentialAsync( - page, photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath), - PlatformName); + await FileUploadHelper + .UploadToInputAsync(photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath).ToArray()); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - // Step 7: Audience - await page.Locator("[class*='Audience_VisibilityItem']").First.ClickAsync(); - await page.GetByText("Public sharing").ClickAsync(); + // Step 7: Audience — retry clicking the trigger until the dialog opens + await page + .GetByTestId("model-upload-audience") + .WaitForAsync(); + + var audienceTrigger = page.Locator("span[aria-haspopup='dialog']:has([data-testid='model-upload-audience'])"); + await audienceTrigger.ScrollIntoViewIfNeededAsync(); + var publicSharingOption = page.GetByText("Public sharing"); + for (var attempt = 0; attempt < 5; attempt++) + { + await audienceTrigger.ClickAsync(); + try + { + await publicSharingOption.WaitForAsync(new() { Timeout = 2_000 }); + break; + } + catch { /* dropdown didn't open, retry */ } + } + await publicSharingOption.ClickAsync(); // Step 7: License - await page.GetByRole(AriaRole.Button, new() { Name = "Select a license" }).ClickAsync(); - await page.GetByText("by-nc-sa_4_0.txt").First.ClickAsync(); - - // Step 8: Human review before publishing - AnsiConsole.MarkupLine( - $"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - - await Task.Run(Console.ReadLine, ct); + await page + .GetByRole(AriaRole.Button, new() { Name = "Select a license" }) + .ClickAsync(); + + await page + .GetByText("by-nc-sa_4_0.txt").First + .ClickAsync(); - // Step 9: Save - await page.GetByTestId("save-model-details").ClickAsync(); + // Step 8: Save — wait for button to be enabled, then click (Thangs uses data-test-id, not data-testid) + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Saving..."); + await Assertions + .Expect(page.Locator("[data-test-id='save-and-publish-model-details']")) + .ToBeEnabledAsync(new() { Timeout = 60_000 }); + + await page + .Locator("[data-test-id='save-and-publish-model-details']") + .ClickAsync(); + + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Save clicked."); + // Step 9: Close the upload dialog (non-fatal if already closed/navigated) + await page.Locator("[class*='FullPageUpload_Header_CloseIcon']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); return new PublishResult(PlatformName, true, page.Url, null); diff --git a/src/ModelPublisher.Core/PublishCommand.cs b/src/ModelPublisher.Core/PublishCommand.cs index 3cc254c..d94f991 100644 --- a/src/ModelPublisher.Core/PublishCommand.cs +++ b/src/ModelPublisher.Core/PublishCommand.cs @@ -82,31 +82,35 @@ public async Task ExecuteAsync( AnsiConsole.WriteLine(); var session = new PublishSession { ManifestPath = manifestPath }; + var consoleLock = new object(); using var playwright = await Playwright.CreateAsync(); - foreach (var (publisher, tier) in runnablePublishers) + var tasks = runnablePublishers.Select(async x => { - AnsiConsole.Write(new Rule($"[bold]{publisher.PlatformName}[/] [dim]({tier})[/]").LeftJustified()); - await using var context = await BrowserContextFactory.GetPersistentContextAsync( - playwright, publisher.PlatformKey); + playwright, x.Publisher.PlatformKey); var page = await context.NewPageAsync(); - var result = tier == "premium" - ? await publisher.PublishPremiumAsync(manifest, page, ct) - : await publisher.PublishFreeAsync(manifest, page, ct); - - session.Results.Add(result with { Tier = tier! }); - - if (result.Success) - AnsiConsole.MarkupLine($"[green]✓ {result.Platform}[/] → {Markup.Escape(result.PublishedUrl ?? "")}"); - else - AnsiConsole.MarkupLine($"[red]✗ {result.Platform}[/] — {Markup.Escape(result.ErrorMessage ?? "")}"); - - AnsiConsole.WriteLine(); - } + var result = x.Tier == "premium" + ? await x.Publisher.PublishPremiumAsync(manifest, page, ct) + : await x.Publisher.PublishFreeAsync(manifest, page, ct); + + var resultWithTier = result with { Tier = x.Tier! }; + + lock (consoleLock) + { + if (result.Success) + AnsiConsole.MarkupLine($"[green]✓ {result.Platform}[/] → {Markup.Escape(result.PublishedUrl ?? "")}"); + else + AnsiConsole.MarkupLine($"[red]✗ {result.Platform}[/] — {Markup.Escape(result.ErrorMessage ?? "")}"); + } + + return resultWithTier; + }); + + session.Results.AddRange(await Task.WhenAll(tasks)); // Summary AnsiConsole.Write(new Rule("[bold]Summary[/]").LeftJustified());