Add SeriesViewDialog

This commit is contained in:
MBucari 2023-03-19 20:05:18 -06:00 committed by Mbucari
parent 784ab73a36
commit 9ae1f0399b
27 changed files with 1293 additions and 18 deletions

View File

@ -171,7 +171,60 @@ namespace ApplicationServices
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, "item");
ArgumentValidator.EnsureNotNull(accountId, "accountId");
ArgumentValidator.EnsureNotNull(localeName, "localeName");
var importItem = new ImportItem
{
DtoItem = item,
AccountId = accountId,
LocaleName = localeName
};
var importItems = new List<ImportItem> { importItem };
var validator = new LibraryValidator();
var exceptions = validator.Validate(importItems.Select(i => i.DtoItem));
if (exceptions?.Any() ?? false)
{
Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
using var context = DbContexts.GetContext();
var bookImporter = new BookImporter(context);
await Task.Run(() => bookImporter.Import(importItems));
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId));
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
context.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
try
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(finalizeLibrarySizeChange);
return qtyChanged;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();

View File

@ -149,7 +149,7 @@ namespace AudibleUtilities
foreach (var parent in items.Where(i => i.IsSeriesParent))
{
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
setSeries(parent, children);
SetSeries(parent, children);
}
sw.Stop();
@ -232,7 +232,7 @@ namespace AudibleUtilities
finally { semaphore.Release(); }
}
private static void setSeries(Item parent, IEnumerable<Item> children)
public static void SetSeries(Item parent, IEnumerable<Item> children)
{
//A series parent will always have exactly 1 Series
parent.Series = new[]
@ -267,4 +267,4 @@ namespace AudibleUtilities
}
#endregion
}
}
}

View File

@ -8,7 +8,7 @@ namespace DataLayer
internal int BookId { get; private set; }
public string Order { get; private set; }
public float Index { get; }
public float Index => StringLib.ExtractFirstNumber(Order);
public Series Series { get; private set; }
public Book Book { get; private set; }
@ -22,8 +22,7 @@ namespace DataLayer
Series = series;
Book = book;
Order = order;
Index = StringLib.ExtractFirstNumber(Order);
}
}
public void UpdateOrder(string order)
{

View File

@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Common", "..\..\audible api\AudibleApi\AudibleApi.Common\AudibleApi.Common.csproj", "{093E79B6-9A57-46FE-8406-DB16BADAD427}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -219,6 +223,14 @@ Global
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU
{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.Build.0 = Release|Any CPU
{093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.Build.0 = Debug|Any CPU
{093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.ActiveCfg = Release|Any CPU
{093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using System;
@ -14,7 +15,14 @@ namespace LibationAvalonia.Controls
public LinkLabel()
{
InitializeComponent();
Tapped += LinkLabel_Tapped;
}
private void LinkLabel_Tapped(object sender, TappedEventArgs e)
{
Foreground = Brushes.Purple;
}
protected override void OnPointerEntered(PointerEventArgs e)
{
this.Cursor = HandCursor;

View File

@ -114,12 +114,20 @@ namespace LibationAvalonia.ViewModels
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList, FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
//Add all children beneath their parent
foreach (var series in SOURCE.OfType<ISeriesEntry>().ToList())
{
var seriesIndex = SOURCE.IndexOf(series);
foreach (var child in series.Children)
SOURCE.Insert(++seriesIndex, child);
}
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
GridEntries_CollectionChanged();
}
@ -253,13 +261,15 @@ namespace LibationAvalonia.ViewModels
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new LibraryBookEntry<AvaloniaEntryStatus>(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
int seriesIndex = SOURCE.IndexOf(seriesEntry);
SOURCE.Insert(seriesIndex + 1, episodeEntry);
int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry);
SOURCE.Insert(seriesIndex + 1 + episodeIndex, episodeEntry);
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);

View File

@ -1,4 +1,5 @@
using LibationFileManager;
using LibationUiBase;
using System;
using System.IO;
using System.Linq;
@ -21,6 +22,8 @@ namespace LibationAvalonia.Views
App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg").CopyTo(ms3);
PictureStorage.SetDefaultImage(PictureSize._500x500, ms3.ToArray());
PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray());
BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault);
}
}
}

