Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/ModelPublisher.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.CommandLine;
using Microsoft.Playwright;
using ModelPublisher.Core;
using ModelPublisher.Core.Shared;
using Spectre.Console;

var manifestArg = new Argument<FileInfo>("manifest")
Expand Down Expand Up @@ -38,4 +40,30 @@
);
});

// codegen <platformKey> <url> — opens Brave with the platform's persistent profile and pauses for inspection
var codegenPlatformArg = new Argument<string>("platform") { Description = "Platform key (e.g. thangs)" };
var codegenUrlArg = new Argument<string>("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();
2 changes: 0 additions & 2 deletions src/ModelPublisher.Core/Platforms/PatreonPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ public async Task<PublishResult> PublishFreeAsync(ReleaseManifest manifest, IPag
{
try
{
// TODO: read manifest.GetPlatformConfig<PatreonConfig>(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 =>
Expand Down
13 changes: 2 additions & 11 deletions src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
117 changes: 86 additions & 31 deletions src/ModelPublisher.Core/Platforms/ThangsPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,99 @@ public class ThangsPublisher : IPlatformPublisher
public bool IsFreeOnly => false;
public bool SupportsMarkdown => true;

public string Disclaimer => "";

public async Task<PublishResult> PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default)
public string Disclaimer => GetDisclaimer();

public async Task<PublishResult> 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);
Expand All @@ -80,4 +128,11 @@ public Task<PublishResult> 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._";
}
}
Loading