Add clip and bookmark viewer and exporter

This commit is contained in:
Michael Bucari-Tovo 2023-01-05 23:40:39 -07:00
parent 6417aee780
commit 7eaa03e43c
10 changed files with 529 additions and 52 deletions

View file

@ -10,7 +10,7 @@ namespace LibationAvalonia.Controls
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs> CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
private static readonly AvaloniaList<MenuItem> MenuItems = new();
private static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
static DataGridContextMenus()
@ -65,7 +65,7 @@ namespace LibationAvalonia.Controls
public DataGridColumn Column { get; init; }
public GridEntry GridEntry { get; init; }
public ContextMenu ContextMenu { get; init; }
public AvaloniaList<MenuItem> ContextMenuItems
=> ContextMenu.Items as AvaloniaList<MenuItem>;
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.Items as AvaloniaList<Control>;
}
}

View file

@ -0,0 +1,139 @@
<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="700" d:DesignHeight="450"
Width="700" Height="450"
x:Class="LibationAvalonia.Dialogs.BookRecordsDialog"
Title="BookRecordsDialog"
Icon="/Assets/libation.ico">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
AutoGenerateColumns="False"
IsReadOnly="False"
Items="{Binding DataGridCollectionView}"
GridLinesVisibility="All">
<DataGrid.Styles>
<Style Selector="DataGridColumnHeader">
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<Style Selector="DataGridCell">
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridCheckBoxColumn
Width="Auto"
IsReadOnly="False"
Binding="{Binding IsChecked, Mode=TwoWay}"
Header="Checked"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Type}"
Header="Type"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Created}"
Header="Created"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Start}"
Header="Start"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Modified}"
Header="Modified"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding End}"
Header="End"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Note}"
Header="Note"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Title}"
Header="Title"/>
</DataGrid.Columns>
</DataGrid>
<Grid
Grid.Row="1"
Margin="10"
ColumnDefinitions="Auto,Auto,*,Auto"
RowDefinitions="Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,10,0,0"/>
<Setter Property="Height" Value="30"/>
</Style>
</Grid.Styles>
<Button
Grid.Column="0"
Grid.Row="0"
Content="Check All"
Click="CheckAll_Click"/>
<Button
Grid.Column="0"
Grid.Row="1"
Content="Uncheck All"
Click="UncheckAll_Click"/>
<Button
Grid.Column="1"
Grid.Row="0"
Margin="20,10,0,0"
Content="Delete Checked"
Click="DeleteChecked_Click"/>
<Button
Grid.Column="1"
Grid.Row="1"
Margin="20,10,0,0"
Content="Reload All"
Click="ReloadAll_Click"/>
<Button
Grid.Column="3"
Grid.Row="0"
Content="Export Checked"
Click="ExportChecked_Click"/>
<Button
Grid.Column="3"
Grid.Row="1"
Content="Export All"
Click="ExportAll_Click"/>
</Grid>
</Grid>
</Window>

View file

