Add eBay credentials, edit listings feature, fix category service token

This commit is contained in:
Peter Foster
2026-04-14 11:45:15 +01:00
parent f4e7854297
commit 1ff9d3d78b
5 changed files with 434 additions and 10 deletions

View File

@@ -14,6 +14,9 @@ public class EbayCategoryService
{ {
private readonly EbayAuthService _auth; private readonly EbayAuthService _auth;
// Static client — avoids socket exhaustion from per-call `new HttpClient()`
private static readonly HttpClient _http = new();
public EbayCategoryService(EbayAuthService auth) public EbayCategoryService(EbayAuthService auth)
{ {
_auth = auth; _auth = auth;
@@ -26,15 +29,18 @@ public class EbayCategoryService
try try
{ {
var token = await _auth.GetValidAccessTokenAsync(); // Taxonomy API supports app-level tokens — no user login required
using var http = new HttpClient(); var token = await _auth.GetAppTokenAsync();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); using var request = new HttpRequestMessage(HttpMethod.Get,
http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB"); $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3/get_category_suggestions" +
$"?q={Uri.EscapeDataString(query)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
var url = $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3/get_category_suggestions" + var response = await _http.SendAsync(request);
$"?q={Uri.EscapeDataString(query)}"; var json = await response.Content.ReadAsStringAsync();
var json = await http.GetStringAsync(url); if (!response.IsSuccessStatusCode) return new List<CategorySuggestion>();
var obj = JObject.Parse(json); var obj = JObject.Parse(json);
var results = new List<CategorySuggestion>(); var results = new List<CategorySuggestion>();

View File

@@ -91,6 +91,56 @@ public class SavedListingsService
catch { /* ignore — user may have already deleted it */ } catch { /* ignore — user may have already deleted it */ }
} }
/// <summary>
/// Updates an existing listing's metadata and regenerates its text export file.
/// The listing must be the same object reference held in Listings.
/// </summary>
public void Update(SavedListing listing)
{
if (Directory.Exists(listing.ExportFolder))
{
// Replace the text export — remove old .txt files first
foreach (var old in Directory.GetFiles(listing.ExportFolder, "*.txt"))
{
try { File.Delete(old); } catch { }
}
var safeName = MakeSafeFilename(listing.Title);
var textFile = Path.Combine(listing.ExportFolder, $"{safeName}.txt");
File.WriteAllText(textFile, BuildTextExport(
listing.Title, listing.Description, listing.Price,
listing.Category, listing.ConditionNotes));
}
Persist(); // E1: propagates on failure so caller can show error
}
/// <summary>
/// Copies a source photo into the listing's export folder and returns the destination path.
/// currentCount is the number of photos already in the edit session (used for naming).
/// </summary>
public string CopyPhotoToExportFolder(SavedListing listing, string sourcePath, int currentCount)
{
if (!Directory.Exists(listing.ExportFolder))
throw new DirectoryNotFoundException($"Export folder not found: {listing.ExportFolder}");
var safeName = MakeSafeFilename(listing.Title);
var ext = Path.GetExtension(sourcePath);
var dest = currentCount == 0
? Path.Combine(listing.ExportFolder, $"{safeName}{ext}")
: Path.Combine(listing.ExportFolder, $"{safeName}_{currentCount + 1}{ext}");
// Ensure unique filename if a file with that name already exists
int attempt = 2;
while (File.Exists(dest))
{
dest = Path.Combine(listing.ExportFolder, $"{safeName}_{currentCount + attempt}{ext}");
attempt++;
}
File.Copy(sourcePath, dest);
return dest;
}
// S3: use ProcessStartInfo with FileName so spaces/special chars are handled correctly // S3: use ProcessStartInfo with FileName so spaces/special chars are handled correctly
public void OpenExportFolder(SavedListing listing) public void OpenExportFolder(SavedListing listing)
{ {

View File

@@ -262,6 +262,15 @@
<!-- Action buttons --> <!-- Action buttons -->
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<Button Click="EditListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Edit" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="OpenFolderDetail_Click" <Button Click="OpenFolderDetail_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}" Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6"> Height="34" Padding="14,0" Margin="0,0,8,6">
@@ -311,6 +320,69 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<!-- Edit panel — shown in place of DetailPanel when editing -->
<ScrollViewer x:Name="EditPanel" Visibility="Collapsed"
VerticalScrollBarVisibility="Auto" Padding="18,14">
<StackPanel>
<!-- Title -->
<TextBlock Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditTitle" FontSize="13" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Listing title"/>
<!-- Price + Category -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="PRICE (£)" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<mah:NumericUpDown x:Name="EditPrice" Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5" Value="0"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCategory" FontSize="12"
mah:TextBoxHelper.Watermark="e.g. Clothing, Shoes &amp; Accessories"/>
</StackPanel>
</Grid>
<!-- Condition notes -->
<TextBlock Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCondition" FontSize="12" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Optional — e.g. minor scuff on base"/>
<!-- Description -->
<TextBlock Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4"
TextWrapping="Wrap" AcceptsReturn="True"
Height="130" VerticalScrollBarVisibility="Auto"/>
<!-- Photos -->
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Margin="0,0,0,10">
<StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer>
<!-- Save / Cancel -->
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<Button x:Name="SaveEditBtn" Click="SaveEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="16,0" Margin="0,0,8,0"
Content="Save Changes"/>
<Button x:Name="CancelEditBtn" Click="CancelEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="14,0"
Content="Cancel"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid> </Grid>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -6,6 +6,7 @@ using System.Windows.Media.Animation;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using EbayListingTool.Models; using EbayListingTool.Models;
using EbayListingTool.Services; using EbayListingTool.Services;
using Microsoft.Win32;
namespace EbayListingTool.Views; namespace EbayListingTool.Views;
@@ -14,6 +15,10 @@ public partial class SavedListingsView : UserControl
private SavedListingsService? _service; private SavedListingsService? _service;
private SavedListing? _selected; private SavedListing? _selected;
// Edit mode working state
private List<string> _editPhotoPaths = new();
private List<string> _pendingDeletes = new();
// Normal card background — resolved once after load so we can restore it on mouse-leave // Normal card background — resolved once after load so we can restore it on mouse-leave
private Brush? _cardNormalBg; private Brush? _cardNormalBg;
private Brush? _cardHoverBg; private Brush? _cardHoverBg;
@@ -327,6 +332,297 @@ public partial class SavedListingsView : UserControl
catch { } catch { }
} }
// ---- Edit mode ----
private void EditListing_Click(object sender, RoutedEventArgs e)
{
if (_selected == null) return;
EnterEditMode(_selected);
}
private void EnterEditMode(SavedListing listing)
{
EditTitle.Text = listing.Title;
EditPrice.Value = (double)listing.Price;
EditCategory.Text = listing.Category;
EditCondition.Text = listing.ConditionNotes;
EditDescription.Text = listing.Description;
_editPhotoPaths = new List<string>(listing.PhotoPaths);
_pendingDeletes = new List<string>();
BuildEditPhotoStrip();
DetailPanel.Visibility = Visibility.Collapsed;
EditPanel.Visibility = Visibility.Visible;
}
private void ExitEditMode()
{
EditPanel.Visibility = Visibility.Collapsed;
DetailPanel.Visibility = Visibility.Visible;
}
private void BuildEditPhotoStrip()
{
EditPhotosPanel.Children.Clear();
for (int i = 0; i < _editPhotoPaths.Count; i++)
{
var path = _editPhotoPaths[i];
var index = i; // capture for lambdas
var container = new Grid { Width = 120, Height = 120, Margin = new Thickness(0, 0, 8, 0) };
// Photo image
var imgBorder = new Border
{
Width = 120, Height = 120,
CornerRadius = new CornerRadius(6),
ClipToBounds = true,
Background = (Brush)FindResource("MahApps.Brushes.Gray8")
};
if (File.Exists(path))
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 240;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
imgBorder.Child = new Image { Source = bmp, Stretch = Stretch.UniformToFill };
}
catch { AddPhotoIcon(imgBorder); }
}
else
{
AddPhotoIcon(imgBorder);
}
container.Children.Add(imgBorder);
// "Cover" badge on the first photo
if (i == 0)
{
var badge = new Border
{
CornerRadius = new CornerRadius(3),
Background = (Brush)FindResource("MahApps.Brushes.Accent"),
Padding = new Thickness(4, 1, 4, 1),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(4, 4, 0, 0)
};
badge.Child = new TextBlock { Text = "Cover", FontSize = 9, Foreground = Brushes.White };
container.Children.Add(badge);
}
// Remove (×) button — top-right corner
var removeBtn = new Button
{
Content = "×",
Width = 22, Height = 22,
FontSize = 13,
Padding = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 2, 2, 0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
Foreground = Brushes.White,
Background = new SolidColorBrush(Color.FromArgb(200, 40, 40, 40)),
ToolTip = "Remove photo"
};
removeBtn.Click += (s, e) =>
{
_pendingDeletes.Add(_editPhotoPaths[index]);
_editPhotoPaths.RemoveAt(index);
BuildEditPhotoStrip();
};
container.Children.Add(removeBtn);
// Left/right reorder buttons — bottom-centre
var reorderPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 4)
};
if (i > 0)
{
var leftBtn = new Button
{
Content = "◀",
Width = 26, Height = 20,
FontSize = 9,
Padding = new Thickness(0),
Margin = new Thickness(0, 0, 2, 0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
Foreground = Brushes.White,
Background = new SolidColorBrush(Color.FromArgb(180, 40, 40, 40)),
ToolTip = "Move left"
};
leftBtn.Click += (s, e) =>
{
(_editPhotoPaths[index], _editPhotoPaths[index - 1]) =
(_editPhotoPaths[index - 1], _editPhotoPaths[index]);
BuildEditPhotoStrip();
};
reorderPanel.Children.Add(leftBtn);
}
if (i < _editPhotoPaths.Count - 1)
{
var rightBtn = new Button
{
Content = "▶",
Width = 26, Height = 20,
FontSize = 9,
Padding = new Thickness(0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
Foreground = Brushes.White,
Background = new SolidColorBrush(Color.FromArgb(180, 40, 40, 40)),
ToolTip = "Move right"
};
rightBtn.Click += (s, e) =>
{
(_editPhotoPaths[index], _editPhotoPaths[index + 1]) =
(_editPhotoPaths[index + 1], _editPhotoPaths[index]);
BuildEditPhotoStrip();
};
reorderPanel.Children.Add(rightBtn);
}
if (reorderPanel.Children.Count > 0)
container.Children.Add(reorderPanel);
EditPhotosPanel.Children.Add(container);
}
// "Add photos" button at the end of the strip
var addBtn = new Button
{
Width = 60, Height = 120,
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
ToolTip = "Add photos"
};
var addContent = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
addContent.Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial
{
Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Plus,
Width = 20, Height = 20,
HorizontalAlignment = HorizontalAlignment.Center,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent")
});
addContent.Children.Add(new TextBlock
{
Text = "Add",
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent"),
Margin = new Thickness(0, 4, 0, 0)
});
addBtn.Content = addContent;
addBtn.Click += AddEditPhoto_Click;
EditPhotosPanel.Children.Add(addBtn);
}
private void AddEditPhoto_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _service == null) return;
var dlg = new OpenFileDialog
{
Multiselect = true,
Filter = "Image files|*.jpg;*.jpeg;*.png;*.gif;*.bmp|All files|*.*",
Title = "Add Photos"
};
if (dlg.ShowDialog() != true) return;
foreach (var src in dlg.FileNames)
{
try
{
var dest = _service.CopyPhotoToExportFolder(_selected, src, _editPhotoPaths.Count);
_editPhotoPaths.Add(dest);
}
catch (Exception ex)
{
MessageBox.Show($"Could not add \"{Path.GetFileName(src)}\": {ex.Message}",
"Add Photo", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
BuildEditPhotoStrip();
}
private void SaveEdit_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _service == null) return;
var title = EditTitle.Text.Trim();
if (string.IsNullOrWhiteSpace(title))
{
MessageBox.Show("Title cannot be empty.", "Save", MessageBoxButton.OK, MessageBoxImage.Warning);
EditTitle.Focus();
return;
}
_selected.Title = title;
_selected.Price = (decimal)(EditPrice.Value ?? 0);
_selected.Category = EditCategory.Text.Trim();
_selected.ConditionNotes = EditCondition.Text.Trim();
_selected.Description = EditDescription.Text.Trim();
_selected.PhotoPaths = new List<string>(_editPhotoPaths);
try
{
_service.Update(_selected);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to save changes:\n{ex.Message}", "Save Error",
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
// Delete removed photos from disk now that the save succeeded
foreach (var path in _pendingDeletes)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { /* ignore — file may already be gone */ }
}
_pendingDeletes.Clear();
ExitEditMode();
ShowDetail(_selected, animate: false);
RefreshList();
}
private void CancelEdit_Click(object sender, RoutedEventArgs e)
{
if (_selected != null)
{
// Delete any photos that were added to disk during this edit session but not saved
var originalPaths = new HashSet<string>(_selected.PhotoPaths);
foreach (var path in _editPhotoPaths.Where(p => !originalPaths.Contains(p)))
{
try { if (File.Exists(path)) File.Delete(path); }
catch { }
}
}
_editPhotoPaths.Clear();
_pendingDeletes.Clear();
ExitEditMode();
}
// ---- Button handlers ---- // ---- Button handlers ----
private void OpenExportsDir_Click(object sender, RoutedEventArgs e) private void OpenExportsDir_Click(object sender, RoutedEventArgs e)

View File

@@ -1,8 +1,8 @@
{ {
"Ebay": { "Ebay": {
"ClientId": "YOUR_EBAY_CLIENT_ID", "ClientId": "PeterFos-Lister-SBX-f6c15d8b1-1e21a7cf",
"ClientSecret": "YOUR_EBAY_CLIENT_SECRET", "ClientSecret": "SBX-6c15d8b15850-bd12-45b9-a4d9-d5d7",
"RuName": "YOUR_EBAY_RUNAME", "RuName": "Peter_Foster-PeterFos-Lister-eutksmb",
"Sandbox": true, "Sandbox": true,
"RedirectPort": 8080, "RedirectPort": 8080,
"DefaultPostcode": "NR1 1AA" "DefaultPostcode": "NR1 1AA"