View File

@ -215,6 +215,19 @@ namespace LibationAvalonia.Views
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
}
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
{
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
var viewSeriesMenuItem = new MenuItem { Header = header };
args.ContextMenuItems.Add(viewSeriesMenuItem);
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
}
#endregion
}
else

View File

@ -0,0 +1,32 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Views.SeriesViewDialog"
Icon="/Assets/libation.ico"
Title="View All Items in Series">
<TabControl
Items="{Binding TabItems}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="23"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,5"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="20,5,20,5"/>
</Style>
</TabControl.Styles>
</TabControl>
</Window>

View File

@ -0,0 +1,70 @@
using AudibleApi.Common;
using AudibleApi;
using Avalonia.Controls;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Collections;
using LibationAvalonia.Dialogs;
using LibationUiBase.SeriesView;
using System;
using Avalonia.Media;
namespace LibationAvalonia.Views
{
public partial class SeriesViewDialog : DialogWindow
{
private readonly LibraryBook LibraryBook;
public AvaloniaList<TabItem> TabItems { get; } = new();
public SeriesViewDialog()
{
InitializeComponent();
DataContext = this;
if (Design.IsDesignMode)
{
TabItems.Add(new TabItem { Header = "This is a Header", FontSize = 14, Content = new TextBlock { Text = "Some Text" } });
}
else
{
Loaded += SeriesViewDialog_Loaded;
}
}
public SeriesViewDialog(LibraryBook libraryBook) : this()
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook");
}
private async void SeriesViewDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
try
{
var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook);
foreach (var series in seriesEntries.Keys)
{
TabItems.Add(new TabItem
{
Header = series.Title,
FontSize = 14,
Content = new SeriesViewGrid(LibraryBook, series, seriesEntries[series])
});
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading searies info");
TabItems.Add(new TabItem
{
Header = "ERROR",
Content = new TextBlock { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, Foreground = Brushes.Red }
});
}
}
}
}

View File

@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
x:Class="LibationAvalonia.Views.SeriesViewGrid">
<DataGrid
ClipboardCopyMode="IncludeHeader"
GridLinesVisibility="All"
AutoGenerateColumns="False"
Items="{Binding SeriesEntries}"
CanUserSortColumns="True"
CanUserReorderColumns="True"
BorderThickness="3">
<DataGrid.Styles>
<Style Selector="DataGridCell">
<Setter Property="Height" Value="80"/>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>
<Style Selector="DataGridCell > Panel > TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Padding" Value="4"/>
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTemplateColumn Width="80" Header="Cover" CanUserSort="False">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Image
Tapped="Cover_Click"
Height="80"
Source="{Binding Cover}"
ToolTip.Tip="Click to see full size" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="Auto" Header="Series&#xa;Order" CanUserSort="True" SortMemberPath="Order">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel>
<TextBlock
Text="{Binding Order}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="Auto" Header="Availability" CanUserSort="True" SortMemberPath="Button">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel>
<Button
Padding="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Click="Availability_Click"
IsVisible="{Binding Button.HasButtonAction}"
IsEnabled="{Binding Button.Enabled}">
<TextBlock
Text="{Binding Button.DisplayText}"
TextAlignment="Center"
VerticalAlignment="Center" />
</Button>
<TextBlock
HorizontalAlignment="Center"
IsVisible="{Binding !Button.HasButtonAction}"
Text="{Binding Button.DisplayText}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn MinWidth="150" Width="*" Header="Title" CanUserSort="True" SortMemberPath="Title">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel ToolTip.Tip="Open Audible product page">
<controls:LinkLabel
VerticalAlignment="Center"
Text="{Binding Title}"
Tapped="Title_Click" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</UserControl>

View File

