Add clip and bookmark viewer and exporter
This commit is contained in:
parent
6417aee780
commit
7eaa03e43c
10 changed files with 529 additions and 52 deletions
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml
Normal file
139
Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml
Normal 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>
|
||||
219
Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs
Normal file
219
Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue