diff --git a/src/ModelPublisher.Cli/Program.cs b/src/ModelPublisher.Cli/Program.cs index c3c7cec..6703f3b 100644 --- a/src/ModelPublisher.Cli/Program.cs +++ b/src/ModelPublisher.Cli/Program.cs @@ -1,5 +1,7 @@ using System.CommandLine; +using Microsoft.Playwright; using ModelPublisher.Core; +using ModelPublisher.Core.Shared; using Spectre.Console; var manifestArg = new Argument("manifest") @@ -38,4 +40,30 @@ ); }); +// codegen — opens Brave with the platform's persistent profile and pauses for inspection +var codegenPlatformArg = new Argument("platform") { Description = "Platform key (e.g. thangs)" }; +var codegenUrlArg = new Argument("url") { Description = "URL to navigate to" }; +var codegenCommand = new Command("codegen", "Open Brave with a platform profile for selector inspection") +{ + codegenPlatformArg, + codegenUrlArg +}; +codegenCommand.SetAction(async (parseResult, ct) => +{ + var platformKey = parseResult.GetValue(codegenPlatformArg)!; + var url = parseResult.GetValue(codegenUrlArg)!; + + AnsiConsole.MarkupLine($"[cyan]Opening Brave with profile '[bold]{platformKey}[/]' at {Markup.Escape(url)}[/]"); + AnsiConsole.MarkupLine("[yellow]Use the Playwright Inspector to inspect selectors. Close the browser when done.[/]"); + + using var playwright = await Playwright.CreateAsync(); + var context = await BrowserContextFactory.GetPersistentContextAsync(playwright, platformKey); + var page = await context.NewPageAsync(); + await page.GotoAsync(url); + await page.PauseAsync(); + await context.CloseAsync(); + return 0; +}); +rootCommand.Add(codegenCommand); + return await rootCommand.Parse(args).InvokeAsync(); \ No newline at end of file diff --git a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs index 94f4293..cae236a 100644 --- a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs @@ -38,8 +38,6 @@ public async Task PublishFreeAsync(ReleaseManifest manifest, IPag { try { - // TODO: read manifest.GetPlatformConfig(PlatformKey) for FreePost and AccessTierId when Patreon automation is implemented - await page.GotoAsync("https://www.patreon.com/posts/create"); await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => diff --git a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs index 9a64d9b..7bdbf56 100644 --- a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs @@ -45,27 +45,18 @@ await FileUploadHelper.UploadSequentialAsync( AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling model details..."); // Step 2: Model Title - await page - .GetByRole(AriaRole.Textbox, new() { Name = "Model name (required)" }) - .ClickAsync(); - await page .GetByRole(AriaRole.Textbox, new() { Name = "Model name (required)" }) .FillAsync(manifest.Title); // Step 3: Model Summary - await page - .GetByRole(AriaRole.Textbox, new() { Name = "Summary (required)" }) - .ClickAsync(); - await page .GetByRole(AriaRole.Textbox, new() { Name = "Summary (required)" }) .FillAsync(manifest.Title); // Step 4: Model Category - // await page.GetByRole(AriaRole.Button, new() { Name = "Main category (required)" }).ClickAsync(); - // await page.GetByRole(AriaRole.Button, new() { Name = manifest.Category }).ClickAsync(); - + // TODO: Manually for now + // Step 5: Tags await page .GetByRole(AriaRole.Textbox, new() { Name = "Additional tags" }) diff --git a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs index 3bdc96d..5c6f1e9 100644 --- a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs @@ -21,51 +21,99 @@ public class ThangsPublisher : IPlatformPublisher public bool IsFreeOnly => false; public bool SupportsMarkdown => true; - public string Disclaimer => ""; - - public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default) + public string Disclaimer => GetDisclaimer(); + + public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, + CancellationToken ct = default) { try { - await page.GotoAsync("https://thangs.com/designer/upload"); + await page.GotoAsync("https://thangs.com/mythangs"); - await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => - { - return await p.Locator("[data-testid='user-menu'], .user-avatar, nav .avatar") - .First.IsVisibleAsync(); - }, ct); + await AuthGuard.EnsureLoggedInAsync(page, PlatformName, + p => Task.FromResult(p.Url.Contains("mythangs")), ct); + + await page + .GetByRole(AriaRole.Button, new() { Name = "Add new" }) + .ClickAsync(); + + 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(); + + await page + .GetByTestId("file-selector-buttons-upload-files") + .ClickAsync(); + + // Step 2: Model Title + await page + .GetByTestId("model-upload-name-input") + .ClearAsync(); + + await page + .GetByTestId("model-upload-name-input") + .FillAsync(manifest.Title); - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling model details..."); + // Step 3: Model Description + await page + .GetByTestId("model-upload-description-input") + .FillAsync(manifest.GetDescription(this)); - // TODO: Replace selectors after running codegen - await page.Locator("input[name='name'], input[placeholder*='model name' i]").First.FillAsync(manifest.Title); - await page.Locator("textarea[name='description'], [placeholder*='description' i]").First.FillAsync(manifest.GetDescription(this)); + // Step 4: Model Category + // TODO: Manually for now - // Tags + // Step 5: Tags foreach (var tag in manifest.Tags) { - var tagInput = page.Locator("input[placeholder*='tag' i], .tags-input input").First; - await tagInput.FillAsync(tag); - await tagInput.PressAsync("Enter"); + await page + .GetByTestId("cy_tag_input") + .FillAsync(tag); + + await page + .GetByTestId("cy_tag_input") + .PressAsync("Enter"); + await page.WaitForTimeoutAsync(300); } - // Model file - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); - // await page.Locator("input[type='file']").First - // .SetInputFilesAsync(manifest.ResolveFilePath(manifest.Files.Model)); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - - // Photos + // Step 6: Upload photos AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading photos..."); - var photoInput = page.Locator("input[type='file'][accept*='image']").First; - await FileUploadHelper.UploadSequentialAsync(page, photoInput, - manifest.Files.Photos.Select(manifest.ResolveFilePath), PlatformName); - AnsiConsole.MarkupLine($"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - await Task.Run(() => Console.ReadLine(), ct); + var photoInput = page.Locator("input[multiple][accept*='.jpg']").First; + await FileUploadHelper.UploadSequentialAsync( + page, photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath), + PlatformName); + + // Step 7: Audience + await page.Locator("[class*='Audience_VisibilityItem']").First.ClickAsync(); + await page.GetByText("Public sharing").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); + + // Step 9: Save + await page.GetByTestId("save-model-details").ClickAsync(); - await page.Locator("button:has-text('Publish'), button:has-text('Upload'), button[type='submit']").First.ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); return new PublishResult(PlatformName, true, page.Url, null); @@ -80,4 +128,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 Companion subscription._"; + } +} \ No newline at end of file