@ -0,0 +1,90 @@
using ApplicationServices;
using AudibleApi.Common;
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using DataLayer;
using Dinah.Core;
using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationFileManager;
using LibationUiBase.SeriesView;
using System.Collections.Generic;
using System.Linq;
namespace LibationAvalonia.Views
{
public partial class SeriesViewGrid : UserControl
{
private ImageDisplayDialog imageDisplayDialog;
private readonly LibraryBook LibraryBook;
public AvaloniaList<SeriesItem> SeriesEntries { get; } = new();
public SeriesViewGrid()
{
InitializeComponent();
DataContext = this;
}
public SeriesViewGrid(LibraryBook libraryBook, Item series, List<SeriesItem> entries) : this()
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
ArgumentValidator.EnsureNotNull(series, nameof(series));
ArgumentValidator.EnsureNotNull(entries, nameof(entries));
SeriesEntries.AddRange(entries.OrderBy(s => s.Order));
}
public async void Availability_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
if (sender is Button button && button.DataContext is SeriesItem sentry && sentry.Button.HasButtonAction)
{
await sentry.Button.PerformClickAsync(LibraryBook);
}
}
public void Title_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is not LinkLabel label || label.DataContext is not SeriesItem sentry)
return;
sentry.ViewOnAudible(LibraryBook.Book.Locale);
}
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is not Image tblock || tblock.DataContext is not SeriesItem sentry)
return;
Item libraryBook = sentry.Item;
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
{
imageDisplayDialog = new ImageDisplayDialog();
}
var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native);
void PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == picDef.PictureId)
imageDisplayDialog.SetCoverBytes(e.Picture);
PictureStorage.PictureCached -= PictureCached;
}
PictureStorage.PictureCached += PictureCached;
(bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef);
var windowTitle = $"{libraryBook.Title} - Cover";
imageDisplayDialog.Title = windowTitle;
imageDisplayDialog.SetCoverBytes(initialImageBts);
if (!isDefault)
PictureStorage.PictureCached -= PictureCached;
if (!imageDisplayDialog.IsVisible)
imageDisplayDialog.Show();
}
}
}

View File

@ -0,0 +1,13 @@
using LibationFileManager;
using System;
namespace LibationUiBase
{
public static class BaseUtil
{
/// <summary>A delegate that loads image bytes into the the UI framework's image format.</summary>
public static Func<byte[], PictureSize, object> LoadImage { get; private set; }
public static void SetLoadImageDelegate(Func<byte[], PictureSize, object> tryLoadImage)
=> LoadImage = tryLoadImage;
}
}

View File

@ -0,0 +1,133 @@
using ApplicationServices;
using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using FileLiberator;
using System;
using System.Linq;
using Dinah.Core;
using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
{
internal class AyceButton : SeriesButton
{
//Making this event and field static prevents concurrent additions to the library.
//Search engine indexer does not support concurrent re-indexing.
private static event EventHandler ButtonEnabled;
private static bool globalEnabled = true;
public override bool HasButtonAction => true;
public override string DisplayText
=> InLibrary ? "Remove\r\nFrom\r\nLibrary"
: "FREE\r\n\r\nAdd to\r\nLibrary";
public override bool Enabled
{
get => globalEnabled;
protected set
{
if (globalEnabled != value)
{
globalEnabled = value;
ButtonEnabled?.Invoke(null, EventArgs.Empty);
}
}
}
internal AyceButton(Item item, bool inLibrary) : base(item, inLibrary)
{
ButtonEnabled += DownloadButton_ButtonEnabled;
}
public override async Task PerformClickAsync(LibraryBook accountBook)
{
if (!Enabled) return;
Enabled = false;
try
{
if (InLibrary)
await RemoveFromLibraryAsync(accountBook);
else
await AddToLibraryAsync(accountBook);
}
catch(Exception ex)
{
var addRemove = InLibrary ? "remove" : "add";
var toFrom = InLibrary ? "from" : "to";
Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} library", new { Item.ProductId, Item.TitleWithSubtitle });
}
finally { Enabled = true; }
}
private async Task RemoveFromLibraryAsync(LibraryBook accountBook)
{
Api api = await accountBook.GetApiAsync();
if (await api.RemoveItemFromLibraryAsync(Item.ProductId))
{
using var context = DbContexts.GetContext();
var lb = context.GetLibraryBook_Flat_NoTracking(Item.ProductId);
int result = await Task.Run((new[] { lb }).PermanentlyDeleteBooks);
InLibrary = result == 0;
}
}
private async Task AddToLibraryAsync(LibraryBook accountBook)
{
Api api = await accountBook.GetApiAsync();
if (!await api.AddItemToLibraryAsync(Item.ProductId)) return;
Item item = null;
for (int tryCount = 0; tryCount < 5 && item is null; tryCount++)
{
//Wait a half second to allow the server time to update
await Task.Delay(500);
item = await api.GetLibraryBookAsync(Item.ProductId, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
}
if (item is null) return;
if (item.IsEpisodes)
{
var seriesParent = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)
.Select(lb => lb.Book)
.FirstOrDefault(b => b.IsEpisodeParent() && b.AudibleProductId.In(item.Relationships.Select((Relationship r) => r.Asin)));
if (seriesParent is null) return;
item.Series = new[]
{
new AudibleApi.Common.Series
{
Asin = seriesParent.AudibleProductId,
Sequence = item.Relationships.FirstOrDefault(r => r.Asin == seriesParent.AudibleProductId)?.Sort?.ToString() ?? "0",
Title = seriesParent.Title
}
};
}
InLibrary = await LibraryCommands.ImportSingleToDbAsync(item, accountBook.Account, accountBook.Book.Locale) != 0;
}
private void DownloadButton_ButtonEnabled(object sender, EventArgs e)
=> OnPropertyChanged(nameof(Enabled));
public override int CompareTo(object ob)
{
if (ob is not AyceButton other) return 1;
return other.InLibrary.CompareTo(InLibrary);
}
~AyceButton()
{
ButtonEnabled -= DownloadButton_ButtonEnabled;
}
}
}