@ -0,0 +1,219 @@
using ApplicationServices;
using AudibleApi.Common;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DataLayer;
using FileLiberator;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class BookRecordsDialog : DialogWindow
{
public DataGridCollectionView DataGridCollectionView { get; }
private readonly AvaloniaList<BookRecordEntry> bookRecordEntries = new();
private readonly LibraryBook libraryBook;
public BookRecordsDialog()
{
InitializeComponent();
if (Design.IsDesignMode)
{
bookRecordEntries.Add(new BookRecordEntry(new Clip(DateTimeOffset.Now.AddHours(1), TimeSpan.FromHours(6.8667), "xxxxxxx", DateTimeOffset.Now.AddHours(1), TimeSpan.FromHours(6.8668), "Note 2", "title 2")));
bookRecordEntries.Add(new BookRecordEntry(new Clip(DateTimeOffset.Now, TimeSpan.FromHours(4.5667), "xxxxxxx", DateTimeOffset.Now, TimeSpan.FromHours(4.5668), "Note", "title")));
}
DataGridCollectionView = new DataGridCollectionView(bookRecordEntries);
DataContext = this;
}
public BookRecordsDialog(LibraryBook libraryBook) : this()
{
this.libraryBook = libraryBook;
Title = $"{libraryBook.Book.Title} - Clips and Bookmarks";
Loaded += BookRecordsDialog_Loaded;
}
private async void BookRecordsDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries.AddRange(records.Select(r => new BookRecordEntry(r)));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to retrieve records for {libraryBook}", libraryBook);
}
}
#region Buttons
private async Task setControlEnabled(object control, bool enabled)
{
if (control is InputElement c)
await Dispatcher.UIThread.InvokeAsync(() => c.IsEnabled = enabled);
}
public async void ExportChecked_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record));
await setControlEnabled(sender, true);
}
public async void ExportAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Select(r => r.Record));
await setControlEnabled(sender, true);
}
public void CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = true;
}
public void UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = false;
}
public async void DeleteChecked_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var records = bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record).ToList();
if (!records.Any()) return;
await setControlEnabled(sender, false);
bool success = false;
try
{
var api = await libraryBook.GetApiAsync();
success = await api.DeleteRecordsAsync(libraryBook.Book.AudibleProductId, records);
records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
var removed = bookRecordEntries.ExceptBy(records, r => r.Record).ToList();
foreach (var r in removed)
bookRecordEntries.Remove(r);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, ex.Message);
}
finally { await setControlEnabled(sender, true); }
if (!success)
await MessageBox.Show(this, $"Libation was unable to delete the {records.Count} selected records", "Deletion Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
public async void ReloadAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries.Clear();
bookRecordEntries.AddRange(records.Select(r => new BookRecordEntry(r)));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, ex.Message);
await MessageBox.Show(this, $"Libation was unable to reload records", "Reload Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally { await setControlEnabled(sender, true); }
}
#endregion
private async Task saveRecords(IEnumerable<IRecord> records)
{
if (!records.Any()) return;
try
{
var saveFileDialog =
await Dispatcher.UIThread.InvokeAsync(() => new FilePickerSaveOptions
{
Title = "Where to export book records",
SuggestedFileName = $"{libraryBook.Book.Title} - Records",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "*.xlsx" } },
new("CSV files (*.csv)") { Patterns = new[] { "*.csv" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
new("All files (*.*)") { Patterns = new[] { "*" } }
}
});
var selectedFile = await StorageProvider.SaveFilePickerAsync(saveFileDialog);
if (selectedFile?.TryGetUri(out var uri) is not true) return;
var ext = System.IO.Path.GetExtension(uri.LocalPath).ToLowerInvariant();
switch (ext)
{
case ".xlsx":
default:
await Task.Run(() => RecordExporter.ToXlsx(uri.LocalPath, records));
break;
case ".csv":
await Task.Run(() => RecordExporter.ToCsv(uri.LocalPath, records));
break;
case ".json":
await Task.Run(() => RecordExporter.ToJson(uri.LocalPath, libraryBook, records));
break;
}
}
catch (Exception ex)
{
await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
}
}
#region DataGrid Bindings
private class BookRecordEntry : ViewModels.ViewModelBase
{
private const string DateFormat = "yyyy-MM-dd HH\\:mm";
private bool _ischecked;
public IRecord Record { get; }
public bool IsChecked { get => _ischecked; set => this.RaiseAndSetIfChanged(ref _ischecked, value); }
public string Type => Record.GetType().Name;
public string Start => formatTimeSpan(Record.Start);
public string Created => Record.Created.ToString(DateFormat);
public string Modified => Record is IAnnotation annotation ? annotation.Created.ToString(DateFormat) : string.Empty;
public string End => Record is IRangeAnnotation range ? formatTimeSpan(range.End) : string.Empty;
public string Note => Record is IRangeAnnotation range ? range.Text : string.Empty;
public string Title => Record is Clip range ? range.Title : string.Empty;
public BookRecordEntry(IRecord record) => Record = record;
private static string formatTimeSpan(TimeSpan timeSpan)
{
int h = (int)timeSpan.TotalHours;
int m = timeSpan.Minutes;
int s = timeSpan.Seconds;
int ms = timeSpan.Milliseconds;
return ms == 0 ? $"{h:d2}:{m:d2}:{s:d2}" : $"{h:d2}:{m:d2}:{s:d2}.{ms:d3}";
}
}
#endregion
}
}

View file

@ -37,7 +37,7 @@
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Process Queue</TextBlock>
</TabItem.Header>
<Grid Background="AliceBlue" ColumnDefinitions="*" RowDefinitions="*,40">
<Grid ColumnDefinitions="*" RowDefinitions="*,40">
<Border Grid.Column="0" Grid.Row="0" BorderThickness="1" BorderBrush="{DynamicResource DataGridGridLinesBrush}" Background="WhiteSmoke">
<ScrollViewer
Name="scroller"

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using ApplicationServices;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
@ -132,12 +131,17 @@ namespace LibationAvalonia.Views
}
};
args.ContextMenuItems.AddRange(new[]
var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" };
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
args.ContextMenuItems.AddRange(new Control[]
{
setDownloadMenuItem,
setNotDownloadMenuItem,
removeMenuItem,
locateFileMenuItem
locateFileMenuItem,
new Separator(),
bookRecordMenuItem
});
}
else