View File

@ -0,0 +1,51 @@
using AudibleApi.Common;
using DataLayer;
using Dinah.Core.Threading;
using System;
using System.ComponentModel;
using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
{
/// <summary>
/// base view model for the Series Viewer 'Availability' button column
/// </summary>
public abstract class SeriesButton : SynchronizeInvoker, IComparable, INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private bool inLibrary;
protected Item Item { get; }
public abstract string DisplayText { get; }
public abstract bool HasButtonAction { get; }
public abstract bool Enabled { get; protected set; }
public bool InLibrary
{
get => inLibrary;
protected set
{
if (inLibrary != value)
{
inLibrary = value;
OnPropertyChanged(nameof(InLibrary));
OnPropertyChanged(nameof(DisplayText));
}
}
}
protected SeriesButton(Item item, bool inLibrary)
{
Item = item;
this.inLibrary = inLibrary;
}
public abstract Task PerformClickAsync(LibraryBook accountBook);
protected void OnPropertyChanged(string propertyName)
=> Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
public override string ToString() => DisplayText;
public abstract int CompareTo(object ob);
}
}

View File

@ -0,0 +1,151 @@
using ApplicationServices;
using AudibleApi;
using AudibleApi.Common;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator;
using LibationFileManager;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
{
public class SeriesItem : SynchronizeInvoker, INotifyPropertyChanged
{
public object Cover { get; private set; }
public SeriesOrder Order { get; }
public string Title => Item.TitleWithSubtitle;
public SeriesButton Button { get; }
public Item Item { get; }
public event PropertyChangedEventHandler PropertyChanged;
private SeriesItem(Item item, string order, bool inLibrary, bool inWishList)
{
Item = item;
Order = new SeriesOrder(order);
Button = Item.Plans.Any(p => p.IsAyce) ? new AyceButton(item, inLibrary) : new WishlistButton(item, inLibrary, inWishList);
LoadCover(item.PictureId);
Button.PropertyChanged += DownloadButton_PropertyChanged;
}
public void ViewOnAudible(string localeString)
{
var locale = Localization.Get(localeString);
var link = $"https://www.audible.{locale.TopDomain}/pd/{Item.ProductId}";
Go.To.Url(link);
}
private void DownloadButton_PropertyChanged(object sender, PropertyChangedEventArgs e)
=> OnPropertyChanged(nameof(Button));
private void OnPropertyChanged(string propertyName)
=> Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
private void LoadCover(string pictureId)
{
var (isDefault, picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80));
if (isDefault)
{
PictureStorage.PictureCached += PictureStorage_PictureCached;
}
Cover = BaseUtil.LoadImage(picture, PictureSize._80x80);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
if (e?.Definition.PictureId != null && Item?.PictureId != null)
{
byte[] picture = e.Picture;
if ((picture == null || picture.Length != 0) && e.Definition.PictureId == Item.PictureId)
{
Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
OnPropertyChanged(nameof(Cover));
}
}
}
public static async Task<Dictionary<Item, List<SeriesItem>>> GetAllSeriesItemsAsync(LibraryBook libraryBook)
{
var api = await libraryBook.GetApiAsync();
//Get Item for each series that this book belong to
var seriesItemsTask = api.GetCatalogProductsAsync(libraryBook.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId), CatalogOptions.ResponseGroupOptions.Media | CatalogOptions.ResponseGroupOptions.Relationships);
using var semaphore = new SemaphoreSlim(10);
//Start getting the wishlist in the background
var wishlistTask = api.GetWishListProductsAsync(
new WishListOptions
{
PageNumber = 0,
NumberOfResultPerPage = 50,
ResponseGroups = WishListOptions.ResponseGroupOptions.None
},
numItemsPerRequest: 50,
semaphore);
var items = new Dictionary<Item, List<Item>>();
//Get all children of all series
foreach (var series in await seriesItemsTask)
{
//Books that are part of series have RelationshipType.Series
//Podcast episodes have RelationshipType.Episode
var childrenAsins = series.Relationships
.Where(r => r.RelationshipType is RelationshipType.Series or RelationshipType.Episode && r.RelationshipToProduct is RelationshipToProduct.Child)
.Select(r => r.Asin)
.ToList();
if (childrenAsins.Count > 0)
{
var children = await api.GetCatalogProductsAsync(childrenAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS, 50, semaphore);
//If the price is null, this item is not available to the user
var childrenWithPrices = children.Where(p => p.Price != null).ToList();
if (childrenWithPrices.Count > 0)
items[series] = childrenWithPrices;
}
}
//Await the wishlist asins
var wishlistAsins = (await wishlistTask).Select(w => w.Asin).ToHashSet();
var fullLib = DbContexts.GetLibrary_Flat_NoTracking();
var seriesEntries = new Dictionary<Item, List<SeriesItem>>();
//Create a SeriesItem liste for each series.
foreach (var series in items.Keys)
{
ApiExtended.SetSeries(series, items[series]);
seriesEntries[series] = new List<SeriesItem>();
foreach (var item in items[series].Where(i => !string.IsNullOrEmpty(i.PictureId)))
{
var order = item.Series.Single(s => s.Asin == series.Asin).Sequence;
//Match the account/book in the database
var inLibrary = fullLib.Any(lb => lb.Account == libraryBook.Account && lb.Book.AudibleProductId == item.ProductId && !lb.AbsentFromLastScan);
var inWishList = wishlistAsins.Contains(item.Asin);
seriesEntries[series].Add(new SeriesItem(item, order, inLibrary, inWishList));
}
}
return seriesEntries;
}
~SeriesItem()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
Button.PropertyChanged -= DownloadButton_PropertyChanged;
}
}
}

View File

@ -0,0 +1,23 @@
using System;
namespace LibationUiBase.SeriesView
{
public class SeriesOrder : IComparable
{
public float Order { get; }
public string OrderString { get; }
public SeriesOrder(string orderString)
{
OrderString = orderString;
Order = float.TryParse(orderString, out var o) ? o : -1f;
}
public override string ToString() => OrderString;
public int CompareTo(object obj)
{
if (obj is not SeriesOrder other) return 1;
return Order.CompareTo(other.Order);
}
}
}

View File

@ -0,0 +1,93 @@
using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using FileLiberator;
using System;
using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
{
internal class WishlistButton : SeriesButton
{
private bool instanceEnabled = true;
private bool inWishList;
public override bool HasButtonAction => !InLibrary;
public override string DisplayText
=> InLibrary ? "Already\r\nOwned"
: InWishList ? "Remove\r\nFrom\r\nWishlist"
: "Add to\r\nWishlist";
public override bool Enabled
{
get => instanceEnabled;
protected set
{
if (instanceEnabled != value)
{
instanceEnabled = value;
OnPropertyChanged(nameof(Enabled));
}
}
}
private bool InWishList
{
get => inWishList;
set
{
if (inWishList != value)
{
inWishList = value;
OnPropertyChanged(nameof(InWishList));
OnPropertyChanged(nameof(DisplayText));
}
}
}
internal WishlistButton(Item item, bool inLibrary, bool inWishList) : base(item, inLibrary)
{
this.inWishList = inWishList;
}
public override async Task PerformClickAsync(LibraryBook accountBook)
{
if (!Enabled || !HasButtonAction) return;
Enabled = false;
try
{
Api api = await accountBook.GetApiAsync();
if (InWishList)
{
await api.DeleteFromWishListAsync(Item.Asin);
InWishList = false;
}
else
{
await api.AddToWishListAsync(Item.Asin);
InWishList = true;
}
}
catch (Exception ex)
{
var addRemove = InWishList ? "remove" : "add";
var toFrom = InWishList ? "from" : "to";
Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} wish list", new { Item.ProductId, Item.TitleWithSubtitle });
}
finally { Enabled = true; }
}
public override int CompareTo(object ob)
{
if (ob is not WishlistButton other) return -1;
int libcmp = other.InLibrary.CompareTo(InLibrary);
return (libcmp == 0) ? other.InWishList.CompareTo(InWishList) : libcmp;
}
}
}

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using ApplicationServices;
using Dinah.Core.WindowsDesktop.Drawing;
using LibationFileManager;
using LibationUiBase;
namespace LibationWinForms
{
@ -20,6 +21,8 @@ namespace LibationWinForms
PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format));
BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault);
// wire-up event to automatically download after scan.
// winforms only. this should NOT be allowed in cli
updateCountsBw.RunWorkerCompleted += (object sender, System.ComponentModel.RunWorkerCompletedEventArgs e) =>

View File

@ -41,7 +41,6 @@
productsGrid.TabIndex = 0;
productsGrid.VisibleCountChanged += productsGrid_VisibleCountChanged;
productsGrid.LiberateClicked += productsGrid_LiberateClicked;
productsGrid.ConvertToMp3Clicked += productsGrid_ConvertToMp3Clicked;
productsGrid.CoverClicked += productsGrid_CoverClicked;
productsGrid.DetailsClicked += productsGrid_DetailsClicked;
productsGrid.DescriptionClicked += productsGrid_DescriptionClicked;

View File

@ -5,6 +5,7 @@ using FileLiberator;
using LibationFileManager;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using LibationWinForms.SeriesView;
using System;
using System.Collections.Generic;
using System.Drawing;
@ -218,6 +219,20 @@ namespace LibationWinForms.GridView
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this);
}
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
{
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
var viewSeriesMenuItem = new ToolStripMenuItem { Text = header };
ctxMenu.Items.Add(viewSeriesMenuItem);
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
}
#endregion
}
@ -333,12 +348,6 @@ namespace LibationWinForms.GridView
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry)
{
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private void productsGrid_RemovableCountChanged(object sender, EventArgs e)
{
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true));

View File

@ -21,7 +21,6 @@ namespace LibationWinForms.GridView
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler LiberateClicked;
public event LibraryBookEntryClickedEventHandler ConvertToMp3Clicked;
public event GridEntryClickedEventHandler CoverClicked;
public event LibraryBookEntryClickedEventHandler DetailsClicked;
public event GridEntryRectangleClickedEventHandler DescriptionClicked;
@ -308,7 +307,8 @@ namespace LibationWinForms.GridView
//Add episode to the grid beneath the parent
int seriesIndex = bindingList.IndexOf(seriesEntry);
bindingList.Insert(seriesIndex + 1, episodeEntry);
int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry);
bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry);
if (seriesEntry.Liberate.Expanded)
bindingList.ExpandItem(seriesEntry);

View File

@ -0,0 +1,49 @@
using System.Drawing;
using System.Windows.Forms;
using System.Windows.Forms.VisualStyles;
using LibationUiBase.SeriesView;
namespace LibationWinForms.SeriesView
{
public class DownloadButtonColumn : DataGridViewButtonColumn
{
public DownloadButtonColumn()
{
CellTemplate = new DownloadButtonColumnCell();
CellTemplate.Style.WrapMode = DataGridViewTriState.True;
}
}
internal class DownloadButtonColumnCell : DataGridViewButtonCell
{
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
if (value is SeriesButton sentry)
{
string cellValue = sentry.DisplayText;
if (!sentry.Enabled)
{
//Draw disabled button
Rectangle buttonArea = cellBounds;
Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle);
buttonArea.X += buttonAdjustment.X;
buttonArea.Y += buttonAdjustment.Y;
buttonArea.Height -= buttonAdjustment.Height;
buttonArea.Width -= buttonAdjustment.Width;
ButtonRenderer.DrawButton(graphics, buttonArea, cellValue, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak, focused: false, PushButtonState.Disabled);
}
else if (sentry.HasButtonAction)
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, cellValue, cellValue, errorText, cellStyle, advancedBorderStyle, paintParts);
else
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border);
TextRenderer.DrawText(graphics, cellValue, cellStyle.Font, cellBounds, cellStyle.ForeColor);
}
}
else
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border);
}
}
}
}

View File

@ -0,0 +1,46 @@
using LibationUiBase.SeriesView;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.SeriesView
{
internal class SeriesEntryBindingList : BindingList<SeriesItem>
{
private PropertyDescriptor _propertyDescriptor;
private ListSortDirection _listSortDirection;
private bool _isSortedCore;
protected override PropertyDescriptor SortPropertyCore => _propertyDescriptor;
protected override ListSortDirection SortDirectionCore => _listSortDirection;
protected override bool IsSortedCore => _isSortedCore;
protected override bool SupportsSortingCore => true;
public SeriesEntryBindingList() : base(new List<SeriesItem>()) { }
public SeriesEntryBindingList(IEnumerable<SeriesItem> entries) : base(entries.ToList()) { }
protected override void ApplySortCore(PropertyDescriptor prop, ListSortDirection direction)
{
var itemsList = (List<SeriesItem>)base.Items;
var sorted
= (direction == ListSortDirection.Ascending)
? itemsList.OrderBy(prop.GetValue).ToList()
: itemsList.OrderByDescending(prop.GetValue).ToList();
itemsList.Clear();
itemsList.AddRange(sorted);
_propertyDescriptor = prop;
_listSortDirection = direction;
_isSortedCore = true;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
}
}

View File

@ -0,0 +1,62 @@
using System.Windows.Forms;
namespace LibationWinForms.SeriesView
{
partial class SeriesViewDialog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
tabControl1 = new TabControl();
SuspendLayout();
//
// tabControl1
//
tabControl1.Dock = DockStyle.Fill;
tabControl1.Location = new System.Drawing.Point(0, 0);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
tabControl1.Size = new System.Drawing.Size(800, 450);
tabControl1.TabIndex = 0;
//
// SeriesViewDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(800, 450);
Controls.Add(tabControl1);
FormBorderStyle = FormBorderStyle.SizableToolWindow;
Name = "SeriesViewDialog";
StartPosition = FormStartPosition.CenterParent;
Text = "View All Items in Series";
ResumeLayout(false);
}
private TabControl tabControl1;
#endregion
}
}

View File

@ -0,0 +1,193 @@
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using System.ComponentModel;
using System.Windows.Forms;
using System;
using Dinah.Core.WindowsDesktop.Forms;
using LibationWinForms.GridView;
using LibationFileManager;
using LibationUiBase.SeriesView;
using System.Drawing;
namespace LibationWinForms.SeriesView
{
public partial class SeriesViewDialog : Form
{
private readonly LibraryBook LibraryBook;
public SeriesViewDialog()
{
InitializeComponent();
this.RestoreSizeAndLocation(Configuration.Instance);
this.SetLibationIcon();
Load += SeriesViewDialog_Load;
FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
}
public SeriesViewDialog(LibraryBook libraryBook) : this()
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook");
}
private async void SeriesViewDialog_Load(object sender, EventArgs e)
{
try
{
var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook);
//Create a DataGridView for each series and add all children of that series to it.
foreach (var series in seriesEntries.Keys)
{
var dgv = createNewSeriesGrid();
dgv.CellContentClick += Dgv_CellContentClick;
dgv.DataSource = new SeriesEntryBindingList(seriesEntries[series]);
dgv.BindingContextChanged += (_, _) => dgv.Sort(dgv.Columns["Order"], ListSortDirection.Ascending);
var tab = new TabPage { Text = series.Title };
tab.Controls.Add(dgv);
tab.VisibleChanged += (_, _) => dgv.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells);
tabControl1.Controls.Add(tab);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading searies info");
var tab = new TabPage { Text = "ERROR" };
tab.Controls.Add(new Label { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, ForeColor = Color.Red, Dock = DockStyle.Fill });
tabControl1.Controls.Add(tab);
}
}
private ImageDisplay imageDisplay;
private async void Dgv_CellContentClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex < 0) return;
var dgv = (DataGridView)sender;
var sentry = dgv.GetBoundItem<SeriesItem>(e.RowIndex);
if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Cover))
{
coverClicked(sentry.Item);
return;
}
else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Title))
{
sentry.ViewOnAudible(LibraryBook.Book.Locale);
return;
}
else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Button) && sentry.Button.HasButtonAction)
{
await sentry.Button.PerformClickAsync(LibraryBook);
}
}
private void coverClicked(Item libraryBook)
{
var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native);
void PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == picDef.PictureId)
imageDisplay.SetCoverArt(e.Picture);
PictureStorage.PictureCached -= PictureCached;
}
PictureStorage.PictureCached += PictureCached;
(bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef);
var windowTitle = $"{libraryBook.Title} - Cover";
if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible)
{
imageDisplay = new ImageDisplay();
imageDisplay.RestoreSizeAndLocation(Configuration.Instance);
imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance);
}
imageDisplay.Text = windowTitle;
imageDisplay.SetCoverArt(initialImageBts);
if (!isDefault)
PictureStorage.PictureCached -= PictureCached;
if (!imageDisplay.Visible)
imageDisplay.Show();
}
private static DataGridView createNewSeriesGrid()
{
var dgv = new DataGridView
{
Dock = DockStyle.Fill,
RowHeadersVisible = false,
ReadOnly = false,
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AllowUserToResizeRows = false,
AutoGenerateColumns = false
};
dgv.RowTemplate.Height = 80;
dgv.Columns.Add(new DataGridViewImageColumn
{
DataPropertyName = nameof(SeriesItem.Cover),
HeaderText = "Cover",
Name = "Cover",
ReadOnly = true,
Resizable = DataGridViewTriState.False,
Width = 80
});
dgv.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(SeriesItem.Order),
HeaderText = "Series\r\nOrder",
Name = "Order",
ReadOnly = true,
SortMode = DataGridViewColumnSortMode.Automatic,
Width = 50
});
dgv.Columns.Add(new DownloadButtonColumn
{
DataPropertyName = nameof(SeriesItem.Button),
HeaderText = "Availability",
Name = "DownloadButton",
ReadOnly = true,
SortMode = DataGridViewColumnSortMode.Automatic,
Width = 50
});
dgv.Columns.Add(new DataGridViewLinkColumn
{
DataPropertyName = nameof(SeriesItem.Title),
HeaderText = "Title",
Name = "Title",
ReadOnly = true,
TrackVisitedState = true,
SortMode = DataGridViewColumnSortMode.Automatic,
Width = 200,
});
dgv.CellToolTipTextNeeded += Dgv_CellToolTipTextNeeded;
return dgv;
}
private static void Dgv_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e)
{
if (sender is not DataGridView dgv || e.ColumnIndex < 0) return;
e.ToolTipText = dgv.Columns[e.ColumnIndex].DataPropertyName switch
{
nameof(SeriesItem.Cover) => "Click to see full size",
nameof(SeriesItem.Title) => "Open Audible product page",
_ => string.Empty
};
}
}
}

View File

@ -0,0 +1,60 @@
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>